From 413492c3db74dcaeb5469768abac4322d0043972 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Thu, 13 Feb 2025 22:29:52 +0000 Subject: [PATCH 01/13] auth: add bearer token support and domain validation Signed-off-by: modoulo boly sow --- go.mod | 3 +- pkg/auth/oidc_connect.go | 102 ++++++++++++++++++++++++++++++++++----- pkg/config/config.go | 3 ++ 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 036cfb5..ce47307 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module github.com/projectcontour/contour-authserver -go 1.23 +go 1.23.0 + toolchain go1.23.2 require ( diff --git a/pkg/auth/oidc_connect.go b/pkg/auth/oidc_connect.go index f34d9b1..676b2ee 100644 --- a/pkg/auth/oidc_connect.go +++ b/pkg/auth/oidc_connect.go @@ -88,18 +88,29 @@ func (o *OIDCConnect) isValidState(ctx context.Context, req *Request, url *url.U // Do we have stateid stored in querystring var state *store.OIDCState - stateToken := url.Query().Get(stateQueryParamName) - - stateByte, err := o.Cache.Get(stateToken) - if err == nil { - state = store.ConvertToType(stateByte) - } + // Check if there's a bearer token in the Authorization header + authHeader := req.Request.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + // Extract the token + token := strings.TrimPrefix(authHeader, "Bearer ") + // Create new state with the token + state = store.NewState() + state.Status = store.StatusTokenReady + state.IDToken = token + } else { + stateToken := url.Query().Get(stateQueryParamName) - // State not found, try to retrieve from cookies. - if state == nil { - state, _ = o.getStateFromCookie(req) + stateByte, err := o.Cache.Get(stateToken) + if err == nil { + state = store.ConvertToType(stateByte) + } + // State not found, try to retrieve from cookies. + if state == nil { + state, _ = o.getStateFromCookie(req) + } } + // State exists, proceed with token validation. // State exists, proceed with token validation. if state != nil { // Re-initialize provider to refresh the context, this seems like bugs with coreos go-oidc module. @@ -116,7 +127,7 @@ func (o *OIDCConnect) isValidState(ctx context.Context, req *Request, url *url.U resp.Response.Header.Add(oauthTokenName, string(stateJSON)) - if err := o.Cache.Delete(state.OAuthState); err != nil { + if err := o.Cache.Delete(state.OAuthState); err != nil && err != bigcache.ErrEntryNotFound { o.Log.Error(err, "error deleting state") } @@ -135,6 +146,13 @@ func (o *OIDCConnect) loginHandler(u *url.URL) Response { state.RequestPath = path.Join(u.Host, u.Path) state.Scheme = u.Scheme + config := o.oauth2Config() + + redirectURL := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) + if redirectURL != config.RedirectURL && matchDomain(redirectURL, o.OidcConfig.AuthorizedRedirectDomains) { + config.RedirectURL = redirectURL + } + authCodeURL := o.oauth2Config().AuthCodeURL(state.OAuthState) byteState := store.ConvertToByte(state) @@ -205,6 +223,9 @@ func (o *OIDCConnect) callbackHandler(ctx context.Context, u *url.URL) (Response resp.Response.Header.Add("Location", fmt.Sprintf("%s://%s?%s=%s", state.Scheme, state.RequestPath, stateQueryParamName, state.OAuthState)) + stateJSON, _ := json.Marshal(state) + resp.Response.Header.Add("Set-Cookie", fmt.Sprintf("%s=%s; Path=/; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) + return resp, nil } @@ -246,7 +267,6 @@ func (o *OIDCConnect) getStateFromCookie(req *Request) (*store.OIDCState, error) // Check through and get the right cookies if len(cookieVal) > 0 { cookies := strings.Split(cookieVal, ";") - for _, c := range cookies { c = strings.TrimSpace(c) if strings.HasPrefix(c, oauthTokenName) { @@ -280,7 +300,8 @@ func (o *OIDCConnect) oauth2Config() *oauth2.Config { ClientSecret: o.OidcConfig.ClientSecret, Endpoint: o.provider.Endpoint(), Scopes: o.OidcConfig.Scopes, - RedirectURL: o.OidcConfig.RedirectURL + o.OidcConfig.RedirectPath, + + RedirectURL: o.OidcConfig.RedirectURL + o.OidcConfig.RedirectPath, } } @@ -313,3 +334,60 @@ func parseURL(req *Request) *url.URL { return u } + +// matchDomain checks if a domain matches any of the allowed patterns. +func matchDomain(domain string, allowedPatterns []string) bool { + for _, pattern := range allowedPatterns { + if matchPattern(domain, pattern) { + return true + } + } + return false +} + +// matchPattern checks if a domain matches a single pattern with wildcards. +func matchPattern(domain, pattern string) bool { + // Split the pattern and domain into parts. + patternParts := strings.Split(pattern, ".") + domainParts := strings.Split(domain, ".") + + // If the number of parts doesn't match, it's not a match. + if len(patternParts) != len(domainParts) { + return false + } + + // Check each part of the pattern against the domain. + for i := range patternParts { + if !matchPart(domainParts[i], patternParts[i]) { + return false + } + } + + return true +} + +// matchPart checks if a single part of the domain matches the pattern part. +func matchPart(domainPart, patternPart string) bool { + // If the pattern part is a wildcard, it matches anything. + if patternPart == "*" { + return true + } + + // Split the pattern part by the wildcard. + parts := strings.Split(patternPart, "*") + + // Check if the domain part matches the pattern parts in sequence. + pos := 0 + for _, part := range parts { + if part == "" { + continue + } + index := strings.Index(domainPart[pos:], part) + if index == -1 { + return false + } + pos += index + len(part) + } + + return true +} diff --git a/pkg/config/config.go b/pkg/config/config.go index f0568aa..a321e7b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -42,6 +42,9 @@ type OIDCConfig struct { // T decide wether should this be use SessionSecurityKey string `yaml:"sessionSecurityKey" envconfig:"SESSION_SECURITY_KEY"` + + // AuthorizedRedirectDomains is the domain of the resource server. + AuthorizedRedirectDomains []string `yaml:"authorizedRedirectDomains"` } // NewConfig returns a Config struct from serialized config file. From 8e0feef58fb84c3b18b17d2eb5b59ddb3e693ecd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Feb 2025 10:10:28 -0500 Subject: [PATCH 02/13] Bump golang.org/x/oauth2 from 0.25.0 to 0.26.0 (#165) Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.25.0 to 0.26.0. - [Commits](https://github.com/golang/oauth2/compare/v0.25.0...v0.26.0) --- updated-dependencies: - dependency-name: golang.org/x/oauth2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Signed-off-by: modoulo boly sow --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ce47307..c6922a1 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.10.0 github.com/tg123/go-htpasswd v1.2.3 - golang.org/x/oauth2 v0.25.0 + golang.org/x/oauth2 v0.26.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a google.golang.org/grpc v1.70.0 gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index e01a0c5..d239159 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 8401a41d3526caea5375b87d002bc335766ab80a Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Fri, 14 Feb 2025 11:29:33 +0000 Subject: [PATCH 03/13] style: add newlines to improve code readability per linter suggestions Signed-off-by: modoulo boly sow --- pkg/auth/oidc_connect.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/auth/oidc_connect.go b/pkg/auth/oidc_connect.go index 676b2ee..4b18190 100644 --- a/pkg/auth/oidc_connect.go +++ b/pkg/auth/oidc_connect.go @@ -224,7 +224,12 @@ func (o *OIDCConnect) callbackHandler(ctx context.Context, u *url.URL) (Response fmt.Sprintf("%s://%s?%s=%s", state.Scheme, state.RequestPath, stateQueryParamName, state.OAuthState)) stateJSON, _ := json.Marshal(state) - resp.Response.Header.Add("Set-Cookie", fmt.Sprintf("%s=%s; Path=/; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) + resp.Response.Header.Add("Set-Cookie", + fmt.Sprintf("%s=%s; Path=/; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) + + // TODO(robinfoe) #18 : OIDC support should propagate any claims back to the request + resp.Response.Header.Add("Set-Cookie", + fmt.Sprintf("%s=%s; Path=/; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) return resp, nil } @@ -342,6 +347,7 @@ func matchDomain(domain string, allowedPatterns []string) bool { return true } } + return false } @@ -378,14 +384,18 @@ func matchPart(domainPart, patternPart string) bool { // Check if the domain part matches the pattern parts in sequence. pos := 0 + for _, part := range parts { if part == "" { continue } + index := strings.Index(domainPart[pos:], part) + if index == -1 { return false } + pos += index + len(part) } From 0bf5072acabcccece011e5d583eaac17ba2251e1 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Mon, 3 Mar 2025 18:17:06 +0000 Subject: [PATCH 04/13] feat: add token renewal cache and improve pattern matching --- Dockerfile | 2 +- config/oidc/configmap.yaml | 2 +- go.mod | 1 + go.sum | 2 + pkg/auth/oidc_connect.go | 569 ++++++++++++++++++++++++++++++------- pkg/cli/oidc_connect.go | 16 +- pkg/config/config.go | 2 +- 7 files changed, 481 insertions(+), 113 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5e14d9f..f6e1f43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on make build # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/base:nonroot WORKDIR / COPY --from=builder /workspace/bin/contour-authserver . USER nonroot:nonroot diff --git a/config/oidc/configmap.yaml b/config/oidc/configmap.yaml index 534b793..5da612d 100644 --- a/config/oidc/configmap.yaml +++ b/config/oidc/configmap.yaml @@ -5,7 +5,7 @@ metadata: data: auth-svr-config.yaml: | address: ":9443" - issuerURL: "http://dex.auth.app.192.168.10.134.nip.io:9080" + issuerURL: "https://keycloak.dev.gocno.io/realms/demo-realm" redirectURL: "https://echo.oidc.app.local:9443" redirectPath: "/callback" allowEmptyClientSecret: false diff --git a/go.mod b/go.mod index c6922a1..d705431 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/coreos/go-oidc/v3 v3.12.0 github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index d239159..a50ff2b 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= diff --git a/pkg/auth/oidc_connect.go b/pkg/auth/oidc_connect.go index 4b18190..3e761ec 100644 --- a/pkg/auth/oidc_connect.go +++ b/pkg/auth/oidc_connect.go @@ -20,11 +20,13 @@ import ( "net/http" "net/url" "path" + "regexp" "strings" "github.com/allegro/bigcache" "github.com/coreos/go-oidc/v3/oidc" "github.com/go-logr/logr" + "github.com/golang-jwt/jwt/v5" "github.com/projectcontour/contour-authserver/pkg/config" "github.com/projectcontour/contour-authserver/pkg/store" "golang.org/x/oauth2" @@ -42,6 +44,21 @@ type OIDCConnect struct { Cache *bigcache.BigCache HTTPClient *http.Client provider *oidc.Provider + // RenewedTokenCache stores user authentication information (idtoken and accesstoken) + // for 5 minutes when tokens are renewed. This avoids having to do a refresh token + // on every user request, since we cannot update user cookies during token renewal. + RenewedTokenCache *bigcache.BigCache +} + +type UserInfo struct { + Username string + Email string + EmailVerified bool + GivenName string + FamilyName string + Nickname string + Roles []string + Groups []string } // Implement interface. @@ -62,15 +79,38 @@ func (o *OIDCConnect) Check(ctx context.Context, req *Request) (*Response, error url := parseURL(req) + // check redirect url + if o.OidcConfig.RedirectURL == "" && len(o.OidcConfig.AuthorizedRedirectDomains) == 0 { + return &Response{}, fmt.Errorf("no redirectURL or AuthorizedRedirectDomains specified") + } else if len(o.OidcConfig.AuthorizedRedirectDomains) != 0 { + authorized := false + for _, domain := range o.OidcConfig.AuthorizedRedirectDomains { + domain = strings.TrimPrefix(domain, "*.") + if strings.HasSuffix(url.Host, domain) { + authorized = true + break + } + } + if !authorized { + return &Response{}, fmt.Errorf("redirectURL does not match") + } + + o.OidcConfig.RedirectURL = fmt.Sprintf("%s://%s", url.Scheme, url.Host) + + } + // Check if the current request matches the callback path. if url.Path == o.OidcConfig.RedirectPath { resp, err := o.callbackHandler(ctx, url) return &resp, err } + // Do we have stateid stored in querystring + state := o.GetState(ctx, req, url) // Validate the state. - resp, valid, err := o.isValidState(ctx, req, url) + resp, valid, err := o.isValidState(ctx, state) if err != nil { + o.Log.Error(err, "error validating state") return &resp, err } @@ -80,37 +120,22 @@ func (o *OIDCConnect) Check(ctx context.Context, req *Request) (*Response, error return &resp, nil } + userInfo, resp, err := o.GetUserInfo(ctx, state) + if err != nil { + return &resp, err + } + + // Validate the authorization. + resp, authorized, err := o.isAuthorized(req, &resp, url, userInfo) + if !authorized { + return &resp, err + } + return &resp, nil } // isValidState checks the user token and state validity for subsequent calls. -func (o *OIDCConnect) isValidState(ctx context.Context, req *Request, url *url.URL) (Response, bool, error) { - // Do we have stateid stored in querystring - var state *store.OIDCState - - // Check if there's a bearer token in the Authorization header - authHeader := req.Request.Header.Get("Authorization") - if strings.HasPrefix(authHeader, "Bearer ") { - // Extract the token - token := strings.TrimPrefix(authHeader, "Bearer ") - // Create new state with the token - state = store.NewState() - state.Status = store.StatusTokenReady - state.IDToken = token - } else { - stateToken := url.Query().Get(stateQueryParamName) - - stateByte, err := o.Cache.Get(stateToken) - if err == nil { - state = store.ConvertToType(stateByte) - } - // State not found, try to retrieve from cookies. - if state == nil { - state, _ = o.getStateFromCookie(req) - } - } - - // State exists, proceed with token validation. +func (o *OIDCConnect) isValidState(ctx context.Context, state *store.OIDCState) (Response, bool, error) { // State exists, proceed with token validation. if state != nil { // Re-initialize provider to refresh the context, this seems like bugs with coreos go-oidc module. @@ -120,25 +145,139 @@ func (o *OIDCConnect) isValidState(ctx context.Context, req *Request, url *url.U return createResponse(http.StatusInternalServerError), false, err } - if o.isValidStateToken(ctx, state, provider) { - stateJSON, _ := json.Marshal(state) - // Restore cookies. - resp := createResponse(http.StatusOK) + if !o.isValidStateToken(ctx, state, provider) { + if err := o.refreshTokens(ctx, state, provider); err != nil { + o.Log.Error(err, "fail to refresh tokens") + return Response{}, false, nil + } + } - resp.Response.Header.Add(oauthTokenName, string(stateJSON)) + stateJSON, _ := json.Marshal(state) + // Restore cookies. + resp := createResponse(http.StatusOK) - if err := o.Cache.Delete(state.OAuthState); err != nil && err != bigcache.ErrEntryNotFound { - o.Log.Error(err, "error deleting state") - } + resp.Response.Header.Add(oauthTokenName, string(stateJSON)) - return resp, true, nil + if err := o.Cache.Delete(state.OAuthState); err != nil && err != bigcache.ErrEntryNotFound { + o.Log.Error(err, "error deleting state") } + + return resp, true, nil } // return empty response, will direct to loginHandler return Response{}, false, nil } +// refreshTokens refreshes the access and ID tokens using the refresh token. +func (o *OIDCConnect) refreshTokens(ctx context.Context, state *store.OIDCState, provider *oidc.Provider) error { + o.Log.Info("refreshing tokens...") + tokenSource := o.oauth2Config().TokenSource(ctx, &oauth2.Token{ + RefreshToken: state.RefreshToken, + }) + + newToken, err := tokenSource.Token() + if err != nil { + o.Log.Error(err, "failed to refresh token") + return err + } + + // Get new ID token from the token response + rawIDToken, ok := newToken.Extra("id_token").(string) + if !ok { + return fmt.Errorf("no id_token in token response") + } + + // Verify the new ID token + verifier := provider.Verifier(&oidc.Config{ + ClientID: o.OidcConfig.ClientID, + SkipIssuerCheck: o.OidcConfig.SkipIssuerCheck, + }) + + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + o.Log.Error(err, "failed to verify refreshed ID token") + return err + } + // Try to claim. + var claims json.RawMessage + if err := idToken.Claims(&claims); err != nil { + o.Log.Error(err, "error decoding ID token") + return err + } + + // Update state with new id and access tokens + state.IDToken = rawIDToken + state.AccessToken = newToken.AccessToken + + // Only cache the ID token and access token since those are the only values + // that change during token refresh + stateToStore := store.NewState() + stateToStore.IDToken = rawIDToken + stateToStore.AccessToken = newToken.AccessToken + err = o.RenewedTokenCache.Set(state.OAuthState, store.ConvertToByte(stateToStore)) + if err != nil { + o.Log.Error(err, "error setting cache state") + } + + return nil +} + +func (o *OIDCConnect) GetState(ctx context.Context, req *Request, url *url.URL) *store.OIDCState { + var state *store.OIDCState + + stateToken := url.Query().Get(stateQueryParamName) + + stateByte, err := o.Cache.Get(stateToken) + if err == nil { + state = store.ConvertToType(stateByte) + } else { + // State not found, try to retrieve from cookies. + state, _ = o.getStateFromCookie(req) + } + + // Check if state has been updated and stored in RenewedTokenCache + if state != nil && state.OAuthState != "" { + + data, err := o.RenewedTokenCache.Get(state.OAuthState) + if err == nil { + cachedState := store.ConvertToType(data) + if cachedState != nil { + state.IDToken = cachedState.IDToken + state.AccessToken = cachedState.AccessToken + return state + } + } + + } + + return state +} + +// getStateFromCookie retrieve state token from cookie header and return the value as OIDCState. +func (o *OIDCConnect) getStateFromCookie(req *Request) (*store.OIDCState, error) { + var state *store.OIDCState + + cookieVal := req.Request.Header.Get("cookie") + + // Check through and get the right cookies + if len(cookieVal) > 0 { + cookies := strings.Split(cookieVal, ";") + for _, c := range cookies { + c = strings.TrimSpace(c) + if strings.HasPrefix(c, oauthTokenName) { + cookieJSON := c[len(oauthTokenName)+1:] + if len(cookieJSON) > 0 { + state = store.ConvertToType([]byte(cookieJSON)) + return state, nil + } + } + } + } + + return nil, fmt.Errorf("no %q cookie", oauthTokenName) +} + // loginHandler takes a url returning a Response with a new state that is required by oauth during initial user login. func (o *OIDCConnect) loginHandler(u *url.URL) Response { state := store.NewState() @@ -146,15 +285,10 @@ func (o *OIDCConnect) loginHandler(u *url.URL) Response { state.RequestPath = path.Join(u.Host, u.Path) state.Scheme = u.Scheme - config := o.oauth2Config() - - redirectURL := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) - if redirectURL != config.RedirectURL && matchDomain(redirectURL, o.OidcConfig.AuthorizedRedirectDomains) { - config.RedirectURL = redirectURL - } - authCodeURL := o.oauth2Config().AuthCodeURL(state.OAuthState) + o.Log.Info("Redirecting to", "authCodeURL", authCodeURL) + byteState := store.ConvertToByte(state) if err := o.Cache.Set(state.OAuthState, byteState); err != nil { o.Log.Error(err, "error setting cache state") @@ -225,11 +359,7 @@ func (o *OIDCConnect) callbackHandler(ctx context.Context, u *url.URL) (Response stateJSON, _ := json.Marshal(state) resp.Response.Header.Add("Set-Cookie", - fmt.Sprintf("%s=%s; Path=/; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) - - // TODO(robinfoe) #18 : OIDC support should propagate any claims back to the request - resp.Response.Header.Add("Set-Cookie", - fmt.Sprintf("%s=%s; Path=/; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) + fmt.Sprintf("%s=%s; Path=/; HttpOnly; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) return resp, nil } @@ -263,28 +393,252 @@ func (o *OIDCConnect) isValidStateToken(ctx context.Context, state *store.OIDCSt return true } -// getStateFromCookie retrieve state token from cookie header and return the value as OIDCState. -func (o *OIDCConnect) getStateFromCookie(req *Request) (*store.OIDCState, error) { - var state *store.OIDCState +// isAuthorized checks the user role and groups against the authorized +// roles and groups from the Auth-Context-%s headers. +func (o *OIDCConnect) isAuthorized( + req *Request, + resp *Response, + url *url.URL, + userInfo *UserInfo, +) (Response, bool, error) { + for rule, requiredPrivileges := range req.Context { + o.Log.Info("context", "rule", rule, "requiredPrivileges", requiredPrivileges) + if o.isRuleApplicableToMethodAndPath(rule, req.Request.Method, url.Path) { + o.Log.Info("rule applicable", "rule", rule, "requiredPrivileges", requiredPrivileges) + if !o.hasRequiredPermissions(rule, requiredPrivileges, url.Path, userInfo) { + o.Log.Info("rule not allowed", "rule", rule, "requiredPrivileges", requiredPrivileges, "userInfo", userInfo) + return createResponse(http.StatusUnauthorized), false, nil + } + o.Log.Info("rule allowed", "rule", rule, "requiredPrivileges", requiredPrivileges, "userInfo", userInfo) + } + } - cookieVal := req.Request.Header.Get("cookie") + // Propagate the user info to the response headers. + o.PropagateUserInfo(resp, userInfo) - // Check through and get the right cookies - if len(cookieVal) > 0 { - cookies := strings.Split(cookieVal, ";") - for _, c := range cookies { - c = strings.TrimSpace(c) - if strings.HasPrefix(c, oauthTokenName) { - cookieJSON := c[len(oauthTokenName)+1:] - if len(cookieJSON) > 0 { - state = store.ConvertToType([]byte(cookieJSON)) - return state, nil + return *resp, true, nil +} + +func (o *OIDCConnect) isRuleApplicableToMethodAndPath(rule, method, path string) bool { + parts := strings.Split(rule, ";") + if len(parts) > 3 { + return false + } else if len(parts) < 2 { + return true + } + + if len(parts) == 3 && !o.isMethodMarched(parts[0], method) { + return false + } + + if !o.isPathMatched(parts[1], path) { + return false + } + + return true +} + +func (o *OIDCConnect) isMethodMarched(methodsPart, method string) bool { + methods := strings.Split(methodsPart, "|") + return contains(methods, method) +} + +func (o *OIDCConnect) isPathMatched(pathsPart, path string) bool { + paths := strings.Split(pathsPart, "|") + for _, p := range paths { + if _, ok := o.matchPatternWithVars(p, path); ok { + return true + } + } + return false +} + +func (o *OIDCConnect) hasRequiredPermissions( + rule, requiredPrivileges string, + path string, + userInfo *UserInfo, +) bool { + parts := strings.Split(rule, ";") + privilegesType := parts[len(parts)-1] + userPrivileges := o.getUserPrivileges(privilegesType, userInfo) + + o.Log.Info("hasRequiredPermissions", "rule", rule, "requiredPrivileges", requiredPrivileges, "userPrivileges", userPrivileges) + + if len(parts) < 2 || len(parts) > 3 { + for _, requiredPrivilege := range strings.Split(requiredPrivileges, "|") { + if contains(userPrivileges, requiredPrivilege) { + return true + } + } + return false + } + + for _, requiredPrivilege := range strings.Split(requiredPrivileges, "|") { + pattern := parts[1] + if variables, ok := o.matchPatternWithVars(pattern, path); ok { + if o.isPrivilegeMatched(requiredPrivilege, userPrivileges, variables) { + return true + } + } + } + + return false +} + +func (o *OIDCConnect) isPrivilegeMatched(requiredPrivilege string, userPrivileges []string, variables map[string]string) bool { + for _, privilege := range userPrivileges { + if o.matchPrivilege(requiredPrivilege, privilege, variables) { + return true + } + } + return false +} + +func (o *OIDCConnect) getUserPrivileges(privilegesType string, userInfo *UserInfo) []string { + if privilegesType == "roles" || privilegesType == "required_roles" || privilegesType == "required_role" { + return userInfo.Roles + } else if privilegesType == "groups" || privilegesType == "required_groups" || privilegesType == "required_group" { + return userInfo.Groups + } + return []string{} +} + +// PropagateUserInfo propagates the user info to the response headers. +func (o *OIDCConnect) PropagateUserInfo(resp *Response, userInfo *UserInfo) { + if userInfo == nil { + return + } + + resp.Response.Header.Add("X-Auth-User-Username", userInfo.Username) + resp.Response.Header.Add("X-Auth-User-Email", userInfo.Email) + resp.Response.Header.Add("X-Auth-User-Email-Verified", fmt.Sprintf("%v", userInfo.EmailVerified)) + resp.Response.Header.Add("X-Auth-User-Given-Name", userInfo.GivenName) + resp.Response.Header.Add("X-Auth-User-Family-Name", userInfo.FamilyName) + resp.Response.Header.Add("X-Auth-User-Nickname", userInfo.Nickname) + resp.Response.Header.Add("X-Auth-User-Roles", strings.Join(userInfo.Roles, ",")) + resp.Response.Header.Add("X-Auth-User-Groups", strings.Join(userInfo.Groups, ",")) +} + +func (o *OIDCConnect) GetUserInfo(ctx context.Context, state *store.OIDCState) (*UserInfo, Response, error) { + if state == nil { + return nil, createResponse(http.StatusUnauthorized), fmt.Errorf("state is nil") + } + + verifier := o.provider.Verifier(&oidc.Config{ + ClientID: o.OidcConfig.ClientID, + SkipIssuerCheck: o.OidcConfig.SkipIssuerCheck, + }) + + // Verify token and signature. + idToken, err := verifier.Verify(ctx, state.IDToken) + if err != nil { + o.Log.Info(fmt.Sprintf("failed to verify ID token: %v", err)) + return nil, createResponse(http.StatusUnauthorized), err + } + + claims, err := o.extractClaims(idToken) + if err != nil { + return nil, createResponse(http.StatusUnauthorized), err + } + + userInfo, err := o.populateUserInfo(claims) + if err != nil { + return nil, createResponse(http.StatusUnauthorized), err + } + + if err := o.verifyAccessToken(idToken, state.AccessToken); err != nil { + return nil, createResponse(http.StatusUnauthorized), err + } + + roles, groups, err := o.extractRolesAndGroups(state.AccessToken) + if err != nil { + return nil, createResponse(http.StatusUnauthorized), err + } + + userInfo.Roles = roles + userInfo.Groups = groups + + return &userInfo, createResponse(http.StatusOK), nil +} + +func (o *OIDCConnect) extractClaims(idToken *oidc.IDToken) (map[string]interface{}, error) { + var claims json.RawMessage + if err := idToken.Claims(&claims); err != nil { + o.Log.Error(err, "error decoding ID token") + return nil, err + } + + var claimsMap map[string]interface{} + if err := json.Unmarshal(claims, &claimsMap); err != nil { + o.Log.Error(err, "error unmarshaling claims") + return nil, err + } + + return claimsMap, nil +} + +func (o *OIDCConnect) populateUserInfo(claims map[string]interface{}) (UserInfo, error) { + userInfo := UserInfo{} + + for k, v := range claims { + switch k { + case "username": + userInfo.Username = v.(string) + case "email": + userInfo.Email = v.(string) + case "email_verified": + userInfo.EmailVerified = v.(bool) + case "given_name": + userInfo.GivenName = v.(string) + case "family_name": + userInfo.FamilyName = v.(string) + case "nickname": + userInfo.Nickname = v.(string) + } + } + + return userInfo, nil +} + +func (o *OIDCConnect) verifyAccessToken(idToken *oidc.IDToken, accessToken string) error { + if accessToken != "" { + if err := idToken.VerifyAccessToken(accessToken); err != nil { + o.Log.Error(err, "access token not verified") + return err + } + } + return nil +} + +func (o *OIDCConnect) extractRolesAndGroups(accessToken string) ([]string, []string, error) { + token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) + if err != nil { + return nil, nil, fmt.Errorf("error parsing token: %v", err) + } + + roles := []string{} + groups := []string{} + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + o.Log.Info("get user infos", "claims", claims) + for k, v := range claims { + switch k { + case "realm_access": + realmAccess := v.(map[string]interface{}) + if rolesList, ok := realmAccess["roles"].([]interface{}); ok { + for _, role := range rolesList { + roles = append(roles, role.(string)) + } + } + case "groups": + for _, group := range v.([]interface{}) { + groups = append(groups, group.(string)) } } } } - return nil, fmt.Errorf("no %q cookie", oauthTokenName) + return roles, groups, nil } // initProvider initialize oidc provide with ths given issuer URL. return oidc.Provider. @@ -340,64 +694,67 @@ func parseURL(req *Request) *url.URL { return u } -// matchDomain checks if a domain matches any of the allowed patterns. -func matchDomain(domain string, allowedPatterns []string) bool { - for _, pattern := range allowedPatterns { - if matchPattern(domain, pattern) { +// contains checks if a string is present in a slice of strings. +func contains(slice []string, item string) bool { + for _, s := range slice { + if strings.EqualFold(s, item) { return true } } - return false } -// matchPattern checks if a domain matches a single pattern with wildcards. -func matchPattern(domain, pattern string) bool { - // Split the pattern and domain into parts. - patternParts := strings.Split(pattern, ".") - domainParts := strings.Split(domain, ".") - - // If the number of parts doesn't match, it's not a match. - if len(patternParts) != len(domainParts) { - return false - } - - // Check each part of the pattern against the domain. - for i := range patternParts { - if !matchPart(domainParts[i], patternParts[i]) { - return false - } +// matchRole checks if the user's role matches the required role. +func (o *OIDCConnect) matchPrivilege(requiredPrivilege, userPrivilege string, variables map[string]string) bool { + // Replace variables in the required role with extracted values + o.Log.Info("matchPrivilege", "requiredPrivilege", requiredPrivilege, "userPrivilege", userPrivilege, "variables", variables) + for key, value := range variables { + requiredPrivilege = strings.ReplaceAll(requiredPrivilege, "$"+key, value) } + o.Log.Info("aftermatchPrivilege", "requiredPrivilege", requiredPrivilege, "userPrivilege", userPrivilege, "variables", variables) - return true + return requiredPrivilege == userPrivilege } -// matchPart checks if a single part of the domain matches the pattern part. -func matchPart(domainPart, patternPart string) bool { - // If the pattern part is a wildcard, it matches anything. - if patternPart == "*" { - return true - } +// matchPatternWithVars checks if the path matches the pattern and extracts variables. +func (o *OIDCConnect) matchPatternWithVars(pattern, path string) (map[string]string, bool) { + o.Log.Info("matchPatternWithVars 0", "pattern", pattern, "path", path) + // Replace variables in the pattern with regular expressions + re := regexp.MustCompile(`\$(\w+)`) + patternRegex := re.ReplaceAllStringFunc(pattern, func(s string) string { + return `(?P<` + s[1:] + `>[^/]+)` + }) - // Split the pattern part by the wildcard. - parts := strings.Split(patternPart, "*") + // Replace * with [^/]* to match zero or more characters that are not / + patternRegex = strings.ReplaceAll(patternRegex, "*", "[^/]*") - // Check if the domain part matches the pattern parts in sequence. - pos := 0 + // Ensure that /[^/]*/ is replaced with /[^/]+/ to avoid empty segments + patternRegex = strings.ReplaceAll(patternRegex, "/[^/]*/", "/[^/]+/") - for _, part := range parts { - if part == "" { - continue - } + // Handle trailing /* by allowing an optional segment + if strings.HasSuffix(pattern, "/*") { + patternRegex = strings.TrimSuffix(patternRegex, "/[^/]*") + "(/[^/]+)?" + } - index := strings.Index(domainPart[pos:], part) + // Allow an optional trailing slash + patternRegex = patternRegex + "/?" - if index == -1 { - return false - } + // Check if the path matches the pattern + re = regexp.MustCompile("^" + patternRegex + "$") + match := re.FindStringSubmatch(path) + if match == nil { + o.Log.Info("matchPatternWithVars 1", "pattern", pattern, "path", path, "match", match) + return nil, false + } - pos += index + len(part) + o.Log.Info("matchPatternWithVars okkk", "pattern", pattern, "path", path, "match", match) + // Extract variables + variables := make(map[string]string) + for i, name := range re.SubexpNames() { + if i != 0 && name != "" { + variables[name] = match[i] + } } - return true + return variables, true } diff --git a/pkg/cli/oidc_connect.go b/pkg/cli/oidc_connect.go index e6688bf..ea023c8 100644 --- a/pkg/cli/oidc_connect.go +++ b/pkg/cli/oidc_connect.go @@ -48,13 +48,21 @@ func NewOIDCConnect() *cobra.Command { log.Info("init oidc... ") + renewTokenCacheConfig := bigcache.DefaultConfig(time.Duration(5) * time.Minute) + renewTokenCacheConfig.MaxEntrySize = 5 * 1024 // 5KB per entry + renewTokenCacheConfig.MaxEntriesInWindow = 500000 // 500k entries + renewTokenCacheConfig.HardMaxCacheSize = 500 * 1024 * 1024 // 500MB + renewTokenCacheConfig.CleanWindow = time.Minute + renewedTokenCache, _ := bigcache.NewBigCache(renewTokenCacheConfig) + bigCache, _ := bigcache.NewBigCache(bigcache.DefaultConfig(time.Duration(cfg.CacheTimeout) * time.Minute)) authOidc := &auth.OIDCConnect{ - Log: log, - OidcConfig: cfg, - Cache: bigCache, - HTTPClient: http.DefaultClient, // need to handle client creation with TLS + Log: log, + OidcConfig: cfg, + Cache: bigCache, + HTTPClient: http.DefaultClient, // need to handle client creation with TLS + RenewedTokenCache: renewedTokenCache, } listener, err := net.Listen("tcp", authOidc.OidcConfig.Address) diff --git a/pkg/config/config.go b/pkg/config/config.go index a321e7b..e627e24 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -89,7 +89,7 @@ func (cfg *OIDCConfig) Validate() error { {cfg.IssuerURL == "", "no IssuerURL specified"}, {cfg.ClientID == "", "no clientID specified"}, {cfg.ClientSecret == "" && !cfg.AllowEmptyClientSecret, "no clientSecret specified"}, - {cfg.RedirectURL == "", "no redirectURL specified"}, + {cfg.RedirectURL == "" && len(cfg.AuthorizedRedirectDomains) == 0, "redirectURL or AuthorizedRedirectDomains must be specified"}, {cfg.RedirectPath == "", "no redirectURL specified"}, } From d3974b31a3f41f40e6be85c9bc59088a92175fb4 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Mon, 10 Mar 2025 17:16:19 +0000 Subject: [PATCH 05/13] feat: add HTML login form and cookie-based authentication --- pkg/auth/htpasswd.go | 99 +++++++++++++++++++++++++++++++++++++++++++- pkg/cli/htpasswd.go | 10 +++-- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/pkg/auth/htpasswd.go b/pkg/auth/htpasswd.go index 17d180a..08f2e32 100644 --- a/pkg/auth/htpasswd.go +++ b/pkg/auth/htpasswd.go @@ -16,8 +16,11 @@ package auth import ( "bytes" "context" + "encoding/base64" "fmt" + "io" "net/http" + "strings" "sync" "github.com/go-logr/logr" @@ -42,8 +45,8 @@ type Htpasswd struct { Client client.Client Passwords *htpasswd.File Selector labels.Selector - - Lock sync.Mutex + LoginPath string + Lock sync.Mutex } var _ Checker = &Htpasswd{} @@ -85,6 +88,19 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro user, pass, ok := request.Request.BasicAuth() + if !ok { + // Try to get credentials from cookie if basic auth header not present + if cookie, err := request.Request.Cookie("basic-auth"); err == nil { + if decoded, err := base64.StdEncoding.DecodeString(cookie.Value); err == nil { + parts := strings.Split(string(decoded), ":") + if len(parts) == 2 { + user = parts[0] + pass = parts[1] + ok = true + } + } + } + } // If there's an "Authorization" header and we can verify // it, succeed and inject some headers to tell the origin //what we did. @@ -113,6 +129,13 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro }, nil } + url := parseURL(request) + + // Check if the current request matches the callback path. + if url.Path == h.LoginPath { + return h.loginHandler() + } + // If there's no "Authorization" header, or the authentication // failed, send an authenticate request. return &Response{ @@ -126,6 +149,78 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro }, nil } +func (h *Htpasswd) loginHandler() (*Response, error) { + // Return HTML with JavaScript for login modal + loginHTML := ` + + + + + + + + + + +` + + return &Response{ + Allow: false, + Response: http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{ + "Content-Type": {"text/html"}, + }, + Body: io.NopCloser(strings.NewReader(loginHTML)), + }, + }, nil +} + // Reconcile ... func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var opts []client.ListOption diff --git a/pkg/cli/htpasswd.go b/pkg/cli/htpasswd.go index ccc39d8..38aadfd 100644 --- a/pkg/cli/htpasswd.go +++ b/pkg/cli/htpasswd.go @@ -66,10 +66,11 @@ func NewHtpasswdCommand() *cobra.Command { } htpasswd := &auth.Htpasswd{ - Log: log, - Client: mgr.GetClient(), - Realm: mustString(cmd.Flags().GetString("auth-realm")), - Selector: secretsSelector, + Log: log, + Client: mgr.GetClient(), + Realm: mustString(cmd.Flags().GetString("auth-realm")), + Selector: secretsSelector, + LoginPath: mustString(cmd.Flags().GetString("login-path")), } if err := htpasswd.RegisterWithManager(mgr); err != nil { @@ -132,6 +133,7 @@ func NewHtpasswdCommand() *cobra.Command { cmd.Flags().String("tls-cert-path", "", "Path to the TLS server certificate.") cmd.Flags().String("tls-ca-path", "", "Path to the TLS CA certificate bundle.") cmd.Flags().String("tls-key-path", "", "Path to the TLS server key.") + cmd.Flags().String("login-path", "/login", "The path to the login page.") // Authorization flags. cmd.Flags().String("auth-realm", "default", "Basic authentication realm.") From 2c8b94105148e5673d6984ed7e88ddf6d4806a38 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Wed, 12 Mar 2025 14:34:29 +0000 Subject: [PATCH 06/13] feat: add debug logging to htpasswd auth --- pkg/auth/htpasswd.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/auth/htpasswd.go b/pkg/auth/htpasswd.go index 08f2e32..52ad2dd 100644 --- a/pkg/auth/htpasswd.go +++ b/pkg/auth/htpasswd.go @@ -86,11 +86,14 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro "id", request.ID, ) + h.Log.Info("request", "request", request.Request) user, pass, ok := request.Request.BasicAuth() if !ok { + h.Log.Info("no basic auth header") // Try to get credentials from cookie if basic auth header not present if cookie, err := request.Request.Cookie("basic-auth"); err == nil { + h.Log.Info("cookie", "cookie", cookie) if decoded, err := base64.StdEncoding.DecodeString(cookie.Value); err == nil { parts := strings.Split(string(decoded), ":") if len(parts) == 2 { @@ -101,10 +104,14 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro } } } + h.Log.Info("user", "user", user) + h.Log.Info("pass", "pass", pass) + h.Log.Info("ok", "ok", ok) // If there's an "Authorization" header and we can verify // it, succeed and inject some headers to tell the origin //what we did. if ok && h.Match(user, pass) { + h.Log.Info("match") // TODO(jpeach) inject context attributes into the headers. authorized := http.Response{ StatusCode: http.StatusOK, @@ -114,6 +121,7 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro "Auth-Realm": {h.Realm}, }, } + h.Log.Info("authorized", "authorized", authorized) // Reflect the authorization check context into the response headers. for k, v := range request.Context { @@ -130,11 +138,14 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro } url := parseURL(request) - + h.Log.Info("url", "url", url) + h.Log.Info("login path", "login path", h.LoginPath, "url path", url.Path) // Check if the current request matches the callback path. if url.Path == h.LoginPath { + h.Log.Info("login path") return h.loginHandler() } + h.Log.Info("not login path") // If there's no "Authorization" header, or the authentication // failed, send an authenticate request. @@ -150,6 +161,7 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro } func (h *Htpasswd) loginHandler() (*Response, error) { + h.Log.Info("loginHandler") // Return HTML with JavaScript for login modal loginHTML := ` From 0fd1313cde8fbae9b8d0639ad109495c12c51726 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Thu, 13 Mar 2025 15:01:22 +0000 Subject: [PATCH 07/13] debug --- main.go | 3 +++ pkg/auth/htpasswd.go | 14 ++++++++++++-- pkg/cli/htpasswd.go | 3 +++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index e95f60f..11c7180 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,8 @@ import ( "fmt" "os" + "log" + "github.com/projectcontour/contour-authserver/pkg/cli" "github.com/projectcontour/contour-authserver/pkg/version" @@ -38,6 +40,7 @@ func main() { Short: "Authentication server for the Envoy proxy", Version: fmt.Sprintf("%s/%s, built %s", version.Version, version.Sha, version.BuildDate), }) + log.Println("debug version", version.Version) root.AddCommand(cli.Defaults(cli.NewTestserverCommand())) root.AddCommand(cli.Defaults(cli.NewHtpasswdCommand())) diff --git a/pkg/auth/htpasswd.go b/pkg/auth/htpasswd.go index 52ad2dd..fe865ff 100644 --- a/pkg/auth/htpasswd.go +++ b/pkg/auth/htpasswd.go @@ -19,6 +19,7 @@ import ( "encoding/base64" "fmt" "io" + "log" "net/http" "strings" "sync" @@ -55,14 +56,14 @@ var _ Checker = &Htpasswd{} func (h *Htpasswd) Set(passwd *htpasswd.File) { h.Lock.Lock() defer h.Lock.Unlock() - + log.Println("debug version Set") h.Passwords = passwd } // Match authenticates the credential against the htpasswd file. func (h *Htpasswd) Match(user string, pass string) bool { var passwd *htpasswd.File - + log.Println("debug version Match") // Arguably, getting and setting the pointer is atomic, but // Go doesn't make any guarantees. h.Lock.Lock() @@ -237,6 +238,8 @@ function login() { func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var opts []client.ListOption + log.Println("debug version reconcile") + if h.Selector != nil { opts = append(opts, client.MatchingLabelsSelector{Selector: h.Selector}) } @@ -247,6 +250,8 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result return ctrl.Result{}, err } + log.Println("debug version reconcile 2", len(secrets.Items)) + passwdData := bytes.Buffer{} for _, s := range secrets.Items { @@ -262,6 +267,8 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result } } + log.Println("debug version reconcile 3") + // Check for the "auth" key, which is the format used by ingress-nginx. authData, ok := s.Data["auth"] if !ok { @@ -287,6 +294,7 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result } if hasBadLine { + log.Println("debug version reconcile 4") continue } @@ -306,6 +314,8 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result h.Set(newPasswd) + log.Println("debug version reconcile 5") + return ctrl.Result{}, nil } diff --git a/pkg/cli/htpasswd.go b/pkg/cli/htpasswd.go index 38aadfd..f8223ff 100644 --- a/pkg/cli/htpasswd.go +++ b/pkg/cli/htpasswd.go @@ -39,6 +39,8 @@ func NewHtpasswdCommand() *cobra.Command { scheme.AddToScheme(s) //nolint:gosec,errcheck + log.Info("debug version cli.htpasswd.go") + options := ctrl.Options{ Scheme: s, Metrics: ctrl_metrics_server.Options{ @@ -97,6 +99,7 @@ func NewHtpasswdCommand() *cobra.Command { "address", mustString(cmd.Flags().GetString("address")), "realm", htpasswd.Realm) + log.Info("debug version cli.htpasswd.go 2 tun server") if err := auth.RunServer(ctx, listener, srv); err != nil { errChan <- ExitErrorf(EX_FAIL, "authorization server failed: %w", err) } From d9fa60787093b77e62ccae30e7a8df048660aa73 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Thu, 13 Mar 2025 18:40:13 +0000 Subject: [PATCH 08/13] feat: add support for wildcard domain in AuthorizedRedirectDomains --- pkg/auth/oidc_connect.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pkg/auth/oidc_connect.go b/pkg/auth/oidc_connect.go index 3e761ec..f76110b 100644 --- a/pkg/auth/oidc_connect.go +++ b/pkg/auth/oidc_connect.go @@ -78,33 +78,31 @@ func (o *OIDCConnect) Check(ctx context.Context, req *Request) (*Response, error } url := parseURL(req) - // check redirect url if o.OidcConfig.RedirectURL == "" && len(o.OidcConfig.AuthorizedRedirectDomains) == 0 { + o.Log.Info("no redirectURL or AuthorizedRedirectDomains specified") return &Response{}, fmt.Errorf("no redirectURL or AuthorizedRedirectDomains specified") } else if len(o.OidcConfig.AuthorizedRedirectDomains) != 0 { authorized := false for _, domain := range o.OidcConfig.AuthorizedRedirectDomains { domain = strings.TrimPrefix(domain, "*.") - if strings.HasSuffix(url.Host, domain) { + if strings.HasSuffix(url.Host, domain) || domain == "*" { authorized = true break } } if !authorized { + o.Log.Info("redirectURL does not match", "url", url.Host, "authorizedRedirectDomains", o.OidcConfig.AuthorizedRedirectDomains) return &Response{}, fmt.Errorf("redirectURL does not match") } - o.OidcConfig.RedirectURL = fmt.Sprintf("%s://%s", url.Scheme, url.Host) } - // Check if the current request matches the callback path. if url.Path == o.OidcConfig.RedirectPath { resp, err := o.callbackHandler(ctx, url) return &resp, err } - // Do we have stateid stored in querystring state := o.GetState(ctx, req, url) // Validate the state. From 3da7cd4a1ef0da534f36a01b156ab20cd80c0f25 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Thu, 13 Mar 2025 21:10:18 +0000 Subject: [PATCH 09/13] refactor: remove debug logs and login modal --- pkg/auth/htpasswd.go | 125 ++----------------------------------------- pkg/cli/htpasswd.go | 13 ++--- 2 files changed, 8 insertions(+), 130 deletions(-) diff --git a/pkg/auth/htpasswd.go b/pkg/auth/htpasswd.go index fe865ff..17d180a 100644 --- a/pkg/auth/htpasswd.go +++ b/pkg/auth/htpasswd.go @@ -16,12 +16,8 @@ package auth import ( "bytes" "context" - "encoding/base64" "fmt" - "io" - "log" "net/http" - "strings" "sync" "github.com/go-logr/logr" @@ -46,8 +42,8 @@ type Htpasswd struct { Client client.Client Passwords *htpasswd.File Selector labels.Selector - LoginPath string - Lock sync.Mutex + + Lock sync.Mutex } var _ Checker = &Htpasswd{} @@ -56,14 +52,14 @@ var _ Checker = &Htpasswd{} func (h *Htpasswd) Set(passwd *htpasswd.File) { h.Lock.Lock() defer h.Lock.Unlock() - log.Println("debug version Set") + h.Passwords = passwd } // Match authenticates the credential against the htpasswd file. func (h *Htpasswd) Match(user string, pass string) bool { var passwd *htpasswd.File - log.Println("debug version Match") + // Arguably, getting and setting the pointer is atomic, but // Go doesn't make any guarantees. h.Lock.Lock() @@ -87,32 +83,12 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro "id", request.ID, ) - h.Log.Info("request", "request", request.Request) user, pass, ok := request.Request.BasicAuth() - if !ok { - h.Log.Info("no basic auth header") - // Try to get credentials from cookie if basic auth header not present - if cookie, err := request.Request.Cookie("basic-auth"); err == nil { - h.Log.Info("cookie", "cookie", cookie) - if decoded, err := base64.StdEncoding.DecodeString(cookie.Value); err == nil { - parts := strings.Split(string(decoded), ":") - if len(parts) == 2 { - user = parts[0] - pass = parts[1] - ok = true - } - } - } - } - h.Log.Info("user", "user", user) - h.Log.Info("pass", "pass", pass) - h.Log.Info("ok", "ok", ok) // If there's an "Authorization" header and we can verify // it, succeed and inject some headers to tell the origin //what we did. if ok && h.Match(user, pass) { - h.Log.Info("match") // TODO(jpeach) inject context attributes into the headers. authorized := http.Response{ StatusCode: http.StatusOK, @@ -122,7 +98,6 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro "Auth-Realm": {h.Realm}, }, } - h.Log.Info("authorized", "authorized", authorized) // Reflect the authorization check context into the response headers. for k, v := range request.Context { @@ -138,16 +113,6 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro }, nil } - url := parseURL(request) - h.Log.Info("url", "url", url) - h.Log.Info("login path", "login path", h.LoginPath, "url path", url.Path) - // Check if the current request matches the callback path. - if url.Path == h.LoginPath { - h.Log.Info("login path") - return h.loginHandler() - } - h.Log.Info("not login path") - // If there's no "Authorization" header, or the authentication // failed, send an authenticate request. return &Response{ @@ -161,85 +126,10 @@ func (h *Htpasswd) Check(ctx context.Context, request *Request) (*Response, erro }, nil } -func (h *Htpasswd) loginHandler() (*Response, error) { - h.Log.Info("loginHandler") - // Return HTML with JavaScript for login modal - loginHTML := ` - - - - - - - - - - -` - - return &Response{ - Allow: false, - Response: http.Response{ - StatusCode: http.StatusUnauthorized, - Header: http.Header{ - "Content-Type": {"text/html"}, - }, - Body: io.NopCloser(strings.NewReader(loginHTML)), - }, - }, nil -} - // Reconcile ... func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var opts []client.ListOption - log.Println("debug version reconcile") - if h.Selector != nil { opts = append(opts, client.MatchingLabelsSelector{Selector: h.Selector}) } @@ -250,8 +140,6 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result return ctrl.Result{}, err } - log.Println("debug version reconcile 2", len(secrets.Items)) - passwdData := bytes.Buffer{} for _, s := range secrets.Items { @@ -267,8 +155,6 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result } } - log.Println("debug version reconcile 3") - // Check for the "auth" key, which is the format used by ingress-nginx. authData, ok := s.Data["auth"] if !ok { @@ -294,7 +180,6 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result } if hasBadLine { - log.Println("debug version reconcile 4") continue } @@ -314,8 +199,6 @@ func (h *Htpasswd) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result h.Set(newPasswd) - log.Println("debug version reconcile 5") - return ctrl.Result{}, nil } diff --git a/pkg/cli/htpasswd.go b/pkg/cli/htpasswd.go index f8223ff..ccc39d8 100644 --- a/pkg/cli/htpasswd.go +++ b/pkg/cli/htpasswd.go @@ -39,8 +39,6 @@ func NewHtpasswdCommand() *cobra.Command { scheme.AddToScheme(s) //nolint:gosec,errcheck - log.Info("debug version cli.htpasswd.go") - options := ctrl.Options{ Scheme: s, Metrics: ctrl_metrics_server.Options{ @@ -68,11 +66,10 @@ func NewHtpasswdCommand() *cobra.Command { } htpasswd := &auth.Htpasswd{ - Log: log, - Client: mgr.GetClient(), - Realm: mustString(cmd.Flags().GetString("auth-realm")), - Selector: secretsSelector, - LoginPath: mustString(cmd.Flags().GetString("login-path")), + Log: log, + Client: mgr.GetClient(), + Realm: mustString(cmd.Flags().GetString("auth-realm")), + Selector: secretsSelector, } if err := htpasswd.RegisterWithManager(mgr); err != nil { @@ -99,7 +96,6 @@ func NewHtpasswdCommand() *cobra.Command { "address", mustString(cmd.Flags().GetString("address")), "realm", htpasswd.Realm) - log.Info("debug version cli.htpasswd.go 2 tun server") if err := auth.RunServer(ctx, listener, srv); err != nil { errChan <- ExitErrorf(EX_FAIL, "authorization server failed: %w", err) } @@ -136,7 +132,6 @@ func NewHtpasswdCommand() *cobra.Command { cmd.Flags().String("tls-cert-path", "", "Path to the TLS server certificate.") cmd.Flags().String("tls-ca-path", "", "Path to the TLS CA certificate bundle.") cmd.Flags().String("tls-key-path", "", "Path to the TLS server key.") - cmd.Flags().String("login-path", "/login", "The path to the login page.") // Authorization flags. cmd.Flags().String("auth-realm", "default", "Basic authentication realm.") From 8c2ffda76405b71471308a65e35c9222666a9c9e Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Fri, 14 Mar 2025 00:33:37 +0000 Subject: [PATCH 10/13] feat: add httoken auth handler --- pkg/auth/httoken.go | 121 +++++++++++++++++++++++++++++++++++++++++ pkg/cli/httoken.go | 130 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 pkg/auth/httoken.go create mode 100644 pkg/cli/httoken.go diff --git a/pkg/auth/httoken.go b/pkg/auth/httoken.go new file mode 100644 index 0000000..99109aa --- /dev/null +++ b/pkg/auth/httoken.go @@ -0,0 +1,121 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/go-logr/logr" +) + +// Httoken watches Secrets for httoken files and uses them for HTTP Basic Authentication. +type Httoken struct { + Log logr.Logger + StaticToken string +} + +var _ Checker = &Httoken{} + +// Check ... +func (h *Httoken) Check(ctx context.Context, request *Request) (*Response, error) { + h.Log.Info("checking request", + "host", request.Request.Host, + "path", request.Request.URL.Path, + "id", request.ID, + ) + + h.Log.Info("request", "request", request.Request) + + // Check for Bearer token + auth := request.Request.Header.Get("Authorization") + var token string + + if strings.HasPrefix(auth, "Bearer ") { + // Extract token + token = strings.TrimPrefix(auth, "Bearer ") + } + + // If there's an "Authorization" header and we can verify + // it, succeed and inject some headers to tell the origin + //what we did. + if token == h.StaticToken { + // TODO(jpeach) inject context attributes into the headers. + authorized := http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Auth-Handler": {"httoken"}, + "X-Auth-token": {token}, + }, + } + + // Reflect the authorization check context into the response headers. + for k, v := range request.Context { + key := fmt.Sprintf("Auth-Context-%s", k) + key = http.CanonicalHeaderKey(key) // XXX(jpeach) this will not transform invalid characters + + authorized.Header.Add(key, v) + } + + return &Response{ + Allow: true, + Response: authorized, + }, nil + } + + return &Response{ + Allow: false, + Response: http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{ + "WWW-Authenticate": {`Bearer realm="token", charset="UTF-8"`}, + }, + }, + }, nil +} + +func (h *Httoken) loginHandler() (*Response, error) { + h.Log.Info("loginHandler") + + // Script JavaScript avec prompt() natif + loginHTML := ` + + + + + +` + + return &Response{ + Allow: false, + Response: http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{ + "Content-Type": {"text/html"}, + }, + Body: io.NopCloser(strings.NewReader(loginHTML)), + }, + }, nil +} diff --git a/pkg/cli/httoken.go b/pkg/cli/httoken.go new file mode 100644 index 0000000..b852b1c --- /dev/null +++ b/pkg/cli/httoken.go @@ -0,0 +1,130 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "net" + + "github.com/projectcontour/contour-authserver/pkg/auth" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + ctrl_cache "sigs.k8s.io/controller-runtime/pkg/cache" + ctrl_metrics_server "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +// NewHttokenCommand ... +func NewHttokenCommand() *cobra.Command { + cmd := cobra.Command{ + Use: "static-token [OPTIONS]", + Short: "Run a static token authentication server", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + log := ctrl.Log.WithName("auth.httoken") + s := runtime.NewScheme() + + scheme.AddToScheme(s) //nolint:gosec,errcheck + + log.Info("debug version cli.httoken.go") + + options := ctrl.Options{ + Scheme: s, + Metrics: ctrl_metrics_server.Options{ + BindAddress: mustString(cmd.Flags().GetString("metrics-address")), + }, + } + + if namespaces, err := cmd.Flags().GetStringSlice("watch-namespaces"); err == nil && len(namespaces) > 0 { + // Maps namespaces to cache configs. We will set an empty config + // so the higher level defaults are used. + options.Cache.DefaultNamespaces = make(map[string]ctrl_cache.Config) + for _, ns := range namespaces { + options.Cache.DefaultNamespaces[ns] = ctrl_cache.Config{} + } + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), options) + if err != nil { + return ExitErrorf(EX_CONFIG, "failed to create controller manager: %s", err) + } + + httoken := &auth.Httoken{ + Log: log, + StaticToken: mustString(cmd.Flags().GetString("token")), + } + + listener, err := net.Listen("tcp", mustString(cmd.Flags().GetString("address"))) + if err != nil { + return ExitError{EX_CONFIG, err} + } + + srv, err := DefaultServer(cmd) + if err != nil { + return ExitErrorf(EX_CONFIG, "invalid TLS configuration: %s", err) + } + + auth.RegisterServer(srv, httoken) + + errChan := make(chan error) + ctx := ctrl.SetupSignalHandler() + + go func() { + log.Info("started authorization server", + "address", mustString(cmd.Flags().GetString("address"))) + + if err := auth.RunServer(ctx, listener, srv); err != nil { + errChan <- ExitErrorf(EX_FAIL, "authorization server failed: %w", err) + } + + errChan <- nil + }() + + go func() { + log.Info("started controller") + + if err := mgr.Start(ctx); err != nil { + errChan <- ExitErrorf(EX_FAIL, "controller manager failed: %w", err) + } + + errChan <- nil + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + return nil + } + }, + } + + // Controller flags. + cmd.Flags().String("metrics-address", ":8080", "The address the metrics endpoint binds to.") + cmd.Flags().StringSlice("watch-namespaces", []string{}, "The list of namespaces to watch for Secrets.") + cmd.Flags().String("selector", "", "Selector (label-query) to filter Secrets, supports '=', '==', and '!='.") + + // GRPC flags. + cmd.Flags().String("address", ":9090", "The address the authentication endpoint binds to.") + cmd.Flags().String("tls-cert-path", "", "Path to the TLS server certificate.") + cmd.Flags().String("tls-ca-path", "", "Path to the TLS CA certificate bundle.") + cmd.Flags().String("tls-key-path", "", "Path to the TLS server key.") + cmd.Flags().String("token", "", "The token to use for authentication.") + + // Authorization flags. + cmd.Flags().String("auth-realm", "default", "Basic authentication realm.") + + return &cmd +} From 290291e94e66ff3d8b07751287a1dda297c13253 Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Fri, 14 Mar 2025 00:33:59 +0000 Subject: [PATCH 11/13] feat: add auth handler header --- main.go | 2 +- pkg/auth/oidc_connect.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 11c7180..32fb8f8 100644 --- a/main.go +++ b/main.go @@ -45,7 +45,7 @@ func main() { root.AddCommand(cli.Defaults(cli.NewTestserverCommand())) root.AddCommand(cli.Defaults(cli.NewHtpasswdCommand())) root.AddCommand(cli.Defaults(cli.NewOIDCConnect())) - + root.AddCommand(cli.Defaults(cli.NewHttokenCommand())) if err := root.Execute(); err != nil { if msg := err.Error(); msg != "" { fmt.Fprintf(os.Stderr, "%s: %s\n", version.Progname, msg) diff --git a/pkg/auth/oidc_connect.go b/pkg/auth/oidc_connect.go index f76110b..62735eb 100644 --- a/pkg/auth/oidc_connect.go +++ b/pkg/auth/oidc_connect.go @@ -507,6 +507,7 @@ func (o *OIDCConnect) PropagateUserInfo(resp *Response, userInfo *UserInfo) { return } + resp.Response.Header.Add("Auth-Handler", "oidc") resp.Response.Header.Add("X-Auth-User-Username", userInfo.Username) resp.Response.Header.Add("X-Auth-User-Email", userInfo.Email) resp.Response.Header.Add("X-Auth-User-Email-Verified", fmt.Sprintf("%v", userInfo.EmailVerified)) From 9ba11fdc884309791f1421417b65573bc914d8cf Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Fri, 14 Mar 2025 01:12:24 +0000 Subject: [PATCH 12/13] feat: support multiple tokens for authentication --- pkg/auth/httoken.go | 40 +++------------------------------------- pkg/cli/httoken.go | 5 +++-- 2 files changed, 6 insertions(+), 39 deletions(-) diff --git a/pkg/auth/httoken.go b/pkg/auth/httoken.go index 99109aa..a38d99b 100644 --- a/pkg/auth/httoken.go +++ b/pkg/auth/httoken.go @@ -16,8 +16,8 @@ package auth import ( "context" "fmt" - "io" "net/http" + "slices" "strings" "github.com/go-logr/logr" @@ -26,7 +26,7 @@ import ( // Httoken watches Secrets for httoken files and uses them for HTTP Basic Authentication. type Httoken struct { Log logr.Logger - StaticToken string + StaticToken []string } var _ Checker = &Httoken{} @@ -39,8 +39,6 @@ func (h *Httoken) Check(ctx context.Context, request *Request) (*Response, error "id", request.ID, ) - h.Log.Info("request", "request", request.Request) - // Check for Bearer token auth := request.Request.Header.Get("Authorization") var token string @@ -53,7 +51,7 @@ func (h *Httoken) Check(ctx context.Context, request *Request) (*Response, error // If there's an "Authorization" header and we can verify // it, succeed and inject some headers to tell the origin //what we did. - if token == h.StaticToken { + if slices.Contains(h.StaticToken, token) { // TODO(jpeach) inject context attributes into the headers. authorized := http.Response{ StatusCode: http.StatusOK, @@ -87,35 +85,3 @@ func (h *Httoken) Check(ctx context.Context, request *Request) (*Response, error }, }, nil } - -func (h *Httoken) loginHandler() (*Response, error) { - h.Log.Info("loginHandler") - - // Script JavaScript avec prompt() natif - loginHTML := ` - - - - - -` - - return &Response{ - Allow: false, - Response: http.Response{ - StatusCode: http.StatusUnauthorized, - Header: http.Header{ - "Content-Type": {"text/html"}, - }, - Body: io.NopCloser(strings.NewReader(loginHTML)), - }, - }, nil -} diff --git a/pkg/cli/httoken.go b/pkg/cli/httoken.go index b852b1c..47f2a77 100644 --- a/pkg/cli/httoken.go +++ b/pkg/cli/httoken.go @@ -61,9 +61,10 @@ func NewHttokenCommand() *cobra.Command { return ExitErrorf(EX_CONFIG, "failed to create controller manager: %s", err) } + tokens, _ := cmd.Flags().GetStringSlice("token") httoken := &auth.Httoken{ Log: log, - StaticToken: mustString(cmd.Flags().GetString("token")), + StaticToken: tokens, } listener, err := net.Listen("tcp", mustString(cmd.Flags().GetString("address"))) @@ -121,7 +122,7 @@ func NewHttokenCommand() *cobra.Command { cmd.Flags().String("tls-cert-path", "", "Path to the TLS server certificate.") cmd.Flags().String("tls-ca-path", "", "Path to the TLS CA certificate bundle.") cmd.Flags().String("tls-key-path", "", "Path to the TLS server key.") - cmd.Flags().String("token", "", "The token to use for authentication.") + cmd.Flags().StringSlice("token", []string{}, "The token to use for authentication.") // Authorization flags. cmd.Flags().String("auth-realm", "default", "Basic authentication realm.") From 383a8ec0cc97bb82a0dec21d5949c9f87fb874bf Mon Sep 17 00:00:00 2001 From: modoulo boly sow Date: Fri, 14 Mar 2025 16:04:33 +0000 Subject: [PATCH 13/13] fix: unescape state token from query params --- pkg/auth/oidc_connect.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/auth/oidc_connect.go b/pkg/auth/oidc_connect.go index 62735eb..3d6ad0b 100644 --- a/pkg/auth/oidc_connect.go +++ b/pkg/auth/oidc_connect.go @@ -221,10 +221,14 @@ func (o *OIDCConnect) refreshTokens(ctx context.Context, state *store.OIDCState, return nil } -func (o *OIDCConnect) GetState(ctx context.Context, req *Request, url *url.URL) *store.OIDCState { +func (o *OIDCConnect) GetState(ctx context.Context, req *Request, requestUrl *url.URL) *store.OIDCState { var state *store.OIDCState - stateToken := url.Query().Get(stateQueryParamName) + stateToken, err := url.QueryUnescape(requestUrl.Query().Get(stateQueryParamName)) + if err != nil { + o.Log.Error(err, "error unescaping state token") + return nil + } stateByte, err := o.Cache.Get(stateToken) if err == nil {