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 036cfb5..d705431 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 ( @@ -8,11 +9,12 @@ 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 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..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= @@ -155,8 +157,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= diff --git a/main.go b/main.go index e95f60f..32fb8f8 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,11 +40,12 @@ 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())) 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/httoken.go b/pkg/auth/httoken.go new file mode 100644 index 0000000..a38d99b --- /dev/null +++ b/pkg/auth/httoken.go @@ -0,0 +1,87 @@ +// 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" + "net/http" + "slices" + "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, + ) + + // 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 slices.Contains(h.StaticToken, token) { + // 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 +} diff --git a/pkg/auth/oidc_connect.go b/pkg/auth/oidc_connect.go index f34d9b1..3d6ad0b 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. @@ -61,16 +78,37 @@ 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) || 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. - 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,26 +118,22 @@ func (o *OIDCConnect) Check(ctx context.Context, req *Request) (*Response, error return &resp, nil } - 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 - - stateToken := url.Query().Get(stateQueryParamName) - - stateByte, err := o.Cache.Get(stateToken) - if err == nil { - state = store.ConvertToType(stateByte) + userInfo, resp, err := o.GetUserInfo(ctx, state) + if err != nil { + return &resp, err } - // State not found, try to retrieve from cookies. - if state == nil { - state, _ = o.getStateFromCookie(req) + // 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, 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. @@ -109,25 +143,143 @@ 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 { - 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, requestUrl *url.URL) *store.OIDCState { + var state *store.OIDCState + + 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 { + 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() @@ -137,6 +289,8 @@ func (o *OIDCConnect) loginHandler(u *url.URL) Response { 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") @@ -205,6 +359,10 @@ 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=/; HttpOnly; Secure; SameSite=Lax", oauthTokenName, string(stateJSON))) + return resp, nil } @@ -237,29 +395,253 @@ 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, ";") + return *resp, true, nil +} - 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 +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("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)) + 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. @@ -280,7 +662,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 +696,68 @@ func parseURL(req *Request) *url.URL { return u } + +// 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 +} + +// 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 requiredPrivilege == userPrivilege +} + +// 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:] + `>[^/]+)` + }) + + // Replace * with [^/]* to match zero or more characters that are not / + patternRegex = strings.ReplaceAll(patternRegex, "*", "[^/]*") + + // Ensure that /[^/]*/ is replaced with /[^/]+/ to avoid empty segments + patternRegex = strings.ReplaceAll(patternRegex, "/[^/]*/", "/[^/]+/") + + // Handle trailing /* by allowing an optional segment + if strings.HasSuffix(pattern, "/*") { + patternRegex = strings.TrimSuffix(patternRegex, "/[^/]*") + "(/[^/]+)?" + } + + // Allow an optional trailing slash + patternRegex = patternRegex + "/?" + + // 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 + } + + 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 variables, true +} diff --git a/pkg/cli/httoken.go b/pkg/cli/httoken.go new file mode 100644 index 0000000..47f2a77 --- /dev/null +++ b/pkg/cli/httoken.go @@ -0,0 +1,131 @@ +// 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) + } + + tokens, _ := cmd.Flags().GetStringSlice("token") + httoken := &auth.Httoken{ + Log: log, + StaticToken: tokens, + } + + 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().StringSlice("token", []string{}, "The token to use for authentication.") + + // Authorization flags. + cmd.Flags().String("auth-realm", "default", "Basic authentication realm.") + + return &cmd +} 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 f0568aa..e627e24 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. @@ -86,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"}, }