From dea8bc96eaf52e4d9caf5d42620ec5476635a17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 10 Mar 2023 16:31:22 +0200 Subject: [PATCH] refactor: use struct types for claim related types (#283) * oidc: add regression tests for token claim json this helps to verify that the same JSON is produced, after these types are refactored. * refactor: use struct types for claim related types BREAKING CHANGE: The following types are changed from interface to struct type: - AccessTokenClaims - IDTokenClaims - IntrospectionResponse - UserInfo and related types. The following methods of OPStorage now take a pointer to a struct type, instead of an interface: - SetUserinfoFromScopes - SetUserinfoFromToken - SetIntrospectionFromToken The following functions are now generic, so that type-safe extension of Claims is now possible: - op.VerifyIDTokenHint - op.VerifyAccessToken - rp.VerifyTokens - rp.VerifyIDToken - Changed UserInfoAddress to pointer in UserInfo and IntrospectionResponse. This was needed to make omitempty work correctly. - Copy or merge maps in IntrospectionResponse and SetUserInfo * op: add example for VerifyAccessToken * fix: rp: wrong assignment in WithIssuedAtMaxAge WithIssuedAtMaxAge assigned its value to v.maxAge, which was wrong. This change fixes that by assiging the duration to v.maxAgeIAT. * rp: add VerifyTokens example * oidc: add standard references to: - IDTokenClaims - IntrospectionResponse - UserInfo * only count coverage for `./pkg/...` --- .github/workflows/release.yml | 4 +- README.md | 4 +- example/client/api/api.go | 2 +- example/client/app/app.go | 2 +- example/client/github/github.go | 3 +- example/server/storage/storage.go | 37 +- example/server/storage/storage_dynamic.go | 6 +- go.mod | 22 +- go.sum | 11 +- internal/testutil/gen/gen.go | 58 ++ internal/testutil/token.go | 146 +++++ pkg/client/client.go | 4 +- pkg/client/integration_test.go | 9 +- pkg/client/rp/cli/cli.go | 6 +- pkg/client/rp/mock/generate.go | 3 - pkg/client/rp/mock/verifier.mock.go | 163 ----- pkg/client/rp/relying_party.go | 26 +- pkg/client/rp/verifier.go | 48 +- pkg/client/rp/verifier_test.go | 339 ++++++++++ pkg/client/rp/verifier_tokens_example_test.go | 86 +++ pkg/client/rs/resource_server.go | 4 +- pkg/oidc/introspection.go | 384 ++---------- pkg/oidc/introspection_test.go | 78 +++ pkg/oidc/regression_assert_test.go | 50 ++ pkg/oidc/regression_create_test.go | 24 + .../oidc.AccessTokenClaims.json | 26 + .../regression_data/oidc.IDTokenClaims.json | 51 ++ .../oidc.IntrospectionResponse.json | 44 ++ .../oidc.JWTProfileAssertionClaims.json | 11 + pkg/oidc/regression_data/oidc.UserInfo.json | 30 + pkg/oidc/regression_test.go | 40 ++ pkg/oidc/token.go | 578 +++++------------- pkg/oidc/token_request.go | 4 +- pkg/oidc/token_test.go | 227 +++++++ pkg/oidc/types.go | 55 +- pkg/oidc/types_test.go | 112 ++++ pkg/oidc/userinfo.go | 405 ++---------- pkg/oidc/userinfo_test.go | 111 ++-- pkg/oidc/util.go | 49 ++ pkg/oidc/util_test.go | 147 +++++ pkg/oidc/verifier.go | 6 + pkg/op/auth_request.go | 2 +- pkg/op/mock/storage.mock.go | 6 +- pkg/op/session.go | 2 +- pkg/op/storage.go | 8 +- pkg/op/token.go | 14 +- pkg/op/token_exchange.go | 14 +- pkg/op/token_intospection.go | 4 +- pkg/op/token_revocation.go | 4 +- pkg/op/userinfo.go | 6 +- pkg/op/verifier_access_token.go | 20 +- pkg/op/verifier_access_token_example_test.go | 70 +++ pkg/op/verifier_access_token_test.go | 126 ++++ pkg/op/verifier_id_token_hint.go | 22 +- pkg/op/verifier_id_token_hint_test.go | 161 +++++ 55 files changed, 2358 insertions(+), 1516 deletions(-) create mode 100644 internal/testutil/gen/gen.go create mode 100644 internal/testutil/token.go delete mode 100644 pkg/client/rp/mock/generate.go delete mode 100644 pkg/client/rp/mock/verifier.mock.go create mode 100644 pkg/client/rp/verifier_test.go create mode 100644 pkg/client/rp/verifier_tokens_example_test.go create mode 100644 pkg/oidc/introspection_test.go create mode 100644 pkg/oidc/regression_assert_test.go create mode 100644 pkg/oidc/regression_create_test.go create mode 100644 pkg/oidc/regression_data/oidc.AccessTokenClaims.json create mode 100644 pkg/oidc/regression_data/oidc.IDTokenClaims.json create mode 100644 pkg/oidc/regression_data/oidc.IntrospectionResponse.json create mode 100644 pkg/oidc/regression_data/oidc.JWTProfileAssertionClaims.json create mode 100644 pkg/oidc/regression_data/oidc.UserInfo.json create mode 100644 pkg/oidc/regression_test.go create mode 100644 pkg/oidc/token_test.go create mode 100644 pkg/oidc/util.go create mode 100644 pkg/oidc/util_test.go create mode 100644 pkg/op/verifier_access_token_example_test.go create mode 100644 pkg/op/verifier_access_token_test.go create mode 100644 pkg/op/verifier_id_token_hint_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9509826..2abef36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - go: ['1.16', '1.17', '1.18', '1.19', '1.20'] + go: ['1.18', '1.19', '1.20'] name: Go ${{ matrix.go }} test steps: - uses: actions/checkout@v3 @@ -24,7 +24,7 @@ jobs: uses: actions/setup-go@v3 with: go-version: ${{ matrix.go }} - - run: go test -race -v -coverprofile=profile.cov -coverpkg=github.com/zitadel/oidc/... ./pkg/... + - run: go test -race -v -coverprofile=profile.cov -coverpkg=./pkg/... ./pkg/... - uses: codecov/codecov-action@v3.1.1 with: file: ./profile.cov diff --git a/README.md b/README.md index 31287e9..7b9bf22 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,7 @@ Versions that also build are marked with :warning:. | Version | Supported | |---------|--------------------| -| <1.16 | :x: | -| 1.16 | :warning: | -| 1.17 | :warning: | +| <1.18 | :x: | | 1.18 | :warning: | | 1.19 | :white_check_mark: | | 1.20 | :white_check_mark: | diff --git a/example/client/api/api.go b/example/client/api/api.go index c475354..8093b63 100644 --- a/example/client/api/api.go +++ b/example/client/api/api.go @@ -76,7 +76,7 @@ func main() { params := mux.Vars(r) requestedClaim := params["claim"] requestedValue := params["value"] - value, ok := resp.GetClaim(requestedClaim).(string) + value, ok := resp.Claims[requestedClaim].(string) if !ok || value == "" || value != requestedValue { http.Error(w, "claim does not match", http.StatusForbidden) return diff --git a/example/client/app/app.go b/example/client/app/app.go index 97e8948..560ac02 100644 --- a/example/client/app/app.go +++ b/example/client/app/app.go @@ -60,7 +60,7 @@ func main() { http.Handle("/login", rp.AuthURLHandler(state, provider)) // for demonstration purposes the returned userinfo response is written as JSON object onto response - marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { + marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { data, err := json.Marshal(info) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/example/client/github/github.go b/example/client/github/github.go index 57bb3ae..9cb813c 100644 --- a/example/client/github/github.go +++ b/example/client/github/github.go @@ -13,6 +13,7 @@ import ( "github.com/zitadel/oidc/v2/pkg/client/rp" "github.com/zitadel/oidc/v2/pkg/client/rp/cli" "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) var ( @@ -43,7 +44,7 @@ func main() { state := func() string { return uuid.New().String() } - token := cli.CodeFlow(ctx, relyingParty, callbackPath, port, state) + token := cli.CodeFlow[*oidc.IDTokenClaims](ctx, relyingParty, callbackPath, port, state) client := github.NewClient(relyingParty.OAuthConfig().Client(ctx, token.Token)) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 2794783..ff7889e 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -429,13 +429,13 @@ func (s *Storage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientS // SetUserinfoFromScopes implements the op.Storage interface // it will be called for the creation of an id_token, so we'll just pass it to the private function without any further check -func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, userID, clientID string, scopes []string) error { +func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error { return s.setUserinfo(ctx, userinfo, userID, clientID, scopes) } // SetUserinfoFromToken implements the op.Storage interface // it will be called for the userinfo endpoint, so we read the token and pass the information from that to the private function -func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error { +func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error { token, ok := func() (*Token, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -463,7 +463,7 @@ func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserIn // SetIntrospectionFromToken implements the op.Storage interface // it will be called for the introspection endpoint, so we read the token and pass the information from that to the private function -func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection oidc.IntrospectionResponse, tokenID, subject, clientID string) error { +func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) error { token, ok := func() (*Token, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -480,14 +480,17 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection o // this will automatically be done by the library if you don't return an error // you can also return further information about the user / associated token // e.g. the userinfo (equivalent to userinfo endpoint) - err := s.setUserinfo(ctx, introspection, subject, clientID, token.Scopes) + + userInfo := new(oidc.UserInfo) + err := s.setUserinfo(ctx, userInfo, subject, clientID, token.Scopes) if err != nil { return err } + introspection.SetUserInfo(userInfo) //...and also the requested scopes... - introspection.SetScopes(token.Scopes) + introspection.Scope = token.Scopes //...and the client the token was issued to - introspection.SetClientID(token.ApplicationID) + introspection.ClientID = token.ApplicationID return nil } } @@ -608,7 +611,7 @@ func (s *Storage) accessToken(applicationID, refreshTokenID, subject string, aud } // setUserinfo sets the info based on the user, scopes and if necessary the clientID -func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, userID, clientID string, scopes []string) (err error) { +func (s *Storage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, clientID string, scopes []string) (err error) { s.lock.Lock() defer s.lock.Unlock() user := s.userStore.GetUserByID(userID) @@ -618,17 +621,19 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, for _, scope := range scopes { switch scope { case oidc.ScopeOpenID: - userInfo.SetSubject(user.ID) + userInfo.Subject = user.ID case oidc.ScopeEmail: - userInfo.SetEmail(user.Email, user.EmailVerified) + userInfo.Email = user.Email + userInfo.EmailVerified = oidc.Bool(user.EmailVerified) case oidc.ScopeProfile: - userInfo.SetPreferredUsername(user.Username) - userInfo.SetName(user.FirstName + " " + user.LastName) - userInfo.SetFamilyName(user.LastName) - userInfo.SetGivenName(user.FirstName) - userInfo.SetLocale(user.PreferredLanguage) + userInfo.PreferredUsername = user.Username + userInfo.Name = user.FirstName + " " + user.LastName + userInfo.FamilyName = user.LastName + userInfo.GivenName = user.FirstName + userInfo.Locale = oidc.NewLocale(user.PreferredLanguage) case oidc.ScopePhone: - userInfo.SetPhone(user.Phone, user.PhoneVerified) + userInfo.PhoneNumber = user.Phone + userInfo.PhoneNumberVerified = user.PhoneVerified case CustomScope: // you can also have a custom scope and assert public or custom claims based on that userInfo.AppendClaims(CustomClaim, customClaim(clientID)) @@ -698,7 +703,7 @@ func (s *Storage) GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, // SetUserinfoFromScopesForTokenExchange implements the op.TokenExchangeStorage interface // it will be called for the creation of an id_token - we are using the same private function as for other flows, // plus adding token exchange specific claims related to delegation or impersonation -func (s *Storage) SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo oidc.UserInfoSetter, request op.TokenExchangeRequest) error { +func (s *Storage) SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo *oidc.UserInfo, request op.TokenExchangeRequest) error { err := s.setUserinfo(ctx, userinfo, request.GetSubject(), request.GetClientID(), request.GetScopes()) if err != nil { return err diff --git a/example/server/storage/storage_dynamic.go b/example/server/storage/storage_dynamic.go index d424a89..6e5ee32 100644 --- a/example/server/storage/storage_dynamic.go +++ b/example/server/storage/storage_dynamic.go @@ -198,7 +198,7 @@ func (s *multiStorage) AuthorizeClientIDSecret(ctx context.Context, clientID, cl // SetUserinfoFromScopes implements the op.Storage interface // it will be called for the creation of an id_token, so we'll just pass it to the private function without any further check -func (s *multiStorage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, userID, clientID string, scopes []string) error { +func (s *multiStorage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error { storage, err := s.storageFromContext(ctx) if err != nil { return err @@ -208,7 +208,7 @@ func (s *multiStorage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc. // SetUserinfoFromToken implements the op.Storage interface // it will be called for the userinfo endpoint, so we read the token and pass the information from that to the private function -func (s *multiStorage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error { +func (s *multiStorage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error { storage, err := s.storageFromContext(ctx) if err != nil { return err @@ -218,7 +218,7 @@ func (s *multiStorage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.U // SetIntrospectionFromToken implements the op.Storage interface // it will be called for the introspection endpoint, so we read the token and pass the information from that to the private function -func (s *multiStorage) SetIntrospectionFromToken(ctx context.Context, introspection oidc.IntrospectionResponse, tokenID, subject, clientID string) error { +func (s *multiStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) error { storage, err := s.storageFromContext(ctx) if err != nil { return err diff --git a/go.mod b/go.mod index 2691e57..9ed1e8e 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,36 @@ module github.com/zitadel/oidc/v2 -go 1.16 +go 1.18 require ( github.com/golang/mock v1.6.0 - github.com/google/go-cmp v0.5.2 // indirect github.com/google/go-github/v31 v31.0.0 github.com/google/uuid v1.3.0 github.com/gorilla/mux v1.8.0 github.com/gorilla/schema v1.2.0 github.com/gorilla/securecookie v1.1.1 github.com/jeremija/gosubmit v0.2.7 - github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/muhlemmer/gu v0.3.0 github.com/rs/cors v1.8.3 github.com/sirupsen/logrus v1.9.0 github.com/stretchr/testify v1.8.1 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 golang.org/x/text v0.6.0 - gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/square/go-jose.v2 v2.6.0 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/protobuf v1.4.2 // indirect + github.com/google/go-cmp v0.5.2 // indirect + github.com/google/go-querystring v1.0.0 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect + google.golang.org/appengine v1.6.6 // indirect + google.golang.org/protobuf v1.25.0 // indirect + gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index c73eb9d..1933228 100644 --- a/go.sum +++ b/go.sum @@ -123,6 +123,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.0 h1:UwNv9xXGp1WDgHKgk7ljjh3duh1w4ZAY1k1NsWBYl3Y= +github.com/muhlemmer/gu v0.3.0/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -146,7 +148,6 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -190,7 +191,6 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -217,7 +217,6 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -237,7 +236,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -266,19 +264,15 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -325,7 +319,6 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/testutil/gen/gen.go b/internal/testutil/gen/gen.go new file mode 100644 index 0000000..a9f5925 --- /dev/null +++ b/internal/testutil/gen/gen.go @@ -0,0 +1,58 @@ +// Package gen allows generating of example tokens and claims. +// +// go run ./internal/testutil/gen +package main + +import ( + "encoding/json" + "fmt" + "os" + + tu "github.com/zitadel/oidc/v2/internal/testutil" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +var custom = map[string]any{ + "foo": "Hello, World!", + "bar": struct { + Count int `json:"count,omitempty"` + Tags []string `json:"tags,omitempty"` + }{ + Count: 22, + Tags: []string{"some", "tags"}, + }, +} + +func main() { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + + accessToken, atClaims := tu.NewAccessTokenCustom( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidJWTID, + tu.ValidClientID, tu.ValidSkew, custom, + ) + atHash, err := oidc.ClaimHash(accessToken, tu.SignatureAlgorithm) + if err != nil { + panic(err) + } + + idToken, idClaims := tu.NewIDTokenCustom( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidAuthTime, + tu.ValidNonce, tu.ValidACR, tu.ValidAMR, tu.ValidClientID, + tu.ValidSkew, atHash, custom, + ) + + fmt.Println("access token claims:") + if err := enc.Encode(atClaims); err != nil { + panic(err) + } + fmt.Printf("access token:\n%s\n", accessToken) + + fmt.Println("ID token claims:") + if err := enc.Encode(idClaims); err != nil { + panic(err) + } + fmt.Printf("ID token:\n%s\n", idToken) +} diff --git a/internal/testutil/token.go b/internal/testutil/token.go new file mode 100644 index 0000000..121aa0b --- /dev/null +++ b/internal/testutil/token.go @@ -0,0 +1,146 @@ +// Package testuril helps setting up required data for testing, +// such as tokens, claims and verifiers. +package testutil + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/zitadel/oidc/v2/pkg/oidc" + "gopkg.in/square/go-jose.v2" +) + +// KeySet implements oidc.Keys +type KeySet struct{} + +// VerifySignature implments op.KeySet. +func (KeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) { + if ctx.Err() != nil { + return nil, err + } + + return jws.Verify(WebKey.Public()) +} + +// use a reproducible signing key +const webkeyJSON = `{"kty":"RSA","kid":"1","alg":"PS512","n":"x6JoG8t2Li68JSwPwnh51TvHYFf3z72tQ3wmJG3VosU6MdJF0gSTCIwflOJ38OWE6hYtN1WAeyBy2CYdnXd1QZzkK_apGK4M7hsNA9jCTg8NOZjLPL0ww1jp7313Skla7mbm90uNdg4TUNp2n_r-sCYywI-9cfSlhzLSksxKK_BRdzy6xW20daAcI-mErQXIcvdYIguunJk_uTb8kJedsWMcQ4Mb57QujUok2Z2YabWyb9Fi1_StixXJvd_WEu93SHNMORB0u6ymnO3aZJdATLdhtcP-qsVicQhffpqVazmZQPf7K-7n4I5vJE4g9XXzZ2dSKSp3Ewe_nna_2kvbCw","e":"AQAB","d":"sl3F_QeF2O-CxQegMRYpbL6Tfd47GM6VDxXOkn_cACmNvFPudB4ILPvdf830cjTv06Lq1WS8fcZZNgygK0A_cNc3-pvRK67e-KMMtuIlgU7rdwmwlN1Iw1Ee-w6z1ZjC-PzR4iQMCW28DmKS2I-OnV4TvH7xOe7nMmvTPrvujV__YKfUxvAWXJG7_wtaJBGplezn5nNsKG2Ot9h0mhMdYUgGC36wLxo3Q5d4m79EXQYdhm89EfxogwvMmHRes5PNpHRuDZRHGAI4RZi2KvgmqF07e1Qdq4TqbQnY5pCYrdjqvEFFjGC6jTE-ak_b21FcSVy-9aZHyf04U4g5-cIUEQ","p":"7AaicFryJCHRekdSkx8tfPxaSiyEuN8jhP9cLqs4rLkIbrSHmanPhjnLe-Tlh3icQ8hPoy6WC8ktLwsrzbfGIh4U_zgAfvtD1Y_lZM-YSWZsxqlrGiI5do11iVzzoy4a1XdkgOjHQz9y6J-uoA9jY8ILG7VaEZQnaYwWZV3cspk","q":"2Ide9hlwthXJQJYqI0mibM5BiGBxJ4CafPmF1DYNXggBCczZ6ERGReNTGM_AEhy5mvLXUH6uBSOJlfHTYzx49C1GgIO3hEWVEGAKAytVRL6RfAkVSOXMQUp-HjXKpGg_Nx1SJxQf3rulbW8HXO4KqIlloyIXpPQSK7jB8A4hJUM","dp":"1nmc6F4sRNsaQHRJO_mL21RxM4_KtzfFThjCCoJ6iLHHUNnpkp_1PTKNjrLMRFM8JHgErfMqU-FmlqYfEtvZRq1xRQ39nWX0GT-eIwJljuVtGQVglqnc77bRxJXbqz-9EJdik6VzVM92Op7IDxiMp1zvvSkJhInNWqL6wvgNEZk","dq":"dlHizlAwiw90ndpwxD-khhhfLwqkSpW31br0KnYu78cn6hcKrCVC0UXbTp-XsU4JDmbMyauvpBc7Q7iVbpDI94UWFXvkeF8diYkxb3HqclpAXasI-oC4EKWILTHvvc9JW_Clx7zzfV7Ekvws5dcd8-LAq1gh232TwFiBgY_3BMk","qi":"E1k_9W3odXgcmIP2PCJztE7hB7jeuAL1ElAY88VJBBPY670uwOEjKL2VfQuz9q9IjzLAvcgf7vS9blw2RHP_XqHqSOlJWGwvMQTF0Q8zLknCgKt8q7HQQNWIJcBZ8qdUVn02-qf4E3tgZ3JHaHNs8imA_L-__WoUmzC4z5jH_lM"}` + +const SignatureAlgorithm = jose.RS256 + +var ( + WebKey jose.JSONWebKey + Signer jose.Signer +) + +func init() { + err := json.Unmarshal([]byte(webkeyJSON), &WebKey) + if err != nil { + panic(err) + } + Signer, err = jose.NewSigner(jose.SigningKey{Algorithm: SignatureAlgorithm, Key: WebKey}, nil) + if err != nil { + panic(err) + } +} + +func signEncodeTokenClaims(claims any) string { + payload, err := json.Marshal(claims) + if err != nil { + panic(err) + } + object, err := Signer.Sign(payload) + if err != nil { + panic(err) + } + token, err := object.CompactSerialize() + if err != nil { + panic(err) + } + return token +} + +func claimsMap(claims any) map[string]any { + data, err := json.Marshal(claims) + if err != nil { + panic(err) + } + dst := make(map[string]any) + if err = json.Unmarshal(data, &dst); err != nil { + panic(err) + } + return dst +} + +func NewIDTokenCustom(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration, atHash string, custom map[string]any) (string, *oidc.IDTokenClaims) { + claims := oidc.NewIDTokenClaims(issuer, subject, audience, expiration, authTime, nonce, acr, amr, clientID, skew) + claims.AccessTokenHash = atHash + claims.Claims = custom + token := signEncodeTokenClaims(claims) + + // set this so that assertion in tests will work + claims.SignatureAlg = SignatureAlgorithm + claims.Claims = claimsMap(claims) + return token, claims +} + +// NewIDToken creates a new IDTokenClaims with passed data and returns a signed token and claims. +func NewIDToken(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration, atHash string) (string, *oidc.IDTokenClaims) { + return NewIDTokenCustom(issuer, subject, audience, expiration, authTime, nonce, acr, amr, clientID, skew, atHash, nil) +} + +func NewAccessTokenCustom(issuer, subject string, audience []string, expiration time.Time, jwtid, clientID string, skew time.Duration, custom map[string]any) (string, *oidc.AccessTokenClaims) { + claims := oidc.NewAccessTokenClaims(issuer, subject, audience, expiration, jwtid, clientID, skew) + claims.Claims = custom + token := signEncodeTokenClaims(claims) + + // set this so that assertion in tests will work + claims.SignatureAlg = SignatureAlgorithm + claims.Claims = claimsMap(claims) + return token, claims +} + +// NewAcccessToken creates a new AccessTokenClaims with passed data and returns a signed token and claims. +func NewAccessToken(issuer, subject string, audience []string, expiration time.Time, jwtid, clientID string, skew time.Duration) (string, *oidc.AccessTokenClaims) { + return NewAccessTokenCustom(issuer, subject, audience, expiration, jwtid, clientID, skew, nil) +} + +const InvalidSignatureToken = `eyJhbGciOiJQUzUxMiJ9.eyJpc3MiOiJsb2NhbC5jb20iLCJzdWIiOiJ0aW1AbG9jYWwuY29tIiwiYXVkIjpbInVuaXQiLCJ0ZXN0IiwiNTU1NjY2Il0sImV4cCI6MTY3Nzg0MDQzMSwiaWF0IjoxNjc3ODQwMzcwLCJhdXRoX3RpbWUiOjE2Nzc4NDAzMTAsIm5vbmNlIjoiMTIzNDUiLCJhY3IiOiJzb21ldGhpbmciLCJhbXIiOlsiZm9vIiwiYmFyIl0sImF6cCI6IjU1NTY2NiJ9.DtZmvVkuE4Hw48ijBMhRJbxEWCr_WEYuPQBMY73J9TP6MmfeNFkjVJf4nh4omjB9gVLnQ-xhEkNOe62FS5P0BB2VOxPuHZUj34dNspCgG3h98fGxyiMb5vlIYAHDF9T-w_LntlYItohv63MmdYR-hPpAqjXE7KOfErf-wUDGE9R3bfiQ4HpTdyFJB1nsToYrZ9lhP2mzjTCTs58ckZfQ28DFHn_lfHWpR4rJBgvLx7IH4rMrUayr09Ap-PxQLbv0lYMtmgG1z3JK8MXnuYR0UJdZnEIezOzUTlThhCXB-nvuAXYjYxZZTR0FtlgZUHhIpYK0V2abf_Q_Or36akNCUg` + +// These variables always result in a valid token +var ( + ValidIssuer = "local.com" + ValidSubject = "tim@local.com" + ValidAudience = []string{"unit", "test"} + ValidAuthTime = time.Now().Add(-time.Minute) // authtime is always 1 minute in the past + ValidExpiration = ValidAuthTime.Add(2 * time.Minute) // token is always 1 more minute available + ValidJWTID = "9876" + ValidNonce = "12345" + ValidACR = "something" + ValidAMR = []string{"foo", "bar"} + ValidClientID = "555666" + ValidSkew = time.Second +) + +// ValidIDToken returns a token and claims that are in the token. +// It uses the Valid* global variables and the token will always +// pass verification. +func ValidIDToken() (string, *oidc.IDTokenClaims) { + return NewIDToken(ValidIssuer, ValidSubject, ValidAudience, ValidExpiration, ValidAuthTime, ValidNonce, ValidACR, ValidAMR, ValidClientID, ValidSkew, "") +} + +// ValidAccessToken returns a token and claims that are in the token. +// It uses the Valid* global variables and the token always passes +// verification within the same test run. +func ValidAccessToken() (string, *oidc.AccessTokenClaims) { + return NewAccessToken(ValidIssuer, ValidSubject, ValidAudience, ValidExpiration, ValidJWTID, ValidClientID, ValidSkew) +} + +// ACRVerify is a oidc.ACRVerifier func. +func ACRVerify(acr string) error { + if acr != ValidACR { + return errors.New("invalid acr") + } + return nil +} diff --git a/pkg/client/client.go b/pkg/client/client.go index ebe1442..9eda973 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -176,8 +176,8 @@ func SignedJWTProfileAssertion(clientID string, audience []string, expiration ti Issuer: clientID, Subject: clientID, Audience: audience, - ExpiresAt: oidc.Time(exp), - IssuedAt: oidc.Time(iat), + ExpiresAt: oidc.FromTime(exp), + IssuedAt: oidc.FromTime(iat), }, signer) } diff --git a/pkg/client/integration_test.go b/pkg/client/integration_test.go index f112b30..7f6ca62 100644 --- a/pkg/client/integration_test.go +++ b/pkg/client/integration_test.go @@ -235,19 +235,19 @@ func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, } var email string - redirect := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { + redirect := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { require.NotNil(t, tokens, "tokens") require.NotNil(t, info, "info") t.Log("access token", tokens.AccessToken) t.Log("refresh token", tokens.RefreshToken) t.Log("id token", tokens.IDToken) - t.Log("email", info.GetEmail()) + t.Log("email", info.Email) accessToken = tokens.AccessToken refreshToken = tokens.RefreshToken idToken = tokens.IDToken - email = info.GetEmail() - http.Redirect(w, r, targetURL, http.StatusFound) + email = info.Email + http.Redirect(w, r, targetURL, 302) } rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider)(capturedW, get) @@ -258,7 +258,6 @@ func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, } }() require.Less(t, capturedW.Code, 400, "token exchange response code") - require.Less(t, capturedW.Code, 400, "token exchange response code") //nolint:bodyclose resp = capturedW.Result() diff --git a/pkg/client/rp/cli/cli.go b/pkg/client/rp/cli/cli.go index 936f319..91b200d 100644 --- a/pkg/client/rp/cli/cli.go +++ b/pkg/client/rp/cli/cli.go @@ -13,13 +13,13 @@ const ( loginPath = "/login" ) -func CodeFlow(ctx context.Context, relyingParty rp.RelyingParty, callbackPath, port string, stateProvider func() string) *oidc.Tokens { +func CodeFlow[C oidc.IDClaims](ctx context.Context, relyingParty rp.RelyingParty, callbackPath, port string, stateProvider func() string) *oidc.Tokens[C] { codeflowCtx, codeflowCancel := context.WithCancel(ctx) defer codeflowCancel() - tokenChan := make(chan *oidc.Tokens, 1) + tokenChan := make(chan *oidc.Tokens[C], 1) - callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty) { + callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp rp.RelyingParty) { tokenChan <- tokens msg := "

Success!

" msg = msg + "

You are authenticated and can now return to the CLI.

" diff --git a/pkg/client/rp/mock/generate.go b/pkg/client/rp/mock/generate.go deleted file mode 100644 index 7db81ea..0000000 --- a/pkg/client/rp/mock/generate.go +++ /dev/null @@ -1,3 +0,0 @@ -package mock - -//go:generate mockgen -package mock -destination ./verifier.mock.go github.com/zitadel/oidc/v2/pkg/client/rp IDTokenVerifier diff --git a/pkg/client/rp/mock/verifier.mock.go b/pkg/client/rp/mock/verifier.mock.go deleted file mode 100644 index eac6a79..0000000 --- a/pkg/client/rp/mock/verifier.mock.go +++ /dev/null @@ -1,163 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/v2/pkg/client/rp (interfaces: IDTokenVerifier) - -// Package mock is a generated GoMock package. -package mock - -import ( - context "context" - reflect "reflect" - time "time" - - gomock "github.com/golang/mock/gomock" - oidc "github.com/zitadel/oidc/v2/pkg/oidc" -) - -// MockIDTokenVerifier is a mock of IDTokenVerifier interface. -type MockIDTokenVerifier struct { - ctrl *gomock.Controller - recorder *MockIDTokenVerifierMockRecorder -} - -// MockIDTokenVerifierMockRecorder is the mock recorder for MockIDTokenVerifier. -type MockIDTokenVerifierMockRecorder struct { - mock *MockIDTokenVerifier -} - -// NewMockIDTokenVerifier creates a new mock instance. -func NewMockIDTokenVerifier(ctrl *gomock.Controller) *MockIDTokenVerifier { - mock := &MockIDTokenVerifier{ctrl: ctrl} - mock.recorder = &MockIDTokenVerifierMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockIDTokenVerifier) EXPECT() *MockIDTokenVerifierMockRecorder { - return m.recorder -} - -// ACR mocks base method. -func (m *MockIDTokenVerifier) ACR() oidc.ACRVerifier { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ACR") - ret0, _ := ret[0].(oidc.ACRVerifier) - return ret0 -} - -// ACR indicates an expected call of ACR. -func (mr *MockIDTokenVerifierMockRecorder) ACR() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ACR", reflect.TypeOf((*MockIDTokenVerifier)(nil).ACR)) -} - -// ClientID mocks base method. -func (m *MockIDTokenVerifier) ClientID() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClientID") - ret0, _ := ret[0].(string) - return ret0 -} - -// ClientID indicates an expected call of ClientID. -func (mr *MockIDTokenVerifierMockRecorder) ClientID() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientID", reflect.TypeOf((*MockIDTokenVerifier)(nil).ClientID)) -} - -// Issuer mocks base method. -func (m *MockIDTokenVerifier) Issuer() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Issuer") - ret0, _ := ret[0].(string) - return ret0 -} - -// Issuer indicates an expected call of Issuer. -func (mr *MockIDTokenVerifierMockRecorder) Issuer() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Issuer", reflect.TypeOf((*MockIDTokenVerifier)(nil).Issuer)) -} - -// KeySet mocks base method. -func (m *MockIDTokenVerifier) KeySet() oidc.KeySet { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "KeySet") - ret0, _ := ret[0].(oidc.KeySet) - return ret0 -} - -// KeySet indicates an expected call of KeySet. -func (mr *MockIDTokenVerifierMockRecorder) KeySet() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KeySet", reflect.TypeOf((*MockIDTokenVerifier)(nil).KeySet)) -} - -// MaxAge mocks base method. -func (m *MockIDTokenVerifier) MaxAge() time.Duration { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MaxAge") - ret0, _ := ret[0].(time.Duration) - return ret0 -} - -// MaxAge indicates an expected call of MaxAge. -func (mr *MockIDTokenVerifierMockRecorder) MaxAge() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MaxAge", reflect.TypeOf((*MockIDTokenVerifier)(nil).MaxAge)) -} - -// MaxAgeIAT mocks base method. -func (m *MockIDTokenVerifier) MaxAgeIAT() time.Duration { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MaxAgeIAT") - ret0, _ := ret[0].(time.Duration) - return ret0 -} - -// MaxAgeIAT indicates an expected call of MaxAgeIAT. -func (mr *MockIDTokenVerifierMockRecorder) MaxAgeIAT() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MaxAgeIAT", reflect.TypeOf((*MockIDTokenVerifier)(nil).MaxAgeIAT)) -} - -// Nonce mocks base method. -func (m *MockIDTokenVerifier) Nonce(arg0 context.Context) string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Nonce", arg0) - ret0, _ := ret[0].(string) - return ret0 -} - -// Nonce indicates an expected call of Nonce. -func (mr *MockIDTokenVerifierMockRecorder) Nonce(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Nonce", reflect.TypeOf((*MockIDTokenVerifier)(nil).Nonce), arg0) -} - -// Offset mocks base method. -func (m *MockIDTokenVerifier) Offset() time.Duration { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Offset") - ret0, _ := ret[0].(time.Duration) - return ret0 -} - -// Offset indicates an expected call of Offset. -func (mr *MockIDTokenVerifierMockRecorder) Offset() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Offset", reflect.TypeOf((*MockIDTokenVerifier)(nil).Offset)) -} - -// SupportedSignAlgs mocks base method. -func (m *MockIDTokenVerifier) SupportedSignAlgs() []string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SupportedSignAlgs") - ret0, _ := ret[0].([]string) - return ret0 -} - -// SupportedSignAlgs indicates an expected call of SupportedSignAlgs. -func (mr *MockIDTokenVerifierMockRecorder) SupportedSignAlgs() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SupportedSignAlgs", reflect.TypeOf((*MockIDTokenVerifier)(nil).SupportedSignAlgs)) -} diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go index 5bd3558..8aa7b1e 100644 --- a/pkg/client/rp/relying_party.go +++ b/pkg/client/rp/relying_party.go @@ -373,7 +373,7 @@ func GenerateAndStoreCodeChallenge(w http.ResponseWriter, rp RelyingParty) (stri // CodeExchange handles the oauth2 code exchange, extracting and validating the id_token // returning it parsed together with the oauth2 tokens (access, refresh) -func CodeExchange(ctx context.Context, code string, rp RelyingParty, opts ...CodeExchangeOpt) (tokens *oidc.Tokens, err error) { +func CodeExchange[C oidc.IDClaims](ctx context.Context, code string, rp RelyingParty, opts ...CodeExchangeOpt) (tokens *oidc.Tokens[C], err error) { ctx = context.WithValue(ctx, oauth2.HTTPClient, rp.HttpClient()) codeOpts := make([]oauth2.AuthCodeOption, 0) for _, opt := range opts { @@ -386,7 +386,7 @@ func CodeExchange(ctx context.Context, code string, rp RelyingParty, opts ...Cod } if rp.IsOAuth2Only() { - return &oidc.Tokens{Token: token}, nil + return &oidc.Tokens[C]{Token: token}, nil } idTokenString, ok := token.Extra(idTokenKey).(string) @@ -394,20 +394,20 @@ func CodeExchange(ctx context.Context, code string, rp RelyingParty, opts ...Cod return nil, errors.New("id_token missing") } - idToken, err := VerifyTokens(ctx, token.AccessToken, idTokenString, rp.IDTokenVerifier()) + idToken, err := VerifyTokens[C](ctx, token.AccessToken, idTokenString, rp.IDTokenVerifier()) if err != nil { return nil, err } - return &oidc.Tokens{Token: token, IDTokenClaims: idToken, IDToken: idTokenString}, nil + return &oidc.Tokens[C]{Token: token, IDTokenClaims: idToken, IDToken: idTokenString}, nil } -type CodeExchangeCallback func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp RelyingParty) +type CodeExchangeCallback[C oidc.IDClaims] func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp RelyingParty) // CodeExchangeHandler extends the `CodeExchange` method with a http handler // including cookie handling for secure `state` transfer // and optional PKCE code verifier checking -func CodeExchangeHandler(callback CodeExchangeCallback, rp RelyingParty) http.HandlerFunc { +func CodeExchangeHandler[C oidc.IDClaims](callback CodeExchangeCallback[C], rp RelyingParty) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { state, err := tryReadStateCookie(w, r, rp) if err != nil { @@ -436,7 +436,7 @@ func CodeExchangeHandler(callback CodeExchangeCallback, rp RelyingParty) http.Ha } codeOpts = append(codeOpts, WithClientAssertionJWT(assertion)) } - tokens, err := CodeExchange(r.Context(), params.Get("code"), rp, codeOpts...) + tokens, err := CodeExchange[C](r.Context(), params.Get("code"), rp, codeOpts...) if err != nil { http.Error(w, "failed to exchange token: "+err.Error(), http.StatusUnauthorized) return @@ -445,13 +445,13 @@ func CodeExchangeHandler(callback CodeExchangeCallback, rp RelyingParty) http.Ha } } -type CodeExchangeUserinfoCallback func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, provider RelyingParty, info oidc.UserInfo) +type CodeExchangeUserinfoCallback[C oidc.IDClaims] func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, provider RelyingParty, info *oidc.UserInfo) // UserinfoCallback wraps the callback function of the CodeExchangeHandler // and calls the userinfo endpoint with the access token // on success it will pass the userinfo into its callback function as well -func UserinfoCallback(f CodeExchangeUserinfoCallback) CodeExchangeCallback { - return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp RelyingParty) { +func UserinfoCallback[C oidc.IDClaims](f CodeExchangeUserinfoCallback[C]) CodeExchangeCallback[C] { + return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp RelyingParty) { info, err := Userinfo(tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), rp) if err != nil { http.Error(w, "userinfo failed: "+err.Error(), http.StatusUnauthorized) @@ -462,17 +462,17 @@ func UserinfoCallback(f CodeExchangeUserinfoCallback) CodeExchangeCallback { } // Userinfo will call the OIDC Userinfo Endpoint with the provided token -func Userinfo(token, tokenType, subject string, rp RelyingParty) (oidc.UserInfo, error) { +func Userinfo(token, tokenType, subject string, rp RelyingParty) (*oidc.UserInfo, error) { req, err := http.NewRequest("GET", rp.UserinfoEndpoint(), nil) if err != nil { return nil, err } req.Header.Set("authorization", tokenType+" "+token) - userinfo := oidc.NewUserInfo() + userinfo := new(oidc.UserInfo) if err := httphelper.HttpRequest(rp.HttpClient(), req, &userinfo); err != nil { return nil, err } - if userinfo.GetSubject() != subject { + if userinfo.Subject != subject { return nil, ErrUserInfoSubNotMatching } return userinfo, nil diff --git a/pkg/client/rp/verifier.go b/pkg/client/rp/verifier.go index f3db128..75d149b 100644 --- a/pkg/client/rp/verifier.go +++ b/pkg/client/rp/verifier.go @@ -21,69 +21,71 @@ type IDTokenVerifier interface { // VerifyTokens implement the Token Response Validation as defined in OIDC specification // https://openid.net/specs/openid-connect-core-1_0.html#TokenResponseValidation -func VerifyTokens(ctx context.Context, accessToken, idTokenString string, v IDTokenVerifier) (oidc.IDTokenClaims, error) { - idToken, err := VerifyIDToken(ctx, idTokenString, v) +func VerifyTokens[C oidc.IDClaims](ctx context.Context, accessToken, idToken string, v IDTokenVerifier) (claims C, err error) { + var nilClaims C + + claims, err = VerifyIDToken[C](ctx, idToken, v) if err != nil { - return nil, err + return nilClaims, err } - if err := VerifyAccessToken(accessToken, idToken.GetAccessTokenHash(), idToken.GetSignatureAlgorithm()); err != nil { - return nil, err + if err := VerifyAccessToken(accessToken, claims.GetAccessTokenHash(), claims.GetSignatureAlgorithm()); err != nil { + return nilClaims, err } - return idToken, nil + return claims, nil } // VerifyIDToken validates the id token according to // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation -func VerifyIDToken(ctx context.Context, token string, v IDTokenVerifier) (oidc.IDTokenClaims, error) { - claims := oidc.EmptyIDTokenClaims() +func VerifyIDToken[C oidc.Claims](ctx context.Context, token string, v IDTokenVerifier) (claims C, err error) { + var nilClaims C decrypted, err := oidc.DecryptToken(token) if err != nil { - return nil, err + return nilClaims, err } - payload, err := oidc.ParseToken(decrypted, claims) + payload, err := oidc.ParseToken(decrypted, &claims) if err != nil { - return nil, err + return nilClaims, err } if err := oidc.CheckSubject(claims); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckIssuer(claims, v.Issuer()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckAudience(claims, v.ClientID()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckAuthorizedParty(claims, v.ClientID()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckSignature(ctx, decrypted, payload, claims, v.SupportedSignAlgs(), v.KeySet()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckExpiration(claims, v.Offset()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckIssuedAt(claims, v.MaxAgeIAT(), v.Offset()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckNonce(claims, v.Nonce(ctx)); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckAuthorizationContextClassReference(claims, v.ACR()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckAuthTime(claims, v.MaxAge()); err != nil { - return nil, err + return nilClaims, err } return claims, nil } @@ -112,7 +114,7 @@ func NewIDTokenVerifier(issuer, clientID string, keySet oidc.KeySet, options ... issuer: issuer, clientID: clientID, keySet: keySet, - offset: 1 * time.Second, + offset: time.Second, nonce: func(_ context.Context) string { return "" }, @@ -139,7 +141,7 @@ func WithIssuedAtOffset(offset time.Duration) func(*idTokenVerifier) { // WithIssuedAtMaxAge provides the ability to define the maximum duration between iat and now func WithIssuedAtMaxAge(maxAge time.Duration) func(*idTokenVerifier) { return func(v *idTokenVerifier) { - v.maxAge = maxAge + v.maxAgeIAT = maxAge } } diff --git a/pkg/client/rp/verifier_test.go b/pkg/client/rp/verifier_test.go new file mode 100644 index 0000000..7588c1f --- /dev/null +++ b/pkg/client/rp/verifier_test.go @@ -0,0 +1,339 @@ +package rp + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tu "github.com/zitadel/oidc/v2/internal/testutil" + "github.com/zitadel/oidc/v2/pkg/oidc" + "gopkg.in/square/go-jose.v2" +) + +func TestVerifyTokens(t *testing.T) { + verifier := &idTokenVerifier{ + issuer: tu.ValidIssuer, + maxAgeIAT: 2 * time.Minute, + offset: time.Second, + supportedSignAlgs: []string{string(tu.SignatureAlgorithm)}, + keySet: tu.KeySet{}, + maxAge: 2 * time.Minute, + acr: tu.ACRVerify, + nonce: func(context.Context) string { return tu.ValidNonce }, + clientID: tu.ValidClientID, + } + accessToken, _ := tu.ValidAccessToken() + atHash, err := oidc.ClaimHash(accessToken, tu.SignatureAlgorithm) + require.NoError(t, err) + + tests := []struct { + name string + accessToken string + idTokenClaims func() (string, *oidc.IDTokenClaims) + wantErr bool + }{ + { + name: "without access token", + idTokenClaims: tu.ValidIDToken, + }, + { + name: "with access token", + accessToken: accessToken, + idTokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, atHash, + ) + }, + }, + { + name: "expired id token", + accessToken: accessToken, + idTokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration.Add(-time.Hour), tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, atHash, + ) + }, + wantErr: true, + }, + { + name: "wrong access token", + accessToken: accessToken, + idTokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "~~~", + ) + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + idToken, want := tt.idTokenClaims() + got, err := VerifyTokens[*oidc.IDTokenClaims](context.Background(), tt.accessToken, idToken, verifier) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, got, want) + }) + } +} + +func TestVerifyIDToken(t *testing.T) { + verifier := &idTokenVerifier{ + issuer: tu.ValidIssuer, + maxAgeIAT: 2 * time.Minute, + offset: time.Second, + supportedSignAlgs: []string{string(tu.SignatureAlgorithm)}, + keySet: tu.KeySet{}, + maxAge: 2 * time.Minute, + acr: tu.ACRVerify, + nonce: func(context.Context) string { return tu.ValidNonce }, + } + + tests := []struct { + name string + clientID string + tokenClaims func() (string, *oidc.IDTokenClaims) + wantErr bool + }{ + { + name: "success", + clientID: tu.ValidClientID, + tokenClaims: tu.ValidIDToken, + }, + { + name: "parse err", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { return "~~~~", nil }, + wantErr: true, + }, + { + name: "invalid signature", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.InvalidSignatureToken, nil }, + wantErr: true, + }, + { + name: "empty subject", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, "", tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "wrong issuer", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + "foo", tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "wrong clientID", + clientID: "foo", + tokenClaims: tu.ValidIDToken, + wantErr: true, + }, + { + name: "expired", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration.Add(-time.Hour), tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "wrong IAT", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, -time.Hour, "", + ) + }, + wantErr: true, + }, + { + name: "wrong acr", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + "else", tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "expired auth", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime.Add(-time.Hour), tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "wrong nonce", + clientID: tu.ValidClientID, + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, "foo", + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token, want := tt.tokenClaims() + verifier.clientID = tt.clientID + got, err := VerifyIDToken[*oidc.IDTokenClaims](context.Background(), token, verifier) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, got, want) + }) + } +} + +func TestVerifyAccessToken(t *testing.T) { + token, _ := tu.ValidAccessToken() + hash, err := oidc.ClaimHash(token, tu.SignatureAlgorithm) + require.NoError(t, err) + + type args struct { + accessToken string + atHash string + sigAlgorithm jose.SignatureAlgorithm + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty hash", + }, + { + name: "success", + args: args{ + accessToken: token, + atHash: hash, + sigAlgorithm: tu.SignatureAlgorithm, + }, + }, + { + name: "invalid algorithm", + args: args{ + accessToken: token, + atHash: hash, + sigAlgorithm: "foo", + }, + wantErr: true, + }, + { + name: "mismatch", + args: args{ + accessToken: token, + atHash: "~~", + sigAlgorithm: tu.SignatureAlgorithm, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := VerifyAccessToken(tt.args.accessToken, tt.args.atHash, tt.args.sigAlgorithm) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestNewIDTokenVerifier(t *testing.T) { + type args struct { + issuer string + clientID string + keySet oidc.KeySet + options []VerifierOption + } + tests := []struct { + name string + args args + want IDTokenVerifier + }{ + { + name: "nil nonce", // otherwise assert.Equal will fail on the function + args: args{ + issuer: tu.ValidIssuer, + clientID: tu.ValidClientID, + keySet: tu.KeySet{}, + options: []VerifierOption{ + WithIssuedAtOffset(time.Minute), + WithIssuedAtMaxAge(time.Hour), + WithNonce(nil), // otherwise assert.Equal will fail on the function + WithACRVerifier(nil), + WithAuthTimeMaxAge(2 * time.Hour), + WithSupportedSigningAlgorithms("ABC", "DEF"), + }, + }, + want: &idTokenVerifier{ + issuer: tu.ValidIssuer, + offset: time.Minute, + maxAgeIAT: time.Hour, + clientID: tu.ValidClientID, + keySet: tu.KeySet{}, + nonce: nil, + acr: nil, + maxAge: 2 * time.Hour, + supportedSignAlgs: []string{"ABC", "DEF"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewIDTokenVerifier(tt.args.issuer, tt.args.clientID, tt.args.keySet, tt.args.options...) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/client/rp/verifier_tokens_example_test.go b/pkg/client/rp/verifier_tokens_example_test.go new file mode 100644 index 0000000..c297efe --- /dev/null +++ b/pkg/client/rp/verifier_tokens_example_test.go @@ -0,0 +1,86 @@ +package rp_test + +import ( + "context" + "fmt" + + tu "github.com/zitadel/oidc/v2/internal/testutil" + "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +// MyCustomClaims extends the TokenClaims base, +// so it implmeents the oidc.Claims interface. +// Instead of carrying a map, we add needed fields// to the struct for type safe access. +type MyCustomClaims struct { + oidc.TokenClaims + NotBefore oidc.Time `json:"nbf,omitempty"` + AccessTokenHash string `json:"at_hash,omitempty"` + Foo string `json:"foo,omitempty"` + Bar *Nested `json:"bar,omitempty"` +} + +// GetAccessTokenHash is required to implement +// the oidc.IDClaims interface. +func (c *MyCustomClaims) GetAccessTokenHash() string { + return c.AccessTokenHash +} + +// Nested struct types are also possible. +type Nested struct { + Count int `json:"count,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +/* +idToken carries the following claims. foo and bar are custom claims + + { + "acr": "something", + "amr": [ + "foo", + "bar" + ], + "at_hash": "2dzbm_vIxy-7eRtqUIGPPw", + "aud": [ + "unit", + "test", + "555666" + ], + "auth_time": 1678100961, + "azp": "555666", + "bar": { + "count": 22, + "tags": [ + "some", + "tags" + ] + }, + "client_id": "555666", + "exp": 4802238682, + "foo": "Hello, World!", + "iat": 1678101021, + "iss": "local.com", + "jti": "9876", + "nbf": 1678101021, + "nonce": "12345", + "sub": "tim@local.com" + } +*/ +const idToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhY3IiOiJzb21ldGhpbmciLCJhbXIiOlsiZm9vIiwiYmFyIl0sImF0X2hhc2giOiIyZHpibV92SXh5LTdlUnRxVUlHUFB3IiwiYXVkIjpbInVuaXQiLCJ0ZXN0IiwiNTU1NjY2Il0sImF1dGhfdGltZSI6MTY3ODEwMDk2MSwiYXpwIjoiNTU1NjY2IiwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiY2xpZW50X2lkIjoiNTU1NjY2IiwiZXhwIjo0ODAyMjM4NjgyLCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MTAxMDIxLCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MTAxMDIxLCJub25jZSI6IjEyMzQ1Iiwic3ViIjoidGltQGxvY2FsLmNvbSJ9.t3GXSfVNNwiW1Suv9_84v0sdn2_-RWHVxhphhRozDXnsO7SDNOlGnEioemXABESxSzMclM7gB7mYy5Qah2ZUNx7eP5t2njoxEYfavgHwx7UJZ2NCg8NDPQyr-hlxelEcfdXK-I0oTd-FRDvF4rqPkD9Us52IpnplChCxnHFgh4wKwPqZZjv2IXVCtn0ilKW3hff1rMOYKEuLRcN2YP0gkyuqyHvcf2dMmjod0t4sLOTJ82rsCbMBC5CLpqv3nIC9HOGITkt1Kd-Am0n1LrdZvWwTo6RFe8AnzF0gpqjcB5Wg4Qeh58DIjZOz4f_8wnmJ_gCqyRh5vfSW4XHdbum0Tw` +const accessToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOlsidW5pdCIsInRlc3QiXSwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiZXhwIjo0ODAyMjM4NjgyLCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MTAxMDIxLCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MTAxMDIxLCJzdWIiOiJ0aW1AbG9jYWwuY29tIn0.Zrz3LWSRjCMJZUMaI5dUbW4vGdSmEeJQ3ouhaX0bcW9rdFFLgBI4K2FWJhNivq8JDmCGSxwLu3mI680GWmDaEoAx1M5sCO9lqfIZHGZh-lfAXk27e6FPLlkTDBq8Bx4o4DJ9Fw0hRJGjUTjnYv5cq1vo2-UqldasL6CwTbkzNC_4oQFfRtuodC4Ql7dZ1HRv5LXuYx7KPkOssLZtV9cwtJp5nFzKjcf2zEE_tlbjcpynMwypornRUp1EhCWKRUGkJhJeiP71ECY5pQhShfjBu9Nc5wDpSnZmnk2S4YsPrRK3QkE-iEkas8BfsOCrGoErHjEJexAIDjasGO5PFLWfCA` + +func ExampleVerifyTokens_customClaims() { + v := rp.NewIDTokenVerifier("local.com", "555666", tu.KeySet{}, + rp.WithNonce(func(ctx context.Context) string { return "12345" }), + ) + + // VerifyAccessToken can be called with the *MyCustomClaims. + claims, err := rp.VerifyTokens[*MyCustomClaims](context.TODO(), accessToken, idToken, v) + if err != nil { + panic(err) + } + // Here we have typesafe access to the custom claims + fmt.Println(claims.Foo, claims.Bar.Count, claims.Bar.Tags) + // Output: Hello, World! 22 [some tags] +} diff --git a/pkg/client/rs/resource_server.go b/pkg/client/rs/resource_server.go index 95a0121..4e0353c 100644 --- a/pkg/client/rs/resource_server.go +++ b/pkg/client/rs/resource_server.go @@ -112,7 +112,7 @@ func WithStaticEndpoints(tokenURL, introspectURL string) Option { } } -func Introspect(ctx context.Context, rp ResourceServer, token string) (oidc.IntrospectionResponse, error) { +func Introspect(ctx context.Context, rp ResourceServer, token string) (*oidc.IntrospectionResponse, error) { authFn, err := rp.AuthFn() if err != nil { return nil, err @@ -121,7 +121,7 @@ func Introspect(ctx context.Context, rp ResourceServer, token string) (oidc.Intr if err != nil { return nil, err } - resp := oidc.NewIntrospectionResponse() + resp := new(oidc.IntrospectionResponse) if err := httphelper.HttpRequest(rp.HttpClient(), req, resp); err != nil { return nil, err } diff --git a/pkg/oidc/introspection.go b/pkg/oidc/introspection.go index b7c220c..8313dc4 100644 --- a/pkg/oidc/introspection.go +++ b/pkg/oidc/introspection.go @@ -1,12 +1,6 @@ package oidc -import ( - "encoding/json" - "fmt" - "time" - - "golang.org/x/text/language" -) +import "github.com/muhlemmer/gu" type IntrospectionRequest struct { Token string `schema:"token"` @@ -17,36 +11,11 @@ type ClientAssertionParams struct { ClientAssertionType string `schema:"client_assertion_type"` } -type IntrospectionResponse interface { - UserInfoSetter - IsActive() bool - SetActive(bool) - SetScopes(scopes []string) - SetClientID(id string) - SetTokenType(tokenType string) - SetExpiration(exp time.Time) - SetIssuedAt(iat time.Time) - SetNotBefore(nbf time.Time) - SetAudience(audience []string) - SetIssuer(issuer string) - SetJWTID(id string) - GetScope() []string - GetClientID() string - GetTokenType() string - GetExpiration() time.Time - GetIssuedAt() time.Time - GetNotBefore() time.Time - GetSubject() string - GetAudience() []string - GetIssuer() string - GetJWTID() string -} - -func NewIntrospectionResponse() IntrospectionResponse { - return &introspectionResponse{} -} - -type introspectionResponse struct { +// IntrospectionResponse implements RFC 7662, section 2.2 and +// OpenID Connect Core 1.0, section 5.1 (UserInfo). +// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2. +// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims. +type IntrospectionResponse struct { Active bool `json:"active"` Scope SpaceDelimitedArray `json:"scope,omitempty"` ClientID string `json:"client_id,omitempty"` @@ -58,323 +27,50 @@ type introspectionResponse struct { Audience Audience `json:"aud,omitempty"` Issuer string `json:"iss,omitempty"` JWTID string `json:"jti,omitempty"` - userInfoProfile - userInfoEmail - userInfoPhone + Username string `json:"username,omitempty"` + UserInfoProfile + UserInfoEmail + UserInfoPhone - Address UserInfoAddress `json:"address,omitempty"` - claims map[string]interface{} + Address *UserInfoAddress `json:"address,omitempty"` + Claims map[string]any `json:"-"` } -func (i *introspectionResponse) IsActive() bool { - return i.Active +// SetUserInfo copies all relevant fields from UserInfo +// into the IntroSpectionResponse. +func (i *IntrospectionResponse) SetUserInfo(u *UserInfo) { + i.Subject = u.Subject + i.Username = u.PreferredUsername + i.Address = gu.PtrCopy(u.Address) + i.UserInfoProfile = u.UserInfoProfile + i.UserInfoEmail = u.UserInfoEmail + i.UserInfoPhone = u.UserInfoPhone + if i.Claims == nil { + i.Claims = gu.MapCopy(u.Claims) + } else { + gu.MapMerge(u.Claims, i.Claims) + } } -func (i *introspectionResponse) GetSubject() string { - return i.Subject -} - -func (i *introspectionResponse) GetName() string { - return i.Name -} - -func (i *introspectionResponse) GetGivenName() string { - return i.GivenName -} - -func (i *introspectionResponse) GetFamilyName() string { - return i.FamilyName -} - -func (i *introspectionResponse) GetMiddleName() string { - return i.MiddleName -} - -func (i *introspectionResponse) GetNickname() string { - return i.Nickname -} - -func (i *introspectionResponse) GetProfile() string { - return i.Profile -} - -func (i *introspectionResponse) GetPicture() string { - return i.Picture -} - -func (i *introspectionResponse) GetWebsite() string { - return i.Website -} - -func (i *introspectionResponse) GetGender() Gender { - return i.Gender -} - -func (i *introspectionResponse) GetBirthdate() string { - return i.Birthdate -} - -func (i *introspectionResponse) GetZoneinfo() string { - return i.Zoneinfo -} - -func (i *introspectionResponse) GetLocale() language.Tag { - return i.Locale -} - -func (i *introspectionResponse) GetPreferredUsername() string { - return i.PreferredUsername -} - -func (i *introspectionResponse) GetEmail() string { - return i.Email -} - -func (i *introspectionResponse) IsEmailVerified() bool { - return bool(i.EmailVerified) -} - -func (i *introspectionResponse) GetPhoneNumber() string { - return i.PhoneNumber -} - -func (i *introspectionResponse) IsPhoneNumberVerified() bool { - return i.PhoneNumberVerified -} - -func (i *introspectionResponse) GetAddress() UserInfoAddress { +// GetAddress is a safe getter that takes +// care of a possible nil value. +func (i *IntrospectionResponse) GetAddress() *UserInfoAddress { + if i.Address == nil { + return new(UserInfoAddress) + } return i.Address } -func (i *introspectionResponse) GetClaim(key string) interface{} { - return i.claims[key] -} +// introspectionResponseAlias prevents loops on the JSON methods +type introspectionResponseAlias IntrospectionResponse -func (i *introspectionResponse) GetClaims() map[string]interface{} { - return i.claims -} - -func (i *introspectionResponse) GetScope() []string { - return []string(i.Scope) -} - -func (i *introspectionResponse) GetClientID() string { - return i.ClientID -} - -func (i *introspectionResponse) GetTokenType() string { - return i.TokenType -} - -func (i *introspectionResponse) GetExpiration() time.Time { - return time.Time(i.Expiration) -} - -func (i *introspectionResponse) GetIssuedAt() time.Time { - return time.Time(i.IssuedAt) -} - -func (i *introspectionResponse) GetNotBefore() time.Time { - return time.Time(i.NotBefore) -} - -func (i *introspectionResponse) GetAudience() []string { - return []string(i.Audience) -} - -func (i *introspectionResponse) GetIssuer() string { - return i.Issuer -} - -func (i *introspectionResponse) GetJWTID() string { - return i.JWTID -} - -func (i *introspectionResponse) SetActive(active bool) { - i.Active = active -} - -func (i *introspectionResponse) SetScopes(scope []string) { - i.Scope = scope -} - -func (i *introspectionResponse) SetClientID(id string) { - i.ClientID = id -} - -func (i *introspectionResponse) SetTokenType(tokenType string) { - i.TokenType = tokenType -} - -func (i *introspectionResponse) SetExpiration(exp time.Time) { - i.Expiration = Time(exp) -} - -func (i *introspectionResponse) SetIssuedAt(iat time.Time) { - i.IssuedAt = Time(iat) -} - -func (i *introspectionResponse) SetNotBefore(nbf time.Time) { - i.NotBefore = Time(nbf) -} - -func (i *introspectionResponse) SetAudience(audience []string) { - i.Audience = audience -} - -func (i *introspectionResponse) SetIssuer(issuer string) { - i.Issuer = issuer -} - -func (i *introspectionResponse) SetJWTID(id string) { - i.JWTID = id -} - -func (i *introspectionResponse) SetSubject(sub string) { - i.Subject = sub -} - -func (i *introspectionResponse) SetName(name string) { - i.Name = name -} - -func (i *introspectionResponse) SetGivenName(name string) { - i.GivenName = name -} - -func (i *introspectionResponse) SetFamilyName(name string) { - i.FamilyName = name -} - -func (i *introspectionResponse) SetMiddleName(name string) { - i.MiddleName = name -} - -func (i *introspectionResponse) SetNickname(name string) { - i.Nickname = name -} - -func (i *introspectionResponse) SetUpdatedAt(date time.Time) { - i.UpdatedAt = Time(date) -} - -func (i *introspectionResponse) SetProfile(profile string) { - i.Profile = profile -} - -func (i *introspectionResponse) SetPicture(picture string) { - i.Picture = picture -} - -func (i *introspectionResponse) SetWebsite(website string) { - i.Website = website -} - -func (i *introspectionResponse) SetGender(gender Gender) { - i.Gender = gender -} - -func (i *introspectionResponse) SetBirthdate(birthdate string) { - i.Birthdate = birthdate -} - -func (i *introspectionResponse) SetZoneinfo(zoneInfo string) { - i.Zoneinfo = zoneInfo -} - -func (i *introspectionResponse) SetLocale(locale language.Tag) { - i.Locale = locale -} - -func (i *introspectionResponse) SetPreferredUsername(name string) { - i.PreferredUsername = name -} - -func (i *introspectionResponse) SetEmail(email string, verified bool) { - i.Email = email - i.EmailVerified = boolString(verified) -} - -func (i *introspectionResponse) SetPhone(phone string, verified bool) { - i.PhoneNumber = phone - i.PhoneNumberVerified = verified -} - -func (i *introspectionResponse) SetAddress(address UserInfoAddress) { - i.Address = address -} - -func (i *introspectionResponse) AppendClaims(key string, value interface{}) { - if i.claims == nil { - i.claims = make(map[string]interface{}) +func (i *IntrospectionResponse) MarshalJSON() ([]byte, error) { + if i.Username == "" { + i.Username = i.PreferredUsername } - i.claims[key] = value + return mergeAndMarshalClaims((*introspectionResponseAlias)(i), i.Claims) } -func (i *introspectionResponse) MarshalJSON() ([]byte, error) { - type Alias introspectionResponse - a := &struct { - *Alias - Expiration int64 `json:"exp,omitempty"` - IssuedAt int64 `json:"iat,omitempty"` - NotBefore int64 `json:"nbf,omitempty"` - Locale interface{} `json:"locale,omitempty"` - UpdatedAt int64 `json:"updated_at,omitempty"` - Username string `json:"username,omitempty"` - }{ - Alias: (*Alias)(i), - } - if !i.Locale.IsRoot() { - a.Locale = i.Locale - } - if !time.Time(i.UpdatedAt).IsZero() { - a.UpdatedAt = time.Time(i.UpdatedAt).Unix() - } - if !time.Time(i.Expiration).IsZero() { - a.Expiration = time.Time(i.Expiration).Unix() - } - if !time.Time(i.IssuedAt).IsZero() { - a.IssuedAt = time.Time(i.IssuedAt).Unix() - } - if !time.Time(i.NotBefore).IsZero() { - a.NotBefore = time.Time(i.NotBefore).Unix() - } - a.Username = i.PreferredUsername - - b, err := json.Marshal(a) - if err != nil { - return nil, err - } - - if len(i.claims) == 0 { - return b, nil - } - - err = json.Unmarshal(b, &i.claims) - if err != nil { - return nil, fmt.Errorf("jws: invalid map of custom claims %v", i.claims) - } - - return json.Marshal(i.claims) -} - -func (i *introspectionResponse) UnmarshalJSON(data []byte) error { - type Alias introspectionResponse - a := &struct { - *Alias - UpdatedAt int64 `json:"update_at,omitempty"` - }{ - Alias: (*Alias)(i), - } - if err := json.Unmarshal(data, &a); err != nil { - return err - } - - i.UpdatedAt = Time(time.Unix(a.UpdatedAt, 0).UTC()) - - if err := json.Unmarshal(data, &i.claims); err != nil { - return err - } - - return nil +func (i *IntrospectionResponse) UnmarshalJSON(data []byte) error { + return unmarshalJSONMulti(data, (*introspectionResponseAlias)(i), &i.Claims) } diff --git a/pkg/oidc/introspection_test.go b/pkg/oidc/introspection_test.go new file mode 100644 index 0000000..bd49894 --- /dev/null +++ b/pkg/oidc/introspection_test.go @@ -0,0 +1,78 @@ +package oidc + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIntrospectionResponse_SetUserInfo(t *testing.T) { + tests := []struct { + name string + start *IntrospectionResponse + want *IntrospectionResponse + }{ + { + + name: "nil claims", + start: &IntrospectionResponse{}, + want: &IntrospectionResponse{ + Subject: userInfoData.Subject, + Username: userInfoData.PreferredUsername, + Address: userInfoData.Address, + UserInfoProfile: userInfoData.UserInfoProfile, + UserInfoEmail: userInfoData.UserInfoEmail, + UserInfoPhone: userInfoData.UserInfoPhone, + Claims: userInfoData.Claims, + }, + }, + { + + name: "merge claims", + start: &IntrospectionResponse{ + Claims: map[string]any{ + "hello": "world", + }, + }, + want: &IntrospectionResponse{ + Subject: userInfoData.Subject, + Username: userInfoData.PreferredUsername, + Address: userInfoData.Address, + UserInfoProfile: userInfoData.UserInfoProfile, + UserInfoEmail: userInfoData.UserInfoEmail, + UserInfoPhone: userInfoData.UserInfoPhone, + Claims: map[string]any{ + "foo": "bar", + "hello": "world", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.start.SetUserInfo(userInfoData) + assert.Equal(t, tt.want, tt.start) + }) + } +} + +func TestIntrospectionResponse_GetAddress(t *testing.T) { + // nil address + i := new(IntrospectionResponse) + assert.Equal(t, &UserInfoAddress{}, i.GetAddress()) + + i.Address = &UserInfoAddress{PostalCode: "1234"} + assert.Equal(t, i.Address, i.GetAddress()) +} + +func TestIntrospectionResponse_MarshalJSON(t *testing.T) { + got, err := json.Marshal(&IntrospectionResponse{ + UserInfoProfile: UserInfoProfile{ + PreferredUsername: "muhlemmer", + }, + }) + require.NoError(t, err) + assert.Equal(t, string(got), `{"active":false,"username":"muhlemmer","preferred_username":"muhlemmer"}`) +} diff --git a/pkg/oidc/regression_assert_test.go b/pkg/oidc/regression_assert_test.go new file mode 100644 index 0000000..5e9fb3d --- /dev/null +++ b/pkg/oidc/regression_assert_test.go @@ -0,0 +1,50 @@ +//go:build !create_regression_data + +package oidc + +import ( + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test_assert_regression verifies current output from +// json.Marshal to stored regression data. +// These tests are only ran when the create_regression_data +// tag is NOT set. +func Test_assert_regression(t *testing.T) { + buf := new(strings.Builder) + + for _, obj := range regressionData { + name := jsonFilename(obj) + t.Run(name, func(t *testing.T) { + file, err := os.Open(name) + require.NoError(t, err) + defer file.Close() + + _, err = io.Copy(buf, file) + require.NoError(t, err) + want := buf.String() + buf.Reset() + + encodeJSON(t, buf, obj) + first := buf.String() + buf.Reset() + + assert.JSONEq(t, want, first) + + require.NoError(t, + json.Unmarshal([]byte(first), obj), + ) + second, err := json.Marshal(obj) + require.NoError(t, err) + + assert.JSONEq(t, want, string(second)) + }) + } +} diff --git a/pkg/oidc/regression_create_test.go b/pkg/oidc/regression_create_test.go new file mode 100644 index 0000000..809fe60 --- /dev/null +++ b/pkg/oidc/regression_create_test.go @@ -0,0 +1,24 @@ +//go:build create_regression_data + +package oidc + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test_create_regression generates the regression data. +// It is excluded from regular testing, unless +// called with the create_regression_data tag: +// go test -tags="create_regression_data" ./pkg/oidc +func Test_create_regression(t *testing.T) { + for _, obj := range regressionData { + file, err := os.Create(jsonFilename(obj)) + require.NoError(t, err) + defer file.Close() + + encodeJSON(t, file, obj) + } +} diff --git a/pkg/oidc/regression_data/oidc.AccessTokenClaims.json b/pkg/oidc/regression_data/oidc.AccessTokenClaims.json new file mode 100644 index 0000000..e4f7808 --- /dev/null +++ b/pkg/oidc/regression_data/oidc.AccessTokenClaims.json @@ -0,0 +1,26 @@ +{ + "iss": "zitadel", + "sub": "hello@me.com", + "aud": [ + "foo", + "bar" + ], + "jti": "900", + "azp": "just@me.com", + "nonce": "6969", + "acr": "something", + "amr": [ + "some", + "methods" + ], + "scope": [ + "email", + "phone" + ], + "client_id": "777", + "exp": 12345, + "iat": 12000, + "nbf": 12000, + "auth_time": 12000, + "foo": "bar" +} diff --git a/pkg/oidc/regression_data/oidc.IDTokenClaims.json b/pkg/oidc/regression_data/oidc.IDTokenClaims.json new file mode 100644 index 0000000..af503fb --- /dev/null +++ b/pkg/oidc/regression_data/oidc.IDTokenClaims.json @@ -0,0 +1,51 @@ +{ + "iss": "zitadel", + "aud": [ + "foo", + "bar" + ], + "jti": "900", + "azp": "just@me.com", + "nonce": "6969", + "at_hash": "acthashhash", + "c_hash": "hashhash", + "acr": "something", + "amr": [ + "some", + "methods" + ], + "sid": "666", + "client_id": "777", + "exp": 12345, + "iat": 12000, + "nbf": 12000, + "auth_time": 12000, + "address": { + "country": "Moon", + "formatted": "Sesame street 666\n666-666, Smallvile\nMoon", + "locality": "Smallvile", + "postal_code": "666-666", + "region": "Outer space", + "street_address": "Sesame street 666" + }, + "birthdate": "1st of April", + "email": "tim@zitadel.com", + "email_verified": true, + "family_name": "Möhlmann", + "foo": "bar", + "gender": "male", + "given_name": "Tim", + "locale": "nl", + "middle_name": "Danger", + "name": "Tim Möhlmann", + "nickname": "muhlemmer", + "phone_number": "+1234567890", + "phone_number_verified": true, + "picture": "https://avatars.githubusercontent.com/u/5411563?v=4", + "preferred_username": "muhlemmer", + "profile": "https://github.com/muhlemmer", + "sub": "hello@me.com", + "updated_at": 1, + "website": "https://zitadel.com", + "zoneinfo": "Europe/Amsterdam" +} diff --git a/pkg/oidc/regression_data/oidc.IntrospectionResponse.json b/pkg/oidc/regression_data/oidc.IntrospectionResponse.json new file mode 100644 index 0000000..e0c21a2 --- /dev/null +++ b/pkg/oidc/regression_data/oidc.IntrospectionResponse.json @@ -0,0 +1,44 @@ +{ + "active": true, + "address": { + "country": "Moon", + "formatted": "Sesame street 666\n666-666, Smallvile\nMoon", + "locality": "Smallvile", + "postal_code": "666-666", + "region": "Outer space", + "street_address": "Sesame street 666" + }, + "aud": [ + "foo", + "bar" + ], + "birthdate": "1st of April", + "client_id": "777", + "email": "tim@zitadel.com", + "email_verified": true, + "exp": 12345, + "family_name": "Möhlmann", + "foo": "bar", + "gender": "male", + "given_name": "Tim", + "iat": 12000, + "iss": "zitadel", + "jti": "900", + "locale": "nl", + "middle_name": "Danger", + "name": "Tim Möhlmann", + "nbf": 12000, + "nickname": "muhlemmer", + "phone_number": "+1234567890", + "phone_number_verified": true, + "picture": "https://avatars.githubusercontent.com/u/5411563?v=4", + "preferred_username": "muhlemmer", + "profile": "https://github.com/muhlemmer", + "scope": "email phone", + "sub": "hello@me.com", + "token_type": "idtoken", + "updated_at": 1, + "username": "muhlemmer", + "website": "https://zitadel.com", + "zoneinfo": "Europe/Amsterdam" +} diff --git a/pkg/oidc/regression_data/oidc.JWTProfileAssertionClaims.json b/pkg/oidc/regression_data/oidc.JWTProfileAssertionClaims.json new file mode 100644 index 0000000..4ece780 --- /dev/null +++ b/pkg/oidc/regression_data/oidc.JWTProfileAssertionClaims.json @@ -0,0 +1,11 @@ +{ + "aud": [ + "foo", + "bar" + ], + "exp": 12345, + "foo": "bar", + "iat": 12000, + "iss": "zitadel", + "sub": "hello@me.com" +} diff --git a/pkg/oidc/regression_data/oidc.UserInfo.json b/pkg/oidc/regression_data/oidc.UserInfo.json new file mode 100644 index 0000000..d7795e7 --- /dev/null +++ b/pkg/oidc/regression_data/oidc.UserInfo.json @@ -0,0 +1,30 @@ +{ + "address": { + "country": "Moon", + "formatted": "Sesame street 666\n666-666, Smallvile\nMoon", + "locality": "Smallvile", + "postal_code": "666-666", + "region": "Outer space", + "street_address": "Sesame street 666" + }, + "birthdate": "1st of April", + "email": "tim@zitadel.com", + "email_verified": true, + "family_name": "Möhlmann", + "foo": "bar", + "gender": "male", + "given_name": "Tim", + "locale": "nl", + "middle_name": "Danger", + "name": "Tim Möhlmann", + "nickname": "muhlemmer", + "phone_number": "+1234567890", + "phone_number_verified": true, + "picture": "https://avatars.githubusercontent.com/u/5411563?v=4", + "preferred_username": "muhlemmer", + "profile": "https://github.com/muhlemmer", + "sub": "hello@me.com", + "updated_at": 1, + "website": "https://zitadel.com", + "zoneinfo": "Europe/Amsterdam" +} diff --git a/pkg/oidc/regression_test.go b/pkg/oidc/regression_test.go new file mode 100644 index 0000000..5d33bb6 --- /dev/null +++ b/pkg/oidc/regression_test.go @@ -0,0 +1,40 @@ +package oidc + +// This file contains common functions and data for regression testing + +import ( + "encoding/json" + "fmt" + "io" + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const dataDir = "regression_data" + +// jsonFilename builds a filename for the regression testdata. +// dataDir/.json +func jsonFilename(obj interface{}) string { + name := fmt.Sprintf("%T.json", obj) + return path.Join( + dataDir, + strings.TrimPrefix(name, "*"), + ) +} + +func encodeJSON(t *testing.T, w io.Writer, obj interface{}) { + enc := json.NewEncoder(w) + enc.SetIndent("", "\t") + require.NoError(t, enc.Encode(obj)) +} + +var regressionData = []interface{}{ + accessTokenData, + idTokenData, + introspectionResponseData, + userInfoData, + jwtProfileAssertionData, +} diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index b538465..1ade913 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -2,15 +2,13 @@ package oidc import ( "encoding/json" - "fmt" - "io/ioutil" + "os" "time" "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" "github.com/zitadel/oidc/v2/pkg/crypto" - "github.com/zitadel/oidc/v2/pkg/http" ) const ( @@ -20,380 +18,174 @@ const ( PrefixBearer = BearerToken + " " ) -type Tokens struct { +type Tokens[C IDClaims] struct { *oauth2.Token - IDTokenClaims IDTokenClaims + IDTokenClaims C IDToken string } -type AccessTokenClaims interface { - Claims - GetSubject() string - GetTokenID() string - SetPrivateClaims(map[string]interface{}) - GetClaims() map[string]interface{} -} - -type IDTokenClaims interface { - Claims - GetNotBefore() time.Time - GetJWTID() string - GetAccessTokenHash() string - GetCodeHash() string - GetAuthenticationMethodsReferences() []string - GetClientID() string - GetSignatureAlgorithm() jose.SignatureAlgorithm - SetAccessTokenHash(hash string) - SetUserinfo(userinfo UserInfo) - SetCodeHash(hash string) - UserInfo -} - -func EmptyAccessTokenClaims() AccessTokenClaims { - return new(accessTokenClaims) -} - -func NewAccessTokenClaims(issuer, subject string, audience []string, expiration time.Time, id, clientID string, skew time.Duration) AccessTokenClaims { - now := time.Now().UTC().Add(-skew) - if len(audience) == 0 { - audience = append(audience, clientID) - } - return &accessTokenClaims{ - Issuer: issuer, - Subject: subject, - Audience: audience, - Expiration: Time(expiration), - IssuedAt: Time(now), - NotBefore: Time(now), - JWTID: id, - } -} - -type accessTokenClaims struct { +// TokenClaims contains the base Claims used all tokens. +// It implements OpenID Connect Core 1.0, section 2. +// https://openid.net/specs/openid-connect-core-1_0.html#IDToken +// And RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens, +// section 2.2. https://datatracker.ietf.org/doc/html/rfc9068#name-data-structure +// +// TokenClaims implements the Claims interface, +// and can be used to extend larger claim types by embedding. +type TokenClaims struct { Issuer string `json:"iss,omitempty"` Subject string `json:"sub,omitempty"` Audience Audience `json:"aud,omitempty"` Expiration Time `json:"exp,omitempty"` IssuedAt Time `json:"iat,omitempty"` - NotBefore Time `json:"nbf,omitempty"` - JWTID string `json:"jti,omitempty"` - AuthorizedParty string `json:"azp,omitempty"` - Nonce string `json:"nonce,omitempty"` AuthTime Time `json:"auth_time,omitempty"` - CodeHash string `json:"c_hash,omitempty"` + NotBefore Time `json:"nbf,omitempty"` + Nonce string `json:"nonce,omitempty"` AuthenticationContextClassReference string `json:"acr,omitempty"` AuthenticationMethodsReferences []string `json:"amr,omitempty"` - SessionID string `json:"sid,omitempty"` - Scopes []string `json:"scope,omitempty"` - ClientID string `json:"client_id,omitempty"` - AccessTokenUseNumber int `json:"at_use_nbr,omitempty"` - - claims map[string]interface{} `json:"-"` - signatureAlg jose.SignatureAlgorithm `json:"-"` -} - -// GetIssuer implements the Claims interface -func (a *accessTokenClaims) GetIssuer() string { - return a.Issuer -} - -// GetAudience implements the Claims interface -func (a *accessTokenClaims) GetAudience() []string { - return a.Audience -} - -// GetExpiration implements the Claims interface -func (a *accessTokenClaims) GetExpiration() time.Time { - return time.Time(a.Expiration) -} - -// GetIssuedAt implements the Claims interface -func (a *accessTokenClaims) GetIssuedAt() time.Time { - return time.Time(a.IssuedAt) -} - -// GetNonce implements the Claims interface -func (a *accessTokenClaims) GetNonce() string { - return a.Nonce -} - -// GetAuthenticationContextClassReference implements the Claims interface -func (a *accessTokenClaims) GetAuthenticationContextClassReference() string { - return a.AuthenticationContextClassReference -} - -// GetAuthTime implements the Claims interface -func (a *accessTokenClaims) GetAuthTime() time.Time { - return time.Time(a.AuthTime) -} - -// GetAuthorizedParty implements the Claims interface -func (a *accessTokenClaims) GetAuthorizedParty() string { - return a.AuthorizedParty -} - -// SetSignatureAlgorithm implements the Claims interface -func (a *accessTokenClaims) SetSignatureAlgorithm(algorithm jose.SignatureAlgorithm) { - a.signatureAlg = algorithm -} - -// GetSubject implements the AccessTokenClaims interface -func (a *accessTokenClaims) GetSubject() string { - return a.Subject -} - -// GetTokenID implements the AccessTokenClaims interface -func (a *accessTokenClaims) GetTokenID() string { - return a.JWTID -} - -// SetPrivateClaims implements the AccessTokenClaims interface -func (a *accessTokenClaims) SetPrivateClaims(claims map[string]interface{}) { - a.claims = claims -} - -// GetClaims implements the AccessTokenClaims interface -func (a *accessTokenClaims) GetClaims() map[string]interface{} { - return a.claims -} - -func (a *accessTokenClaims) MarshalJSON() ([]byte, error) { - type Alias accessTokenClaims - s := &struct { - *Alias - Expiration int64 `json:"exp,omitempty"` - IssuedAt int64 `json:"iat,omitempty"` - NotBefore int64 `json:"nbf,omitempty"` - AuthTime int64 `json:"auth_time,omitempty"` - }{ - Alias: (*Alias)(a), - } - if !time.Time(a.Expiration).IsZero() { - s.Expiration = time.Time(a.Expiration).Unix() - } - if !time.Time(a.IssuedAt).IsZero() { - s.IssuedAt = time.Time(a.IssuedAt).Unix() - } - if !time.Time(a.NotBefore).IsZero() { - s.NotBefore = time.Time(a.NotBefore).Unix() - } - if !time.Time(a.AuthTime).IsZero() { - s.AuthTime = time.Time(a.AuthTime).Unix() - } - b, err := json.Marshal(s) - if err != nil { - return nil, err - } - - if a.claims == nil { - return b, nil - } - info, err := json.Marshal(a.claims) - if err != nil { - return nil, err - } - return http.ConcatenateJSON(b, info) -} - -func (a *accessTokenClaims) UnmarshalJSON(data []byte) error { - type Alias accessTokenClaims - if err := json.Unmarshal(data, (*Alias)(a)); err != nil { - return err - } - claims := make(map[string]interface{}) - if err := json.Unmarshal(data, &claims); err != nil { - return err - } - a.claims = claims - - return nil -} - -func EmptyIDTokenClaims() IDTokenClaims { - return new(idTokenClaims) -} - -func NewIDTokenClaims(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration) IDTokenClaims { - audience = AppendClientIDToAudience(clientID, audience) - return &idTokenClaims{ - Issuer: issuer, - Audience: audience, - Expiration: Time(expiration), - IssuedAt: Time(time.Now().UTC().Add(-skew)), - AuthTime: Time(authTime.Add(-skew)), - Nonce: nonce, - AuthenticationContextClassReference: acr, - AuthenticationMethodsReferences: amr, - AuthorizedParty: clientID, - UserInfo: &userinfo{Subject: subject}, - } -} - -type idTokenClaims struct { - Issuer string `json:"iss,omitempty"` - Audience Audience `json:"aud,omitempty"` - Expiration Time `json:"exp,omitempty"` - NotBefore Time `json:"nbf,omitempty"` - IssuedAt Time `json:"iat,omitempty"` - JWTID string `json:"jti,omitempty"` AuthorizedParty string `json:"azp,omitempty"` - Nonce string `json:"nonce,omitempty"` - AuthTime Time `json:"auth_time,omitempty"` - AccessTokenHash string `json:"at_hash,omitempty"` - CodeHash string `json:"c_hash,omitempty"` - AuthenticationContextClassReference string `json:"acr,omitempty"` - AuthenticationMethodsReferences []string `json:"amr,omitempty"` ClientID string `json:"client_id,omitempty"` - UserInfo `json:"-"` + JWTID string `json:"jti,omitempty"` - signatureAlg jose.SignatureAlgorithm + // Additional information set by this framework + SignatureAlg jose.SignatureAlgorithm `json:"-"` } -// GetIssuer implements the Claims interface -func (t *idTokenClaims) GetIssuer() string { - return t.Issuer +func (c *TokenClaims) GetIssuer() string { + return c.Issuer } -// GetAudience implements the Claims interface -func (t *idTokenClaims) GetAudience() []string { - return t.Audience +func (c *TokenClaims) GetSubject() string { + return c.Subject } -// GetExpiration implements the Claims interface -func (t *idTokenClaims) GetExpiration() time.Time { - return time.Time(t.Expiration) +func (c *TokenClaims) GetAudience() []string { + return c.Audience } -// GetIssuedAt implements the Claims interface -func (t *idTokenClaims) GetIssuedAt() time.Time { - return time.Time(t.IssuedAt) +func (c *TokenClaims) GetExpiration() time.Time { + return c.Expiration.AsTime() } -// GetNonce implements the Claims interface -func (t *idTokenClaims) GetNonce() string { - return t.Nonce +func (c *TokenClaims) GetIssuedAt() time.Time { + return c.IssuedAt.AsTime() } -// GetAuthenticationContextClassReference implements the Claims interface -func (t *idTokenClaims) GetAuthenticationContextClassReference() string { - return t.AuthenticationContextClassReference +func (c *TokenClaims) GetNonce() string { + return c.Nonce } -// GetAuthTime implements the Claims interface -func (t *idTokenClaims) GetAuthTime() time.Time { - return time.Time(t.AuthTime) +func (c *TokenClaims) GetAuthTime() time.Time { + return c.AuthTime.AsTime() } -// GetAuthorizedParty implements the Claims interface -func (t *idTokenClaims) GetAuthorizedParty() string { - return t.AuthorizedParty +func (c *TokenClaims) GetAuthorizedParty() string { + return c.AuthorizedParty } -// SetSignatureAlgorithm implements the Claims interface -func (t *idTokenClaims) SetSignatureAlgorithm(alg jose.SignatureAlgorithm) { - t.signatureAlg = alg +func (c *TokenClaims) GetSignatureAlgorithm() jose.SignatureAlgorithm { + return c.SignatureAlg } -// GetNotBefore implements the IDTokenClaims interface -func (t *idTokenClaims) GetNotBefore() time.Time { - return time.Time(t.NotBefore) +func (c *TokenClaims) GetAuthenticationContextClassReference() string { + return c.AuthenticationContextClassReference } -// GetJWTID implements the IDTokenClaims interface -func (t *idTokenClaims) GetJWTID() string { - return t.JWTID +func (c *TokenClaims) SetSignatureAlgorithm(algorithm jose.SignatureAlgorithm) { + c.SignatureAlg = algorithm +} + +type AccessTokenClaims struct { + TokenClaims + Scopes []string `json:"scope,omitempty"` + Claims map[string]any `json:"-"` +} + +func NewAccessTokenClaims(issuer, subject string, audience []string, expiration time.Time, jwtid, clientID string, skew time.Duration) *AccessTokenClaims { + now := time.Now().UTC().Add(-skew) + if len(audience) == 0 { + audience = append(audience, clientID) + } + return &AccessTokenClaims{ + TokenClaims: TokenClaims{ + Issuer: issuer, + Subject: subject, + Audience: audience, + Expiration: FromTime(expiration), + IssuedAt: FromTime(now), + NotBefore: FromTime(now), + JWTID: jwtid, + }, + } +} + +type atcAlias AccessTokenClaims + +func (a *AccessTokenClaims) MarshalJSON() ([]byte, error) { + return mergeAndMarshalClaims((*atcAlias)(a), a.Claims) +} + +func (a *AccessTokenClaims) UnmarshalJSON(data []byte) error { + return unmarshalJSONMulti(data, (*atcAlias)(a), &a.Claims) +} + +// IDTokenClaims extends TokenClaims by further implementing +// OpenID Connect Core 1.0, sections 3.1.3.6 (Code flow), +// 3.2.2.10 (implicit), 3.3.2.11 (Hybrid) and 5.1 (UserInfo). +// https://openid.net/specs/openid-connect-core-1_0.html#toc +type IDTokenClaims struct { + TokenClaims + NotBefore Time `json:"nbf,omitempty"` + AccessTokenHash string `json:"at_hash,omitempty"` + CodeHash string `json:"c_hash,omitempty"` + SessionID string `json:"sid,omitempty"` + UserInfoProfile + UserInfoEmail + UserInfoPhone + Address *UserInfoAddress `json:"address,omitempty"` + Claims map[string]any `json:"-"` } // GetAccessTokenHash implements the IDTokenClaims interface -func (t *idTokenClaims) GetAccessTokenHash() string { +func (t *IDTokenClaims) GetAccessTokenHash() string { return t.AccessTokenHash } -// GetCodeHash implements the IDTokenClaims interface -func (t *idTokenClaims) GetCodeHash() string { - return t.CodeHash +func (t *IDTokenClaims) SetUserInfo(i *UserInfo) { + t.Subject = i.Subject + t.UserInfoProfile = i.UserInfoProfile + t.UserInfoEmail = i.UserInfoEmail + t.UserInfoPhone = i.UserInfoPhone + t.Address = i.Address } -// GetAuthenticationMethodsReferences implements the IDTokenClaims interface -func (t *idTokenClaims) GetAuthenticationMethodsReferences() []string { - return t.AuthenticationMethodsReferences +func NewIDTokenClaims(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration) *IDTokenClaims { + audience = AppendClientIDToAudience(clientID, audience) + return &IDTokenClaims{ + TokenClaims: TokenClaims{ + Issuer: issuer, + Subject: subject, + Audience: audience, + Expiration: FromTime(expiration), + IssuedAt: FromTime(time.Now().Add(-skew)), + AuthTime: FromTime(authTime.Add(-skew)), + Nonce: nonce, + AuthenticationContextClassReference: acr, + AuthenticationMethodsReferences: amr, + AuthorizedParty: clientID, + ClientID: clientID, + }, + } } -// GetClientID implements the IDTokenClaims interface -func (t *idTokenClaims) GetClientID() string { - return t.ClientID +type itcAlias IDTokenClaims + +func (i *IDTokenClaims) MarshalJSON() ([]byte, error) { + return mergeAndMarshalClaims((*itcAlias)(i), i.Claims) } -// GetSignatureAlgorithm implements the IDTokenClaims interface -func (t *idTokenClaims) GetSignatureAlgorithm() jose.SignatureAlgorithm { - return t.signatureAlg -} - -// SetAccessTokenHash implements the IDTokenClaims interface -func (t *idTokenClaims) SetAccessTokenHash(hash string) { - t.AccessTokenHash = hash -} - -// SetUserinfo implements the IDTokenClaims interface -func (t *idTokenClaims) SetUserinfo(info UserInfo) { - t.UserInfo = info -} - -// SetCodeHash implements the IDTokenClaims interface -func (t *idTokenClaims) SetCodeHash(hash string) { - t.CodeHash = hash -} - -func (t *idTokenClaims) MarshalJSON() ([]byte, error) { - type Alias idTokenClaims - a := &struct { - *Alias - Expiration int64 `json:"exp,omitempty"` - IssuedAt int64 `json:"iat,omitempty"` - NotBefore int64 `json:"nbf,omitempty"` - AuthTime int64 `json:"auth_time,omitempty"` - }{ - Alias: (*Alias)(t), - } - if !time.Time(t.Expiration).IsZero() { - a.Expiration = time.Time(t.Expiration).Unix() - } - if !time.Time(t.IssuedAt).IsZero() { - a.IssuedAt = time.Time(t.IssuedAt).Unix() - } - if !time.Time(t.NotBefore).IsZero() { - a.NotBefore = time.Time(t.NotBefore).Unix() - } - if !time.Time(t.AuthTime).IsZero() { - a.AuthTime = time.Time(t.AuthTime).Unix() - } - b, err := json.Marshal(a) - if err != nil { - return nil, err - } - - if t.UserInfo == nil { - return b, nil - } - info, err := json.Marshal(t.UserInfo) - if err != nil { - return nil, err - } - return http.ConcatenateJSON(b, info) -} - -func (t *idTokenClaims) UnmarshalJSON(data []byte) error { - type Alias idTokenClaims - if err := json.Unmarshal(data, (*Alias)(t)); err != nil { - return err - } - userinfo := new(userinfo) - if err := json.Unmarshal(data, userinfo); err != nil { - return err - } - t.UserInfo = userinfo - - return nil +func (i *IDTokenClaims) UnmarshalJSON(data []byte) error { + return unmarshalJSONMulti(data, (*itcAlias)(i), &i.Claims) } type AccessTokenResponse struct { @@ -405,19 +197,7 @@ type AccessTokenResponse struct { State string `json:"state,omitempty" schema:"state,omitempty"` } -type JWTProfileAssertionClaims interface { - GetKeyID() string - GetPrivateKey() []byte - GetIssuer() string - GetSubject() string - GetAudience() []string - GetExpiration() time.Time - GetIssuedAt() time.Time - SetCustomClaim(key string, value interface{}) - GetCustomClaim(key string) interface{} -} - -type jwtProfileAssertion struct { +type JWTProfileAssertionClaims struct { PrivateKeyID string `json:"-"` PrivateKey []byte `json:"-"` Issuer string `json:"iss"` @@ -426,91 +206,21 @@ type jwtProfileAssertion struct { Expiration Time `json:"exp"` IssuedAt Time `json:"iat"` - customClaims map[string]interface{} + Claims map[string]interface{} `json:"-"` } -func (j *jwtProfileAssertion) MarshalJSON() ([]byte, error) { - type Alias jwtProfileAssertion - a := (*Alias)(j) +type jpaAlias JWTProfileAssertionClaims - b, err := json.Marshal(a) - if err != nil { - return nil, err - } - - if len(j.customClaims) == 0 { - return b, nil - } - - err = json.Unmarshal(b, &j.customClaims) - if err != nil { - return nil, fmt.Errorf("jws: invalid map of custom claims %v", j.customClaims) - } - - return json.Marshal(j.customClaims) +func (j *JWTProfileAssertionClaims) MarshalJSON() ([]byte, error) { + return mergeAndMarshalClaims((*jpaAlias)(j), j.Claims) } -func (j *jwtProfileAssertion) UnmarshalJSON(data []byte) error { - type Alias jwtProfileAssertion - a := (*Alias)(j) - - err := json.Unmarshal(data, a) - if err != nil { - return err - } - - err = json.Unmarshal(data, &j.customClaims) - if err != nil { - return err - } - - return nil +func (j *JWTProfileAssertionClaims) UnmarshalJSON(data []byte) error { + return unmarshalJSONMulti(data, (*jpaAlias)(j), &j.Claims) } -func (j *jwtProfileAssertion) GetKeyID() string { - return j.PrivateKeyID -} - -func (j *jwtProfileAssertion) GetPrivateKey() []byte { - return j.PrivateKey -} - -func (j *jwtProfileAssertion) SetCustomClaim(key string, value interface{}) { - if j.customClaims == nil { - j.customClaims = make(map[string]interface{}) - } - j.customClaims[key] = value -} - -func (j *jwtProfileAssertion) GetCustomClaim(key string) interface{} { - if j.customClaims == nil { - return nil - } - return j.customClaims[key] -} - -func (j *jwtProfileAssertion) GetIssuer() string { - return j.Issuer -} - -func (j *jwtProfileAssertion) GetSubject() string { - return j.Subject -} - -func (j *jwtProfileAssertion) GetAudience() []string { - return j.Audience -} - -func (j *jwtProfileAssertion) GetExpiration() time.Time { - return time.Time(j.Expiration) -} - -func (j *jwtProfileAssertion) GetIssuedAt() time.Time { - return time.Time(j.IssuedAt) -} - -func NewJWTProfileAssertionFromKeyJSON(filename string, audience []string, opts ...AssertionOption) (JWTProfileAssertionClaims, error) { - data, err := ioutil.ReadFile(filename) +func NewJWTProfileAssertionFromKeyJSON(filename string, audience []string, opts ...AssertionOption) (*JWTProfileAssertionClaims, error) { + data, err := os.ReadFile(filename) if err != nil { return nil, err } @@ -530,19 +240,19 @@ func NewJWTProfileAssertionStringFromFileData(data []byte, audience []string, op return GenerateJWTProfileToken(NewJWTProfileAssertion(keyData.UserID, keyData.KeyID, audience, []byte(keyData.Key), opts...)) } -func JWTProfileDelegatedSubject(sub string) func(*jwtProfileAssertion) { - return func(j *jwtProfileAssertion) { +func JWTProfileDelegatedSubject(sub string) func(*JWTProfileAssertionClaims) { + return func(j *JWTProfileAssertionClaims) { j.Subject = sub } } -func JWTProfileCustomClaim(key string, value interface{}) func(*jwtProfileAssertion) { - return func(j *jwtProfileAssertion) { - j.customClaims[key] = value +func JWTProfileCustomClaim(key string, value interface{}) func(*JWTProfileAssertionClaims) { + return func(j *JWTProfileAssertionClaims) { + j.Claims[key] = value } } -func NewJWTProfileAssertionFromFileData(data []byte, audience []string, opts ...AssertionOption) (JWTProfileAssertionClaims, error) { +func NewJWTProfileAssertionFromFileData(data []byte, audience []string, opts ...AssertionOption) (*JWTProfileAssertionClaims, error) { keyData := new(struct { KeyID string `json:"keyId"` Key string `json:"key"` @@ -555,18 +265,18 @@ func NewJWTProfileAssertionFromFileData(data []byte, audience []string, opts ... return NewJWTProfileAssertion(keyData.UserID, keyData.KeyID, audience, []byte(keyData.Key), opts...), nil } -type AssertionOption func(*jwtProfileAssertion) +type AssertionOption func(*JWTProfileAssertionClaims) -func NewJWTProfileAssertion(userID, keyID string, audience []string, key []byte, opts ...AssertionOption) JWTProfileAssertionClaims { - j := &jwtProfileAssertion{ +func NewJWTProfileAssertion(userID, keyID string, audience []string, key []byte, opts ...AssertionOption) *JWTProfileAssertionClaims { + j := &JWTProfileAssertionClaims{ PrivateKey: key, PrivateKeyID: keyID, Issuer: userID, Subject: userID, - IssuedAt: Time(time.Now().UTC()), - Expiration: Time(time.Now().Add(1 * time.Hour).UTC()), + IssuedAt: FromTime(time.Now().UTC()), + Expiration: FromTime(time.Now().Add(1 * time.Hour).UTC()), Audience: audience, - customClaims: make(map[string]interface{}), + Claims: make(map[string]interface{}), } for _, opt := range opts { @@ -594,14 +304,14 @@ func AppendClientIDToAudience(clientID string, audience []string) []string { return append(audience, clientID) } -func GenerateJWTProfileToken(assertion JWTProfileAssertionClaims) (string, error) { - privateKey, err := crypto.BytesToPrivateKey(assertion.GetPrivateKey()) +func GenerateJWTProfileToken(assertion *JWTProfileAssertionClaims) (string, error) { + privateKey, err := crypto.BytesToPrivateKey(assertion.PrivateKey) if err != nil { return "", err } key := jose.SigningKey{ Algorithm: jose.RS256, - Key: &jose.JSONWebKey{Key: privateKey, KeyID: assertion.GetKeyID()}, + Key: &jose.JSONWebKey{Key: privateKey, KeyID: assertion.PrivateKeyID}, } signer, err := jose.NewSigner(key, &jose.SignerOptions{}) if err != nil { diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index 78bd658..e63e0e5 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -187,12 +187,12 @@ func (j *JWTTokenRequest) GetAudience() []string { // GetExpiration implements the Claims interface func (j *JWTTokenRequest) GetExpiration() time.Time { - return time.Time(j.ExpiresAt) + return j.ExpiresAt.AsTime() } // GetIssuedAt implements the Claims interface func (j *JWTTokenRequest) GetIssuedAt() time.Time { - return time.Time(j.IssuedAt) + return j.ExpiresAt.AsTime() } // GetNonce implements the Claims interface diff --git a/pkg/oidc/token_test.go b/pkg/oidc/token_test.go new file mode 100644 index 0000000..0d9874e --- /dev/null +++ b/pkg/oidc/token_test.go @@ -0,0 +1,227 @@ +package oidc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + "gopkg.in/square/go-jose.v2" +) + +var ( + tokenClaimsData = TokenClaims{ + Issuer: "zitadel", + Subject: "hello@me.com", + Audience: Audience{"foo", "bar"}, + Expiration: 12345, + IssuedAt: 12000, + JWTID: "900", + AuthorizedParty: "just@me.com", + Nonce: "6969", + AuthTime: 12000, + NotBefore: 12000, + AuthenticationContextClassReference: "something", + AuthenticationMethodsReferences: []string{"some", "methods"}, + ClientID: "777", + SignatureAlg: jose.ES256, + } + accessTokenData = &AccessTokenClaims{ + TokenClaims: tokenClaimsData, + Scopes: []string{"email", "phone"}, + Claims: map[string]interface{}{ + "foo": "bar", + }, + } + idTokenData = &IDTokenClaims{ + TokenClaims: tokenClaimsData, + NotBefore: 12000, + AccessTokenHash: "acthashhash", + CodeHash: "hashhash", + SessionID: "666", + UserInfoProfile: userInfoData.UserInfoProfile, + UserInfoEmail: userInfoData.UserInfoEmail, + UserInfoPhone: userInfoData.UserInfoPhone, + Address: userInfoData.Address, + Claims: map[string]interface{}{ + "foo": "bar", + }, + } + introspectionResponseData = &IntrospectionResponse{ + Active: true, + Scope: SpaceDelimitedArray{"email", "phone"}, + ClientID: "777", + TokenType: "idtoken", + Expiration: 12345, + IssuedAt: 12000, + NotBefore: 12000, + Subject: "hello@me.com", + Audience: Audience{"foo", "bar"}, + Issuer: "zitadel", + JWTID: "900", + Username: "muhlemmer", + UserInfoProfile: userInfoData.UserInfoProfile, + UserInfoEmail: userInfoData.UserInfoEmail, + UserInfoPhone: userInfoData.UserInfoPhone, + Address: userInfoData.Address, + Claims: map[string]interface{}{ + "foo": "bar", + }, + } + userInfoData = &UserInfo{ + Subject: "hello@me.com", + UserInfoProfile: UserInfoProfile{ + Name: "Tim Möhlmann", + GivenName: "Tim", + FamilyName: "Möhlmann", + MiddleName: "Danger", + Nickname: "muhlemmer", + Profile: "https://github.com/muhlemmer", + Picture: "https://avatars.githubusercontent.com/u/5411563?v=4", + Website: "https://zitadel.com", + Gender: "male", + Birthdate: "1st of April", + Zoneinfo: "Europe/Amsterdam", + Locale: NewLocale(language.Dutch), + UpdatedAt: 1, + PreferredUsername: "muhlemmer", + }, + UserInfoEmail: UserInfoEmail{ + Email: "tim@zitadel.com", + EmailVerified: true, + }, + UserInfoPhone: UserInfoPhone{ + PhoneNumber: "+1234567890", + PhoneNumberVerified: true, + }, + Address: &UserInfoAddress{ + Formatted: "Sesame street 666\n666-666, Smallvile\nMoon", + StreetAddress: "Sesame street 666", + Locality: "Smallvile", + Region: "Outer space", + PostalCode: "666-666", + Country: "Moon", + }, + Claims: map[string]interface{}{ + "foo": "bar", + }, + } + jwtProfileAssertionData = &JWTProfileAssertionClaims{ + PrivateKeyID: "8888", + PrivateKey: []byte("qwerty"), + Issuer: "zitadel", + Subject: "hello@me.com", + Audience: Audience{"foo", "bar"}, + Expiration: 12345, + IssuedAt: 12000, + Claims: map[string]interface{}{ + "foo": "bar", + }, + } +) + +func TestTokenClaims(t *testing.T) { + claims := tokenClaimsData + + assert.Equal(t, claims.Issuer, tokenClaimsData.GetIssuer()) + assert.Equal(t, claims.Subject, tokenClaimsData.GetSubject()) + assert.Equal(t, []string(claims.Audience), tokenClaimsData.GetAudience()) + assert.Equal(t, claims.Expiration.AsTime(), tokenClaimsData.GetExpiration()) + assert.Equal(t, claims.IssuedAt.AsTime(), tokenClaimsData.GetIssuedAt()) + assert.Equal(t, claims.Nonce, tokenClaimsData.GetNonce()) + assert.Equal(t, claims.AuthTime.AsTime(), tokenClaimsData.GetAuthTime()) + assert.Equal(t, claims.AuthorizedParty, tokenClaimsData.GetAuthorizedParty()) + assert.Equal(t, claims.SignatureAlg, tokenClaimsData.GetSignatureAlgorithm()) + assert.Equal(t, claims.AuthenticationContextClassReference, tokenClaimsData.GetAuthenticationContextClassReference()) + + claims.SetSignatureAlgorithm(jose.ES384) + assert.Equal(t, jose.ES384, claims.SignatureAlg) +} + +func TestNewAccessTokenClaims(t *testing.T) { + want := &AccessTokenClaims{ + TokenClaims: TokenClaims{ + Issuer: "zitadel", + Subject: "hello@me.com", + Audience: Audience{"foo"}, + Expiration: 12345, + JWTID: "900", + }, + } + + got := NewAccessTokenClaims( + want.Issuer, want.Subject, nil, + want.Expiration.AsTime(), want.JWTID, "foo", time.Second, + ) + + // test if the dynamic timestamps are around now, + // allowing for a delta of 1, just in case we flip on + // either side of a second boundry. + nowMinusSkew := NowTime() - 1 + assert.InDelta(t, int64(nowMinusSkew), int64(got.IssuedAt), 1) + assert.InDelta(t, int64(nowMinusSkew), int64(got.NotBefore), 1) + + // Make equal not fail on dynamic timestamp + got.IssuedAt = 0 + got.NotBefore = 0 + + assert.Equal(t, want, got) +} + +func TestIDTokenClaims_GetAccessTokenHash(t *testing.T) { + assert.Equal(t, idTokenData.AccessTokenHash, idTokenData.GetAccessTokenHash()) +} + +func TestIDTokenClaims_SetUserInfo(t *testing.T) { + want := IDTokenClaims{ + TokenClaims: TokenClaims{ + Subject: userInfoData.Subject, + }, + UserInfoProfile: userInfoData.UserInfoProfile, + UserInfoEmail: userInfoData.UserInfoEmail, + UserInfoPhone: userInfoData.UserInfoPhone, + Address: userInfoData.Address, + } + + var got IDTokenClaims + got.SetUserInfo(userInfoData) + + assert.Equal(t, want, got) +} + +func TestNewIDTokenClaims(t *testing.T) { + want := &IDTokenClaims{ + TokenClaims: TokenClaims{ + Issuer: "zitadel", + Subject: "hello@me.com", + Audience: Audience{"foo", "just@me.com"}, + Expiration: 12345, + AuthTime: 12000, + Nonce: "6969", + AuthenticationContextClassReference: "something", + AuthenticationMethodsReferences: []string{"some", "methods"}, + AuthorizedParty: "just@me.com", + ClientID: "just@me.com", + }, + } + + got := NewIDTokenClaims( + want.Issuer, want.Subject, want.Audience, + want.Expiration.AsTime(), + want.AuthTime.AsTime().Add(time.Second), + want.Nonce, want.AuthenticationContextClassReference, + want.AuthenticationMethodsReferences, want.AuthorizedParty, + time.Second, + ) + + // test if the dynamic timestamp is around now, + // allowing for a delta of 1, just in case we flip on + // either side of a second boundry. + nowMinusSkew := NowTime() - 1 + assert.InDelta(t, int64(nowMinusSkew), int64(got.IssuedAt), 1) + + // Make equal not fail on dynamic timestamp + got.IssuedAt = 0 + + assert.Equal(t, want, got) +} diff --git a/pkg/oidc/types.go b/pkg/oidc/types.go index 21b6fba..415ab04 100644 --- a/pkg/oidc/types.go +++ b/pkg/oidc/types.go @@ -46,6 +46,39 @@ func (d *Display) UnmarshalText(text []byte) error { type Gender string +type Locale struct { + tag language.Tag +} + +func NewLocale(tag language.Tag) *Locale { + return &Locale{tag: tag} +} + +func (l *Locale) Tag() language.Tag { + if l == nil { + return language.Und + } + + return l.tag +} + +func (l *Locale) String() string { + return l.Tag().String() +} + +func (l *Locale) MarshalJSON() ([]byte, error) { + tag := l.Tag() + if tag.IsRoot() { + return []byte("null"), nil + } + + return json.Marshal(tag) +} + +func (l *Locale) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &l.tag) +} + type Locales []language.Tag func (l *Locales) UnmarshalText(text []byte) error { @@ -137,19 +170,18 @@ func NewEncoder() *schema.Encoder { return e } -type Time time.Time +type Time int64 -func (t *Time) UnmarshalJSON(data []byte) error { - var i int64 - if err := json.Unmarshal(data, &i); err != nil { - return err - } - *t = Time(time.Unix(i, 0).UTC()) - return nil +func (ts Time) AsTime() time.Time { + return time.Unix(int64(ts), 0) } -func (t *Time) MarshalJSON() ([]byte, error) { - return json.Marshal(time.Time(*t).UTC().Unix()) +func FromTime(tt time.Time) Time { + return Time(tt.Unix()) +} + +func NowTime() Time { + return FromTime(time.Now()) } type RequestObject struct { @@ -162,5 +194,4 @@ func (r *RequestObject) GetIssuer() string { return r.Issuer } -func (r *RequestObject) SetSignatureAlgorithm(algorithm jose.SignatureAlgorithm) { -} +func (*RequestObject) SetSignatureAlgorithm(algorithm jose.SignatureAlgorithm) {} diff --git a/pkg/oidc/types_test.go b/pkg/oidc/types_test.go index 74323da..c8e2801 100644 --- a/pkg/oidc/types_test.go +++ b/pkg/oidc/types_test.go @@ -10,6 +10,7 @@ import ( "github.com/gorilla/schema" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "golang.org/x/text/language" ) @@ -111,6 +112,117 @@ func TestDisplay_UnmarshalText(t *testing.T) { } } +func TestLocale_Tag(t *testing.T) { + tests := []struct { + name string + l *Locale + want language.Tag + }{ + { + name: "nil", + l: nil, + want: language.Und, + }, + { + name: "Und", + l: NewLocale(language.Und), + want: language.Und, + }, + { + name: "language", + l: NewLocale(language.Afrikaans), + want: language.Afrikaans, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.l.Tag()) + }) + } +} + +func TestLocale_String(t *testing.T) { + tests := []struct { + name string + l *Locale + want language.Tag + }{ + { + name: "nil", + l: nil, + want: language.Und, + }, + { + name: "Und", + l: NewLocale(language.Und), + want: language.Und, + }, + { + name: "language", + l: NewLocale(language.Afrikaans), + want: language.Afrikaans, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want.String(), tt.l.String()) + }) + } +} + +func TestLocale_MarshalJSON(t *testing.T) { + tests := []struct { + name string + l *Locale + want string + wantErr bool + }{ + { + name: "nil", + l: nil, + want: "null", + }, + { + name: "und", + l: NewLocale(language.Und), + want: "null", + }, + { + name: "language", + l: NewLocale(language.Afrikaans), + want: `"af"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.l) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func TestLocale_UnmarshalJSON(t *testing.T) { + type a struct { + Locale *Locale `json:"locale,omitempty"` + } + want := a{ + Locale: NewLocale(language.Afrikaans), + } + + const input = `{"locale": "af"}` + var got a + + require.NoError(t, + json.Unmarshal([]byte(input), &got), + ) + assert.Equal(t, want, got) +} + func TestLocales_UnmarshalText(t *testing.T) { type args struct { text []byte diff --git a/pkg/oidc/userinfo.go b/pkg/oidc/userinfo.go index c8e34d6..caff58e 100644 --- a/pkg/oidc/userinfo.go +++ b/pkg/oidc/userinfo.go @@ -1,320 +1,73 @@ package oidc -import ( - "encoding/json" - "fmt" - "time" - - "golang.org/x/text/language" -) - -type UserInfo interface { - GetSubject() string +// UserInfo implements OpenID Connect Core 1.0, section 5.1. +// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims. +type UserInfo struct { + Subject string `json:"sub,omitempty"` UserInfoProfile UserInfoEmail UserInfoPhone - GetAddress() UserInfoAddress - GetClaim(key string) interface{} - GetClaims() map[string]interface{} + Address *UserInfoAddress `json:"address,omitempty"` + + Claims map[string]any `json:"-"` } -type UserInfoProfile interface { - GetName() string - GetGivenName() string - GetFamilyName() string - GetMiddleName() string - GetNickname() string - GetProfile() string - GetPicture() string - GetWebsite() string - GetGender() Gender - GetBirthdate() string - GetZoneinfo() string - GetLocale() language.Tag - GetPreferredUsername() string +func (u *UserInfo) AppendClaims(k string, v any) { + if u.Claims == nil { + u.Claims = make(map[string]any) + } + + u.Claims[k] = v } -type UserInfoEmail interface { - GetEmail() string - IsEmailVerified() bool -} - -type UserInfoPhone interface { - GetPhoneNumber() string - IsPhoneNumberVerified() bool -} - -type UserInfoAddress interface { - GetFormatted() string - GetStreetAddress() string - GetLocality() string - GetRegion() string - GetPostalCode() string - GetCountry() string -} - -type UserInfoSetter interface { - UserInfo - SetSubject(sub string) - UserInfoProfileSetter - SetEmail(email string, verified bool) - SetPhone(phone string, verified bool) - SetAddress(address UserInfoAddress) - AppendClaims(key string, values interface{}) -} - -type UserInfoProfileSetter interface { - SetName(name string) - SetGivenName(name string) - SetFamilyName(name string) - SetMiddleName(name string) - SetNickname(name string) - SetUpdatedAt(date time.Time) - SetProfile(profile string) - SetPicture(profile string) - SetWebsite(website string) - SetGender(gender Gender) - SetBirthdate(birthdate string) - SetZoneinfo(zoneInfo string) - SetLocale(locale language.Tag) - SetPreferredUsername(name string) -} - -func NewUserInfo() UserInfoSetter { - return &userinfo{} -} - -type userinfo struct { - Subject string `json:"sub,omitempty"` - userInfoProfile - userInfoEmail - userInfoPhone - Address UserInfoAddress `json:"address,omitempty"` - - claims map[string]interface{} -} - -func (u *userinfo) GetSubject() string { - return u.Subject -} - -func (u *userinfo) GetName() string { - return u.Name -} - -func (u *userinfo) GetGivenName() string { - return u.GivenName -} - -func (u *userinfo) GetFamilyName() string { - return u.FamilyName -} - -func (u *userinfo) GetMiddleName() string { - return u.MiddleName -} - -func (u *userinfo) GetNickname() string { - return u.Nickname -} - -func (u *userinfo) GetProfile() string { - return u.Profile -} - -func (u *userinfo) GetPicture() string { - return u.Picture -} - -func (u *userinfo) GetWebsite() string { - return u.Website -} - -func (u *userinfo) GetGender() Gender { - return u.Gender -} - -func (u *userinfo) GetBirthdate() string { - return u.Birthdate -} - -func (u *userinfo) GetZoneinfo() string { - return u.Zoneinfo -} - -func (u *userinfo) GetLocale() language.Tag { - return u.Locale -} - -func (u *userinfo) GetPreferredUsername() string { - return u.PreferredUsername -} - -func (u *userinfo) GetEmail() string { - return u.Email -} - -func (u *userinfo) IsEmailVerified() bool { - return bool(u.EmailVerified) -} - -func (u *userinfo) GetPhoneNumber() string { - return u.PhoneNumber -} - -func (u *userinfo) IsPhoneNumberVerified() bool { - return u.PhoneNumberVerified -} - -func (u *userinfo) GetAddress() UserInfoAddress { +// GetAddress is a safe getter that takes +// care of a possible nil value. +func (u *UserInfo) GetAddress() *UserInfoAddress { if u.Address == nil { - return &userInfoAddress{} + return new(UserInfoAddress) } return u.Address } -func (u *userinfo) GetClaim(key string) interface{} { - return u.claims[key] +type uiAlias UserInfo + +func (u *UserInfo) MarshalJSON() ([]byte, error) { + return mergeAndMarshalClaims((*uiAlias)(u), u.Claims) } -func (u *userinfo) GetClaims() map[string]interface{} { - return u.claims +func (u *UserInfo) UnmarshalJSON(data []byte) error { + return unmarshalJSONMulti(data, (*uiAlias)(u), &u.Claims) } -func (u *userinfo) SetSubject(sub string) { - u.Subject = sub +type UserInfoProfile struct { + Name string `json:"name,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + Nickname string `json:"nickname,omitempty"` + Profile string `json:"profile,omitempty"` + Picture string `json:"picture,omitempty"` + Website string `json:"website,omitempty"` + Gender Gender `json:"gender,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + Zoneinfo string `json:"zoneinfo,omitempty"` + Locale *Locale `json:"locale,omitempty"` + UpdatedAt Time `json:"updated_at,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` } -func (u *userinfo) SetName(name string) { - u.Name = name -} - -func (u *userinfo) SetGivenName(name string) { - u.GivenName = name -} - -func (u *userinfo) SetFamilyName(name string) { - u.FamilyName = name -} - -func (u *userinfo) SetMiddleName(name string) { - u.MiddleName = name -} - -func (u *userinfo) SetNickname(name string) { - u.Nickname = name -} - -func (u *userinfo) SetUpdatedAt(date time.Time) { - u.UpdatedAt = Time(date) -} - -func (u *userinfo) SetProfile(profile string) { - u.Profile = profile -} - -func (u *userinfo) SetPicture(picture string) { - u.Picture = picture -} - -func (u *userinfo) SetWebsite(website string) { - u.Website = website -} - -func (u *userinfo) SetGender(gender Gender) { - u.Gender = gender -} - -func (u *userinfo) SetBirthdate(birthdate string) { - u.Birthdate = birthdate -} - -func (u *userinfo) SetZoneinfo(zoneInfo string) { - u.Zoneinfo = zoneInfo -} - -func (u *userinfo) SetLocale(locale language.Tag) { - u.Locale = locale -} - -func (u *userinfo) SetPreferredUsername(name string) { - u.PreferredUsername = name -} - -func (u *userinfo) SetEmail(email string, verified bool) { - u.Email = email - u.EmailVerified = boolString(verified) -} - -func (u *userinfo) SetPhone(phone string, verified bool) { - u.PhoneNumber = phone - u.PhoneNumberVerified = verified -} - -func (u *userinfo) SetAddress(address UserInfoAddress) { - u.Address = address -} - -func (u *userinfo) AppendClaims(key string, value interface{}) { - if u.claims == nil { - u.claims = make(map[string]interface{}) - } - u.claims[key] = value -} - -func (u *userInfoAddress) GetFormatted() string { - return u.Formatted -} - -func (u *userInfoAddress) GetStreetAddress() string { - return u.StreetAddress -} - -func (u *userInfoAddress) GetLocality() string { - return u.Locality -} - -func (u *userInfoAddress) GetRegion() string { - return u.Region -} - -func (u *userInfoAddress) GetPostalCode() string { - return u.PostalCode -} - -func (u *userInfoAddress) GetCountry() string { - return u.Country -} - -type userInfoProfile struct { - Name string `json:"name,omitempty"` - GivenName string `json:"given_name,omitempty"` - FamilyName string `json:"family_name,omitempty"` - MiddleName string `json:"middle_name,omitempty"` - Nickname string `json:"nickname,omitempty"` - Profile string `json:"profile,omitempty"` - Picture string `json:"picture,omitempty"` - Website string `json:"website,omitempty"` - Gender Gender `json:"gender,omitempty"` - Birthdate string `json:"birthdate,omitempty"` - Zoneinfo string `json:"zoneinfo,omitempty"` - Locale language.Tag `json:"locale,omitempty"` - UpdatedAt Time `json:"updated_at,omitempty"` - PreferredUsername string `json:"preferred_username,omitempty"` -} - -type userInfoEmail struct { +type UserInfoEmail struct { Email string `json:"email,omitempty"` // Handle providers that return email_verified as a string // https://forums.aws.amazon.com/thread.jspa?messageID=949441󧳁 // https://discuss.elastic.co/t/openid-error-after-authenticating-against-aws-cognito/206018/11 - EmailVerified boolString `json:"email_verified,omitempty"` + EmailVerified Bool `json:"email_verified,omitempty"` } -type boolString bool +type Bool bool -func (bs *boolString) UnmarshalJSON(data []byte) error { +func (bs *Bool) UnmarshalJSON(data []byte) error { if string(data) == "true" || string(data) == `"true"` { *bs = true } @@ -322,12 +75,12 @@ func (bs *boolString) UnmarshalJSON(data []byte) error { return nil } -type userInfoPhone struct { +type UserInfoPhone struct { PhoneNumber string `json:"phone_number,omitempty"` PhoneNumberVerified bool `json:"phone_number_verified,omitempty"` } -type userInfoAddress struct { +type UserInfoAddress struct { Formatted string `json:"formatted,omitempty"` StreetAddress string `json:"street_address,omitempty"` Locality string `json:"locality,omitempty"` @@ -336,76 +89,6 @@ type userInfoAddress struct { Country string `json:"country,omitempty"` } -func NewUserInfoAddress(streetAddress, locality, region, postalCode, country, formatted string) UserInfoAddress { - return &userInfoAddress{ - StreetAddress: streetAddress, - Locality: locality, - Region: region, - PostalCode: postalCode, - Country: country, - Formatted: formatted, - } -} - -func (u *userinfo) MarshalJSON() ([]byte, error) { - type Alias userinfo - a := &struct { - *Alias - Locale interface{} `json:"locale,omitempty"` - UpdatedAt int64 `json:"updated_at,omitempty"` - }{ - Alias: (*Alias)(u), - } - if !u.Locale.IsRoot() { - a.Locale = u.Locale - } - if !time.Time(u.UpdatedAt).IsZero() { - a.UpdatedAt = time.Time(u.UpdatedAt).Unix() - } - - b, err := json.Marshal(a) - if err != nil { - return nil, err - } - - if len(u.claims) == 0 { - return b, nil - } - - err = json.Unmarshal(b, &u.claims) - if err != nil { - return nil, fmt.Errorf("jws: invalid map of custom claims %v", u.claims) - } - - return json.Marshal(u.claims) -} - -func (u *userinfo) UnmarshalJSON(data []byte) error { - type Alias userinfo - a := &struct { - Address *userInfoAddress `json:"address,omitempty"` - *Alias - UpdatedAt int64 `json:"update_at,omitempty"` - }{ - Alias: (*Alias)(u), - } - if err := json.Unmarshal(data, &a); err != nil { - return err - } - - if a.Address != nil { - u.Address = a.Address - } - - u.UpdatedAt = Time(time.Unix(a.UpdatedAt, 0).UTC()) - - if err := json.Unmarshal(data, &u.claims); err != nil { - return err - } - - return nil -} - type UserInfoRequest struct { AccessToken string `schema:"access_token"` } diff --git a/pkg/oidc/userinfo_test.go b/pkg/oidc/userinfo_test.go index 319a2fd..faab4e3 100644 --- a/pkg/oidc/userinfo_test.go +++ b/pkg/oidc/userinfo_test.go @@ -7,21 +7,54 @@ import ( "github.com/stretchr/testify/assert" ) +func TestUserInfo_AppendClaims(t *testing.T) { + u := new(UserInfo) + u.AppendClaims("a", "b") + want := map[string]any{"a": "b"} + assert.Equal(t, want, u.Claims) + + u.AppendClaims("d", "e") + want["d"] = "e" + assert.Equal(t, want, u.Claims) +} + +func TestUserInfo_GetAddress(t *testing.T) { + // nil address + u := new(UserInfo) + assert.Equal(t, &UserInfoAddress{}, u.GetAddress()) + + u.Address = &UserInfoAddress{PostalCode: "1234"} + assert.Equal(t, u.Address, u.GetAddress()) +} + func TestUserInfoMarshal(t *testing.T) { - userinfo := NewUserInfo() - userinfo.SetSubject("test") - userinfo.SetAddress(NewUserInfoAddress("Test 789\nPostfach 2", "", "", "", "", "")) - userinfo.SetEmail("test", true) - userinfo.SetPhone("0791234567", true) - userinfo.SetName("Test") - userinfo.AppendClaims("private_claim", "test") + userinfo := &UserInfo{ + Subject: "test", + Address: &UserInfoAddress{ + StreetAddress: "Test 789\nPostfach 2", + }, + UserInfoEmail: UserInfoEmail{ + Email: "test", + EmailVerified: true, + }, + UserInfoPhone: UserInfoPhone{ + PhoneNumber: "0791234567", + PhoneNumberVerified: true, + }, + UserInfoProfile: UserInfoProfile{ + Name: "Test", + }, + Claims: map[string]any{"private_claim": "test"}, + } marshal, err := json.Marshal(userinfo) - out := NewUserInfo() assert.NoError(t, err) + + out := new(UserInfo) assert.NoError(t, json.Unmarshal(marshal, out)) - assert.Equal(t, userinfo.GetAddress(), out.GetAddress()) + assert.Equal(t, userinfo, out) expected, err := json.Marshal(out) + assert.NoError(t, err) assert.Equal(t, expected, marshal) } @@ -29,91 +62,55 @@ func TestUserInfoMarshal(t *testing.T) { func TestUserInfoEmailVerifiedUnmarshal(t *testing.T) { t.Parallel() - t.Run("unmarsha email_verified from json bool true", func(t *testing.T) { + t.Run("unmarshal email_verified from json bool true", func(t *testing.T) { jsonBool := []byte(`{"email": "my@email.com", "email_verified": true}`) - var uie userInfoEmail + var uie UserInfoEmail err := json.Unmarshal(jsonBool, &uie) assert.NoError(t, err) - assert.Equal(t, userInfoEmail{ + assert.Equal(t, UserInfoEmail{ Email: "my@email.com", EmailVerified: true, }, uie) }) - t.Run("unmarsha email_verified from json string true", func(t *testing.T) { + t.Run("unmarshal email_verified from json string true", func(t *testing.T) { jsonBool := []byte(`{"email": "my@email.com", "email_verified": "true"}`) - var uie userInfoEmail + var uie UserInfoEmail err := json.Unmarshal(jsonBool, &uie) assert.NoError(t, err) - assert.Equal(t, userInfoEmail{ + assert.Equal(t, UserInfoEmail{ Email: "my@email.com", EmailVerified: true, }, uie) }) - t.Run("unmarsha email_verified from json bool false", func(t *testing.T) { + t.Run("unmarshal email_verified from json bool false", func(t *testing.T) { jsonBool := []byte(`{"email": "my@email.com", "email_verified": false}`) - var uie userInfoEmail + var uie UserInfoEmail err := json.Unmarshal(jsonBool, &uie) assert.NoError(t, err) - assert.Equal(t, userInfoEmail{ + assert.Equal(t, UserInfoEmail{ Email: "my@email.com", EmailVerified: false, }, uie) }) - t.Run("unmarsha email_verified from json string false", func(t *testing.T) { + t.Run("unmarshal email_verified from json string false", func(t *testing.T) { jsonBool := []byte(`{"email": "my@email.com", "email_verified": "false"}`) - var uie userInfoEmail + var uie UserInfoEmail err := json.Unmarshal(jsonBool, &uie) assert.NoError(t, err) - assert.Equal(t, userInfoEmail{ + assert.Equal(t, UserInfoEmail{ Email: "my@email.com", EmailVerified: false, }, uie) }) } - -// issue 203 test case. -func Test_userinfo_GetAddress_issue_203(t *testing.T) { - tests := []struct { - name string - data string - }{ - { - name: "with address", - data: `{"address":{"street_address":"Test 789\nPostfach 2"},"email":"test","email_verified":true,"name":"Test","phone_number":"0791234567","phone_number_verified":true,"private_claim":"test","sub":"test"}`, - }, - { - name: "without address", - data: `{"email":"test","email_verified":true,"name":"Test","phone_number":"0791234567","phone_number_verified":true,"private_claim":"test","sub":"test"}`, - }, - { - name: "null address", - data: `{"address":null,"email":"test","email_verified":true,"name":"Test","phone_number":"0791234567","phone_number_verified":true,"private_claim":"test","sub":"test"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - info := &userinfo{} - err := json.Unmarshal([]byte(tt.data), info) - assert.NoError(t, err) - - info.GetAddress().GetCountry() //<- used to panic - - // now shortly assure that a marshalling still produces the same as was parsed into the struct - marshal, err := json.Marshal(info) - assert.NoError(t, err) - assert.Equal(t, tt.data, string(marshal)) - }) - } -} diff --git a/pkg/oidc/util.go b/pkg/oidc/util.go new file mode 100644 index 0000000..a89d75e --- /dev/null +++ b/pkg/oidc/util.go @@ -0,0 +1,49 @@ +package oidc + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// mergeAndMarshalClaims merges registered and the custom +// claims map into a single JSON object. +// Registered fields overwrite custom claims. +func mergeAndMarshalClaims(registered any, claims map[string]any) ([]byte, error) { + // Use a buffer for memory re-use, instead off letting + // json allocate a new []byte for every step. + buf := new(bytes.Buffer) + + // Marshal the registered claims into JSON + if err := json.NewEncoder(buf).Encode(registered); err != nil { + return nil, fmt.Errorf("oidc registered claims: %w", err) + } + + if len(claims) > 0 { + // Merge JSON data into custom claims. + // The full-read action by the decoder resets the buffer + // to zero len, while retaining underlaying cap. + if err := json.NewDecoder(buf).Decode(&claims); err != nil { + return nil, fmt.Errorf("oidc registered claims: %w", err) + } + + // Marshal the final result. + if err := json.NewEncoder(buf).Encode(claims); err != nil { + return nil, fmt.Errorf("oidc custom claims: %w", err) + } + } + + return buf.Bytes(), nil +} + +// unmarshalJSONMulti unmarshals the same JSON data into multiple destinations. +// Each destination must be a pointer, as per json.Unmarshal rules. +// Returns on the first error and destinations may be partly filled with data. +func unmarshalJSONMulti(data []byte, destinations ...any) error { + for _, dst := range destinations { + if err := json.Unmarshal(data, dst); err != nil { + return fmt.Errorf("oidc: %w into %T", err, dst) + } + } + return nil +} diff --git a/pkg/oidc/util_test.go b/pkg/oidc/util_test.go new file mode 100644 index 0000000..6363d83 --- /dev/null +++ b/pkg/oidc/util_test.go @@ -0,0 +1,147 @@ +package oidc + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type jsonErrorTest struct{} + +func (jsonErrorTest) MarshalJSON() ([]byte, error) { + return nil, errors.New("test") +} + +func Test_mergeAndMarshalClaims(t *testing.T) { + type args struct { + registered any + claims map[string]any + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "encoder error", + args: args{ + registered: jsonErrorTest{}, + }, + wantErr: true, + }, + { + name: "no claims", + args: args{ + registered: struct { + Foo string `json:"foo,omitempty"` + }{ + Foo: "bar", + }, + }, + want: "{\"foo\":\"bar\"}\n", + }, + { + name: "with claims", + args: args{ + registered: struct { + Foo string `json:"foo,omitempty"` + }{ + Foo: "bar", + }, + claims: map[string]any{ + "bar": "foo", + }, + }, + want: "{\"bar\":\"foo\",\"foo\":\"bar\"}\n", + }, + { + name: "registered overwrites custom", + args: args{ + registered: struct { + Foo string `json:"foo,omitempty"` + }{ + Foo: "bar", + }, + claims: map[string]any{ + "foo": "Hello, World!", + }, + }, + want: "{\"foo\":\"bar\"}\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := mergeAndMarshalClaims(tt.args.registered, tt.args.claims) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func Test_unmarshalJSONMulti(t *testing.T) { + type dst struct { + Foo string `json:"foo,omitempty"` + } + + type args struct { + data string + destinations []any + } + tests := []struct { + name string + args args + want []any + wantErr bool + }{ + { + name: "error", + args: args{ + data: "~!~~", + destinations: []any{ + &dst{}, + &map[string]any{}, + }, + }, + want: []any{ + &dst{}, + &map[string]any{}, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + data: "{\"bar\":\"foo\",\"foo\":\"bar\"}\n", + destinations: []any{ + &dst{}, + &map[string]any{}, + }, + }, + want: []any{ + &dst{Foo: "bar"}, + &map[string]any{ + "foo": "bar", + "bar": "foo", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := unmarshalJSONMulti([]byte(tt.args.data), tt.args.destinations...) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, tt.args.destinations) + }) + } +} diff --git a/pkg/oidc/verifier.go b/pkg/oidc/verifier.go index 1757651..c4ee95e 100644 --- a/pkg/oidc/verifier.go +++ b/pkg/oidc/verifier.go @@ -32,6 +32,12 @@ type ClaimsSignature interface { SetSignatureAlgorithm(algorithm jose.SignatureAlgorithm) } +type IDClaims interface { + Claims + GetSignatureAlgorithm() jose.SignatureAlgorithm + GetAccessTokenHash() string +} + var ( ErrParse = errors.New("parsing of request failed") ErrIssuerInvalid = errors.New("issuer does not match") diff --git a/pkg/op/auth_request.go b/pkg/op/auth_request.go index b13f642..bd6aa95 100644 --- a/pkg/op/auth_request.go +++ b/pkg/op/auth_request.go @@ -371,7 +371,7 @@ func ValidateAuthReqIDTokenHint(ctx context.Context, idTokenHint string, verifie if idTokenHint == "" { return "", nil } - claims, err := VerifyIDTokenHint(ctx, idTokenHint, verifier) + claims, err := VerifyIDTokenHint[*oidc.TokenClaims](ctx, idTokenHint, verifier) if err != nil { return "", oidc.ErrLoginRequired().WithDescription("The id_token_hint is invalid. " + "If you have any questions, you may contact the administrator of the application.") diff --git a/pkg/op/mock/storage.mock.go b/pkg/op/mock/storage.mock.go index fc0c358..85afb2a 100644 --- a/pkg/op/mock/storage.mock.go +++ b/pkg/op/mock/storage.mock.go @@ -263,7 +263,7 @@ func (mr *MockStorageMockRecorder) SaveAuthCode(arg0, arg1, arg2 interface{}) *g } // SetIntrospectionFromToken mocks base method. -func (m *MockStorage) SetIntrospectionFromToken(arg0 context.Context, arg1 oidc.IntrospectionResponse, arg2, arg3, arg4 string) error { +func (m *MockStorage) SetIntrospectionFromToken(arg0 context.Context, arg1 *oidc.IntrospectionResponse, arg2, arg3, arg4 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetIntrospectionFromToken", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(error) @@ -277,7 +277,7 @@ func (mr *MockStorageMockRecorder) SetIntrospectionFromToken(arg0, arg1, arg2, a } // SetUserinfoFromScopes mocks base method. -func (m *MockStorage) SetUserinfoFromScopes(arg0 context.Context, arg1 oidc.UserInfoSetter, arg2, arg3 string, arg4 []string) error { +func (m *MockStorage) SetUserinfoFromScopes(arg0 context.Context, arg1 *oidc.UserInfo, arg2, arg3 string, arg4 []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetUserinfoFromScopes", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(error) @@ -291,7 +291,7 @@ func (mr *MockStorageMockRecorder) SetUserinfoFromScopes(arg0, arg1, arg2, arg3, } // SetUserinfoFromToken mocks base method. -func (m *MockStorage) SetUserinfoFromToken(arg0 context.Context, arg1 oidc.UserInfoSetter, arg2, arg3, arg4 string) error { +func (m *MockStorage) SetUserinfoFromToken(arg0 context.Context, arg1 *oidc.UserInfo, arg2, arg3, arg4 string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetUserinfoFromToken", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(error) diff --git a/pkg/op/session.go b/pkg/op/session.go index e1cc595..3e5ec3c 100644 --- a/pkg/op/session.go +++ b/pkg/op/session.go @@ -59,7 +59,7 @@ func ValidateEndSessionRequest(ctx context.Context, req *oidc.EndSessionRequest, RedirectURI: ender.DefaultLogoutRedirectURI(), } if req.IdTokenHint != "" { - claims, err := VerifyIDTokenHint(ctx, req.IdTokenHint, ender.IDTokenHintVerifier(ctx)) + claims, err := VerifyIDTokenHint[*oidc.TokenClaims](ctx, req.IdTokenHint, ender.IDTokenHintVerifier(ctx)) if err != nil { return nil, oidc.ErrInvalidRequest().WithDescription("id_token_hint invalid").WithParent(err) } diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 8ba1946..5940bd9 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -96,7 +96,7 @@ type TokenExchangeStorage interface { // SetUserinfoFromTokenExchangeRequest will be called during id token creation. // Claims evaluation can be based on all validated request data available, including: scopes, resource, audience, etc. - SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo oidc.UserInfoSetter, request TokenExchangeRequest) error + SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo *oidc.UserInfo, request TokenExchangeRequest) error } // TokenExchangeTokensVerifierStorage is an optional interface used in token exchange process to verify tokens @@ -111,9 +111,9 @@ var ErrInvalidRefreshToken = errors.New("invalid_refresh_token") type OPStorage interface { GetClientByClientID(ctx context.Context, clientID string) (Client, error) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error - SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, userID, clientID string, scopes []string) error - SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error - SetIntrospectionFromToken(ctx context.Context, userinfo oidc.IntrospectionResponse, tokenID, subject, clientID string) error + SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error + SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error + SetIntrospectionFromToken(ctx context.Context, userinfo *oidc.IntrospectionResponse, tokenID, subject, clientID string) error GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (map[string]interface{}, error) GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error) diff --git a/pkg/op/token.go b/pkg/op/token.go index 3a35062..58568a7 100644 --- a/pkg/op/token.go +++ b/pkg/op/token.go @@ -129,7 +129,7 @@ func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, ex if err != nil { return "", err } - claims.SetPrivateClaims(privateClaims) + claims.Claims = privateClaims } signingKey, err := storage.SigningKey(ctx) if err != nil { @@ -169,7 +169,7 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v if err != nil { return "", err } - claims.SetAccessTokenHash(atHash) + claims.AccessTokenHash = atHash if !client.IDTokenUserinfoClaimsAssertion() { scopes = removeUserinfoScopes(scopes) } @@ -178,26 +178,26 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v tokenExchangeRequest, okReq := request.(TokenExchangeRequest) teStorage, okStorage := storage.(TokenExchangeStorage) if okReq && okStorage { - userInfo := oidc.NewUserInfo() + userInfo := new(oidc.UserInfo) err := teStorage.SetUserinfoFromTokenExchangeRequest(ctx, userInfo, tokenExchangeRequest) if err != nil { return "", err } - claims.SetUserinfo(userInfo) + claims.SetUserInfo(userInfo) } else if len(scopes) > 0 { - userInfo := oidc.NewUserInfo() + userInfo := new(oidc.UserInfo) err := storage.SetUserinfoFromScopes(ctx, userInfo, request.GetSubject(), request.GetClientID(), scopes) if err != nil { return "", err } - claims.SetUserinfo(userInfo) + claims.SetUserInfo(userInfo) } if code != "" { codeHash, err := oidc.ClaimHash(code, signingKey.SignatureAlgorithm()) if err != nil { return "", err } - claims.SetCodeHash(codeHash) + claims.CodeHash = codeHash } signer, err := SignerFromKey(signingKey) if err != nil { diff --git a/pkg/op/token_exchange.go b/pkg/op/token_exchange.go index 6b918b1..055ff13 100644 --- a/pkg/op/token_exchange.go +++ b/pkg/op/token_exchange.go @@ -280,9 +280,9 @@ func GetTokenIDAndSubjectFromToken( ) (tokenIDOrToken, subject string, claims map[string]interface{}, ok bool) { switch tokenType { case oidc.AccessTokenType: - var accessTokenClaims oidc.AccessTokenClaims + var accessTokenClaims *oidc.AccessTokenClaims tokenIDOrToken, subject, accessTokenClaims, ok = getTokenIDAndClaims(ctx, exchanger, token) - claims = accessTokenClaims.GetClaims() + claims = accessTokenClaims.Claims case oidc.RefreshTokenType: refreshTokenRequest, err := exchanger.Storage().TokenRequestByRefreshToken(ctx, token) if err != nil { @@ -291,12 +291,12 @@ func GetTokenIDAndSubjectFromToken( tokenIDOrToken, subject, ok = token, refreshTokenRequest.GetSubject(), true case oidc.IDTokenType: - idTokenClaims, err := VerifyIDTokenHint(ctx, token, exchanger.IDTokenHintVerifier(ctx)) + idTokenClaims, err := VerifyIDTokenHint[*oidc.IDTokenClaims](ctx, token, exchanger.IDTokenHintVerifier(ctx)) if err != nil { break } - tokenIDOrToken, subject, claims, ok = token, idTokenClaims.GetSubject(), idTokenClaims.GetClaims(), true + tokenIDOrToken, subject, claims, ok = token, idTokenClaims.Subject, idTokenClaims.Claims, true } if !ok { @@ -380,7 +380,7 @@ func CreateTokenExchangeResponse( }, nil } -func getTokenIDAndClaims(ctx context.Context, userinfoProvider UserinfoProvider, accessToken string) (string, string, oidc.AccessTokenClaims, bool) { +func getTokenIDAndClaims(ctx context.Context, userinfoProvider UserinfoProvider, accessToken string) (string, string, *oidc.AccessTokenClaims, bool) { tokenIDSubject, err := userinfoProvider.Crypto().Decrypt(accessToken) if err == nil { splitToken := strings.Split(tokenIDSubject, ":") @@ -390,10 +390,10 @@ func getTokenIDAndClaims(ctx context.Context, userinfoProvider UserinfoProvider, return splitToken[0], splitToken[1], nil, true } - accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) + accessTokenClaims, err := VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) if err != nil { return "", "", nil, false } - return accessTokenClaims.GetTokenID(), accessTokenClaims.GetSubject(), accessTokenClaims, true + return accessTokenClaims.JWTID, accessTokenClaims.Subject, accessTokenClaims, true } diff --git a/pkg/op/token_intospection.go b/pkg/op/token_intospection.go index e7ca7c4..8582388 100644 --- a/pkg/op/token_intospection.go +++ b/pkg/op/token_intospection.go @@ -28,7 +28,7 @@ func introspectionHandler(introspector Introspector) func(http.ResponseWriter, * } func Introspect(w http.ResponseWriter, r *http.Request, introspector Introspector) { - response := oidc.NewIntrospectionResponse() + response := new(oidc.IntrospectionResponse) token, clientID, err := ParseTokenIntrospectionRequest(r, introspector) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) @@ -44,7 +44,7 @@ func Introspect(w http.ResponseWriter, r *http.Request, introspector Introspecto httphelper.MarshalJSON(w, response) return } - response.SetActive(true) + response.Active = true httphelper.MarshalJSON(w, response) } diff --git a/pkg/op/token_revocation.go b/pkg/op/token_revocation.go index 33978f5..58332c3 100644 --- a/pkg/op/token_revocation.go +++ b/pkg/op/token_revocation.go @@ -151,9 +151,9 @@ func getTokenIDAndSubjectForRevocation(ctx context.Context, userinfoProvider Use } return splitToken[0], splitToken[1], true } - accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) + accessTokenClaims, err := VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) if err != nil { return "", "", false } - return accessTokenClaims.GetTokenID(), accessTokenClaims.GetSubject(), true + return accessTokenClaims.JWTID, accessTokenClaims.Subject, true } diff --git a/pkg/op/userinfo.go b/pkg/op/userinfo.go index cb8f0ae..21a0af4 100644 --- a/pkg/op/userinfo.go +++ b/pkg/op/userinfo.go @@ -34,7 +34,7 @@ func Userinfo(w http.ResponseWriter, r *http.Request, userinfoProvider UserinfoP http.Error(w, "access token invalid", http.StatusUnauthorized) return } - info := oidc.NewUserInfo() + info := new(oidc.UserInfo) err = userinfoProvider.Storage().SetUserinfoFromToken(r.Context(), info, tokenID, subject, r.Header.Get("origin")) if err != nil { httphelper.MarshalJSONWithStatus(w, err, http.StatusForbidden) @@ -81,9 +81,9 @@ func getTokenIDAndSubject(ctx context.Context, userinfoProvider UserinfoProvider } return splitToken[0], splitToken[1], true } - accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) + accessTokenClaims, err := VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) if err != nil { return "", "", false } - return accessTokenClaims.GetTokenID(), accessTokenClaims.GetSubject(), true + return accessTokenClaims.JWTID, accessTokenClaims.Subject, true } diff --git a/pkg/op/verifier_access_token.go b/pkg/op/verifier_access_token.go index 1d53adb..9a8b912 100644 --- a/pkg/op/verifier_access_token.go +++ b/pkg/op/verifier_access_token.go @@ -18,8 +18,6 @@ type accessTokenVerifier struct { maxAgeIAT time.Duration offset time.Duration supportedSignAlgs []string - maxAge time.Duration - acr oidc.ACRVerifier keySet oidc.KeySet } @@ -67,29 +65,29 @@ func NewAccessTokenVerifier(issuer string, keySet oidc.KeySet, opts ...AccessTok return verifier } -// VerifyAccessToken validates the access token (issuer, signature and expiration) -func VerifyAccessToken(ctx context.Context, token string, v AccessTokenVerifier) (oidc.AccessTokenClaims, error) { - claims := oidc.EmptyAccessTokenClaims() +// VerifyAccessToken validates the access token (issuer, signature and expiration). +func VerifyAccessToken[C oidc.Claims](ctx context.Context, token string, v AccessTokenVerifier) (claims C, err error) { + var nilClaims C decrypted, err := oidc.DecryptToken(token) if err != nil { - return nil, err + return nilClaims, err } - payload, err := oidc.ParseToken(decrypted, claims) + payload, err := oidc.ParseToken(decrypted, &claims) if err != nil { - return nil, err + return nilClaims, err } if err := oidc.CheckIssuer(claims, v.Issuer()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckSignature(ctx, decrypted, payload, claims, v.SupportedSignAlgs(), v.KeySet()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckExpiration(claims, v.Offset()); err != nil { - return nil, err + return nilClaims, err } return claims, nil diff --git a/pkg/op/verifier_access_token_example_test.go b/pkg/op/verifier_access_token_example_test.go new file mode 100644 index 0000000..effdd58 --- /dev/null +++ b/pkg/op/verifier_access_token_example_test.go @@ -0,0 +1,70 @@ +package op_test + +import ( + "context" + "fmt" + + tu "github.com/zitadel/oidc/v2/internal/testutil" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" +) + +// MyCustomClaims extends the TokenClaims base, +// so it implements the oidc.Claims interface. +// Instead of carrying a map, we add needed fields// to the struct for type safe access. +type MyCustomClaims struct { + oidc.TokenClaims + NotBefore oidc.Time `json:"nbf,omitempty"` + CodeHash string `json:"c_hash,omitempty"` + SessionID string `json:"sid,omitempty"` + Scopes []string `json:"scope,omitempty"` + AccessTokenUseNumber int `json:"at_use_nbr,omitempty"` + Foo string `json:"foo,omitempty"` + Bar *Nested `json:"bar,omitempty"` +} + +// Nested struct types are also possible. +type Nested struct { + Count int `json:"count,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +/* +accessToken carries the following claims. foo and bar are custom claims + + { + "aud": [ + "unit", + "test" + ], + "bar": { + "count": 22, + "tags": [ + "some", + "tags" + ] + }, + "exp": 4802234675, + "foo": "Hello, World!", + "iat": 1678097014, + "iss": "local.com", + "jti": "9876", + "nbf": 1678097014, + "sub": "tim@local.com" + } +*/ +const accessToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOlsidW5pdCIsInRlc3QiXSwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiZXhwIjo0ODAyMjM0Njc1LCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MDk3MDE0LCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MDk3MDE0LCJzdWIiOiJ0aW1AbG9jYWwuY29tIn0.OUgk-B7OXjYlYFj-nogqSDJiQE19tPrbzqUHEAjcEiJkaWo6-IpGVfDiGKm-TxjXQsNScxpaY0Pg3XIh1xK6TgtfYtoLQm-5RYw_mXgb9xqZB2VgPs6nNEYFUDM513MOU0EBc0QMyqAEGzW-HiSPAb4ugCvkLtM1yo11Xyy6vksAdZNs_mJDT4X3vFXnr0jk0ugnAW6fTN3_voC0F_9HQUAkmd750OIxkAHxAMvEPQcpbLHenVvX_Q0QMrzClVrxehn5TVMfmkYYg7ocr876Bq9xQGPNHAcrwvVIJqdg5uMUA38L3HC2BEueG6furZGvc7-qDWAT1VR9liM5ieKpPg` + +func ExampleVerifyAccessToken_customClaims() { + v := op.NewAccessTokenVerifier("local.com", tu.KeySet{}) + + // VerifyAccessToken can be called with the *MyCustomClaims. + claims, err := op.VerifyAccessToken[*MyCustomClaims](context.TODO(), accessToken, v) + if err != nil { + panic(err) + } + + // Here we have typesafe access to the custom claims + fmt.Println(claims.Foo, claims.Bar.Count, claims.Bar.Tags) + // Output: Hello, World! 22 [some tags] +} diff --git a/pkg/op/verifier_access_token_test.go b/pkg/op/verifier_access_token_test.go new file mode 100644 index 0000000..62c26a9 --- /dev/null +++ b/pkg/op/verifier_access_token_test.go @@ -0,0 +1,126 @@ +package op + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tu "github.com/zitadel/oidc/v2/internal/testutil" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +func TestNewAccessTokenVerifier(t *testing.T) { + type args struct { + issuer string + keySet oidc.KeySet + opts []AccessTokenVerifierOpt + } + tests := []struct { + name string + args args + want AccessTokenVerifier + }{ + { + name: "simple", + args: args{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + }, + want: &accessTokenVerifier{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + }, + }, + { + name: "with signature algorithm", + args: args{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + opts: []AccessTokenVerifierOpt{ + WithSupportedAccessTokenSigningAlgorithms("ABC", "DEF"), + }, + }, + want: &accessTokenVerifier{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + supportedSignAlgs: []string{"ABC", "DEF"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewAccessTokenVerifier(tt.args.issuer, tt.args.keySet, tt.args.opts...) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestVerifyAccessToken(t *testing.T) { + verifier := &accessTokenVerifier{ + issuer: tu.ValidIssuer, + maxAgeIAT: 2 * time.Minute, + offset: time.Second, + supportedSignAlgs: []string{string(tu.SignatureAlgorithm)}, + keySet: tu.KeySet{}, + } + + tests := []struct { + name string + tokenClaims func() (string, *oidc.AccessTokenClaims) + wantErr bool + }{ + { + name: "success", + tokenClaims: tu.ValidAccessToken, + }, + { + name: "parse err", + tokenClaims: func() (string, *oidc.AccessTokenClaims) { return "~~~~", nil }, + wantErr: true, + }, + { + name: "invalid signature", + tokenClaims: func() (string, *oidc.AccessTokenClaims) { return tu.InvalidSignatureToken, nil }, + wantErr: true, + }, + { + name: "wrong issuer", + tokenClaims: func() (string, *oidc.AccessTokenClaims) { + return tu.NewAccessToken( + "foo", tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidJWTID, tu.ValidClientID, + tu.ValidSkew, + ) + }, + wantErr: true, + }, + { + name: "expired", + tokenClaims: func() (string, *oidc.AccessTokenClaims) { + return tu.NewAccessToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration.Add(-time.Hour), tu.ValidJWTID, tu.ValidClientID, + tu.ValidSkew, + ) + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token, want := tt.tokenClaims() + + got, err := VerifyAccessToken[*oidc.AccessTokenClaims](context.Background(), token, verifier) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, got, want) + }) + } +} diff --git a/pkg/op/verifier_id_token_hint.go b/pkg/op/verifier_id_token_hint.go index 9320106..d906075 100644 --- a/pkg/op/verifier_id_token_hint.go +++ b/pkg/op/verifier_id_token_hint.go @@ -74,40 +74,40 @@ func NewIDTokenHintVerifier(issuer string, keySet oidc.KeySet, opts ...IDTokenHi // VerifyIDTokenHint validates the id token according to // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation -func VerifyIDTokenHint(ctx context.Context, token string, v IDTokenHintVerifier) (oidc.IDTokenClaims, error) { - claims := oidc.EmptyIDTokenClaims() +func VerifyIDTokenHint[C oidc.Claims](ctx context.Context, token string, v IDTokenHintVerifier) (claims C, err error) { + var nilClaims C decrypted, err := oidc.DecryptToken(token) if err != nil { - return nil, err + return nilClaims, err } - payload, err := oidc.ParseToken(decrypted, claims) + payload, err := oidc.ParseToken(decrypted, &claims) if err != nil { - return nil, err + return nilClaims, err } if err := oidc.CheckIssuer(claims, v.Issuer()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckSignature(ctx, decrypted, payload, claims, v.SupportedSignAlgs(), v.KeySet()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckExpiration(claims, v.Offset()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckIssuedAt(claims, v.MaxAgeIAT(), v.Offset()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckAuthorizationContextClassReference(claims, v.ACR()); err != nil { - return nil, err + return nilClaims, err } if err = oidc.CheckAuthTime(claims, v.MaxAge()); err != nil { - return nil, err + return nilClaims, err } return claims, nil } diff --git a/pkg/op/verifier_id_token_hint_test.go b/pkg/op/verifier_id_token_hint_test.go new file mode 100644 index 0000000..f4d0b0c --- /dev/null +++ b/pkg/op/verifier_id_token_hint_test.go @@ -0,0 +1,161 @@ +package op + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tu "github.com/zitadel/oidc/v2/internal/testutil" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +func TestNewIDTokenHintVerifier(t *testing.T) { + type args struct { + issuer string + keySet oidc.KeySet + opts []IDTokenHintVerifierOpt + } + tests := []struct { + name string + args args + want IDTokenHintVerifier + }{ + { + name: "simple", + args: args{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + }, + want: &idTokenHintVerifier{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + }, + }, + { + name: "with signature algorithm", + args: args{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + opts: []IDTokenHintVerifierOpt{ + WithSupportedIDTokenHintSigningAlgorithms("ABC", "DEF"), + }, + }, + want: &idTokenHintVerifier{ + issuer: tu.ValidIssuer, + keySet: tu.KeySet{}, + supportedSignAlgs: []string{"ABC", "DEF"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewIDTokenHintVerifier(tt.args.issuer, tt.args.keySet, tt.args.opts...) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestVerifyIDTokenHint(t *testing.T) { + verifier := &idTokenHintVerifier{ + issuer: tu.ValidIssuer, + maxAgeIAT: 2 * time.Minute, + offset: time.Second, + supportedSignAlgs: []string{string(tu.SignatureAlgorithm)}, + maxAge: 2 * time.Minute, + acr: tu.ACRVerify, + keySet: tu.KeySet{}, + } + + tests := []struct { + name string + tokenClaims func() (string, *oidc.IDTokenClaims) + wantErr bool + }{ + { + name: "success", + tokenClaims: tu.ValidIDToken, + }, + { + name: "parse err", + tokenClaims: func() (string, *oidc.IDTokenClaims) { return "~~~~", nil }, + wantErr: true, + }, + { + name: "invalid signature", + tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.InvalidSignatureToken, nil }, + wantErr: true, + }, + { + name: "wrong issuer", + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + "foo", tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "expired", + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration.Add(-time.Hour), tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "wrong IAT", + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, -time.Hour, "", + ) + }, + wantErr: true, + }, + { + name: "wrong acr", + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce, + "else", tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + { + name: "expired auth", + tokenClaims: func() (string, *oidc.IDTokenClaims) { + return tu.NewIDToken( + tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, + tu.ValidExpiration, tu.ValidAuthTime.Add(-time.Hour), tu.ValidNonce, + tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "", + ) + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token, want := tt.tokenClaims() + + got, err := VerifyIDTokenHint[*oidc.IDTokenClaims](context.Background(), token, verifier) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, got, want) + }) + } +}