From a34d7a1630a5ce92fac54e3434802aa891531a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Mon, 6 Feb 2023 12:11:11 +0200 Subject: [PATCH 01/21] chore: add go 1.20 support (#275) --- .github/workflows/release.yml | 2 +- README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c50c741..d97d41a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - go: ['1.16', '1.17', '1.18', '1.19'] + go: ['1.16', '1.17', '1.18', '1.19', '1.20'] name: Go ${{ matrix.go }} test steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 74ce435..d0356ce 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,9 @@ Versions that also build are marked with :warning:. | <1.16 | :x: | | 1.16 | :warning: | | 1.17 | :warning: | -| 1.18 | :white_check_mark: | +| 1.18 | :warning: | | 1.19 | :white_check_mark: | +| 1.20 | :white_check_mark: | ## Why another library From 1165d88c69e7aadebe9ceda31fe0ec93349607f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 9 Feb 2023 18:10:22 +0200 Subject: [PATCH 02/21] feat(op): dynamic issuer depending on request / host (#278) * feat(op): dynamic issuer depending on request / host BREAKING CHANGE: The OpenID Provider package is now able to handle multiple issuers with a single storage implementation. The issuer will be selected from the host of the request and passed into the context, where every function can read it from if necessary. This results in some fundamental changes: - `Configuration` interface: - `Issuer() string` has been changed to `IssuerFromRequest(r *http.Request) string` - `Insecure() bool` has been added - OpenIDProvider interface and dependants: - `Issuer` has been removed from Config struct - `NewOpenIDProvider` now takes an additional parameter `issuer` and returns a pointer to the public/default implementation and not an OpenIDProvider interface: `NewOpenIDProvider(ctx context.Context, config *Config, storage Storage, opOpts ...Option) (OpenIDProvider, error)` changed to `NewOpenIDProvider(ctx context.Context, issuer string, config *Config, storage Storage, opOpts ...Option) (*Provider, error)` - therefore the parameter type Option changed to the public type as well: `Option func(o *Provider) error` - `AuthCallbackURL(o OpenIDProvider) func(string) string` has been changed to `AuthCallbackURL(o OpenIDProvider) func(context.Context, string) string` - `IDTokenHintVerifier() IDTokenHintVerifier` (Authorizer, OpenIDProvider, SessionEnder interfaces), `AccessTokenVerifier() AccessTokenVerifier` (Introspector, OpenIDProvider, Revoker, UserinfoProvider interfaces) and `JWTProfileVerifier() JWTProfileVerifier` (IntrospectorJWTProfile, JWTAuthorizationGrantExchanger, OpenIDProvider, RevokerJWTProfile interfaces) now take a context.Context parameter `IDTokenHintVerifier(context.Context) IDTokenHintVerifier`, `AccessTokenVerifier(context.Context) AccessTokenVerifier` and `JWTProfileVerifier(context.Context) JWTProfileVerifier` - `OidcDevMode` (CAOS_OIDC_DEV) environment variable check has been removed, use `WithAllowInsecure()` Option - Signing: the signer is not kept in memory anymore, but created on request from the loaded key: - `Signer` interface and func `NewSigner` have been removed - `ReadySigner(s Signer) ProbesFn` has been removed - `CreateDiscoveryConfig(c Configuration, s Signer) *oidc.DiscoveryConfiguration` has been changed to `CreateDiscoveryConfig(r *http.Request, config Configuration, storage DiscoverStorage) *oidc.DiscoveryConfiguration` - `Storage` interface: - `GetSigningKey(context.Context, chan<- jose.SigningKey)` has been changed to `SigningKey(context.Context) (SigningKey, error)` - `KeySet(context.Context) ([]Key, error)` has been added - `GetKeySet(context.Context) (*jose.JSONWebKeySet, error)` has been changed to `KeySet(context.Context) ([]Key, error)` - `SigAlgorithms(s Signer) []string` has been changed to `SigAlgorithms(ctx context.Context, storage DiscoverStorage) []string` - KeyProvider interface: `GetKeySet(context.Context) (*jose.JSONWebKeySet, error)` has been changed to `KeySet(context.Context) ([]Key, error)` - `CreateIDToken`: the Signer parameter has been removed * move example * fix examples * fix mocks * update readme * fix examples and update usage * update go module version to v2 * build branch * fix(module): rename caos to zitadel * fix: add state in access token response (implicit flow) * fix: encode auth response correctly (when using query in redirect uri) * fix query param handling * feat: add all optional claims of the introspection response * fix: use default redirect uri when not passed * fix: exchange cors library and add `X-Requested-With` to Access-Control-Request-Headers (#261) * feat(op): add support for client credentials * fix mocks and test * feat: allow to specify token type of JWT Profile Grant * document JWTProfileTokenStorage * cleanup * rp: fix integration test test username needed to be suffixed by issuer domain * chore(deps): bump golang.org/x/text from 0.5.0 to 0.6.0 Bumps [golang.org/x/text](https://github.com/golang/text) from 0.5.0 to 0.6.0. - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.5.0...v0.6.0) --- updated-dependencies: - dependency-name: golang.org/x/text dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * op: mock: cleanup commented code * op: remove duplicate code code duplication caused by merge conflict selections --------- Signed-off-by: dependabot[bot] Co-authored-by: Livio Amstutz Co-authored-by: adlerhurst Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .releaserc.js | 5 +- README.md | 17 +- example/client/api/api.go | 4 +- example/client/app/app.go | 6 +- example/client/github/github.go | 6 +- example/client/service/service.go | 2 +- example/doc.go | 3 +- example/server/dynamic/login.go | 113 +++++ example/server/dynamic/op.go | 138 ++++++ example/server/exampleop/login.go | 7 +- example/server/exampleop/op.go | 13 +- example/server/main.go | 15 +- example/server/storage/client.go | 12 +- example/server/storage/oidc.go | 5 +- example/server/storage/storage.go | 86 ++-- example/server/storage/storage_dynamic.go | 260 +++++++++++ example/server/storage/user.go | 6 +- go.mod | 5 +- go.sum | 13 +- pkg/client/client.go | 6 +- pkg/client/jwt_profile.go | 4 +- pkg/client/profile/jwt_profile.go | 4 +- pkg/client/rp/cli/cli.go | 6 +- pkg/client/rp/delegation.go | 6 +- pkg/client/rp/integration_test.go | 17 +- pkg/client/rp/jwks.go | 4 +- pkg/client/rp/mock/verifier.mock.go | 2 +- pkg/client/rp/relying_party.go | 12 +- pkg/client/rp/tockenexchange.go | 2 +- pkg/client/rp/verifier.go | 8 +- pkg/client/rs/resource_server.go | 6 +- pkg/oidc/code_challenge.go | 2 +- pkg/oidc/token.go | 4 +- pkg/oidc/token_request.go | 10 +- pkg/oidc/verifier.go | 2 +- pkg/op/auth_request.go | 19 +- pkg/op/auth_request_test.go | 8 +- pkg/op/client.go | 2 +- pkg/op/config.go | 86 +++- pkg/op/config_test.go | 300 +++++++++++-- pkg/op/context.go | 49 +++ pkg/op/context_test.go | 76 ++++ pkg/op/crypto.go | 2 +- pkg/op/discovery.go | 242 +++++----- pkg/op/discovery_test.go | 510 ++++++++++++++++++---- pkg/op/endpoint_test.go | 2 +- pkg/op/error.go | 4 +- pkg/op/keys.go | 21 +- pkg/op/keys_test.go | 38 +- pkg/op/mock/authorizer.mock.go | 43 +- pkg/op/mock/authorizer.mock.impl.go | 33 +- pkg/op/mock/client.go | 4 +- pkg/op/mock/client.mock.go | 6 +- pkg/op/mock/configuration.mock.go | 31 +- pkg/op/mock/discovery.mock.go | 51 +++ pkg/op/mock/generate.go | 13 +- pkg/op/mock/key.mock.go | 18 +- pkg/op/mock/signer.mock.go | 138 ++++-- pkg/op/mock/storage.mock.go | 78 ++-- pkg/op/mock/storage.mock.impl.go | 29 +- pkg/op/op.go | 312 +++++++------ pkg/op/probes.go | 11 +- pkg/op/session.go | 8 +- pkg/op/signer.go | 94 +--- pkg/op/storage.go | 22 +- pkg/op/token.go | 54 ++- pkg/op/token_client_credentials.go | 20 +- pkg/op/token_code.go | 4 +- pkg/op/token_intospection.go | 11 +- pkg/op/token_jwt_profile.go | 66 ++- pkg/op/token_refresh.go | 6 +- pkg/op/token_request.go | 12 +- pkg/op/token_revocation.go | 12 +- pkg/op/userinfo.go | 8 +- pkg/op/verifier_access_token.go | 2 +- pkg/op/verifier_id_token_hint.go | 4 +- pkg/op/verifier_jwt_profile.go | 2 +- 77 files changed, 2349 insertions(+), 913 deletions(-) create mode 100644 example/server/dynamic/login.go create mode 100644 example/server/dynamic/op.go create mode 100644 example/server/storage/storage_dynamic.go create mode 100644 pkg/op/context.go create mode 100644 pkg/op/context_test.go create mode 100644 pkg/op/mock/discovery.mock.go diff --git a/.releaserc.js b/.releaserc.js index 6500ace..7b9f1ce 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -1,5 +1,8 @@ module.exports = { - branches: ["main"], + branches: [ + {name: "main"}, + {name: "dynamic-issuer", prerelease: true}, + ], plugins: [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", diff --git a/README.md b/README.md index d0356ce..26a08ec 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The most important packages of the library: /client/app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile) /client/github example of the extended OAuth2 library, providing an HTTP client with a reuse token source /client/service demonstration of JWT Profile Authorization Grant - /server example of an OpenID Provider implementation including some very basic login UI + /server examples of an OpenID Provider implementations (including dynamic) with some very basic login UI ## How To Use It @@ -44,16 +44,27 @@ Check the `/example` folder where example code for different scenarios is locate ```bash # start oidc op server # oidc discovery http://localhost:9998/.well-known/openid-configuration -go run github.com/zitadel/oidc/example/server +go run github.com/zitadel/oidc/v2/example/server # start oidc web client (in a new terminal) CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998 SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/example/client/app ``` - open http://localhost:9999/login in your browser - you will be redirected to op server and the login UI -- login with user `test-user` and password `verysecure` +- login with user `test-user@localhost` and password `verysecure` - the OP will redirect you to the client app, which displays the user info +for the dynamic issuer, just start it with: +```bash +go run github.com/zitadel/oidc/v2/example/server/dynamic +``` +the oidc web client above will still work, but if you add `oidc.local` (pointing to 127.0.0.1) in your hosts file you can also start it with: +```bash +CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://oidc.local:9998/ SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/v2/example/client/app +``` + +> Note: Usernames are suffixed with the hostname (`test-user@localhost` or `test-user@oidc.local`) + ## Features | | Code Flow | Implicit Flow | Hybrid Flow | Discovery | PKCE | Token Exchange | mTLS | JWT Profile | Refresh Token | Client Credentials | diff --git a/example/client/api/api.go b/example/client/api/api.go index 0ab669d..c475354 100644 --- a/example/client/api/api.go +++ b/example/client/api/api.go @@ -12,8 +12,8 @@ import ( "github.com/gorilla/mux" "github.com/sirupsen/logrus" - "github.com/zitadel/oidc/pkg/client/rs" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/client/rs" + "github.com/zitadel/oidc/v2/pkg/oidc" ) const ( diff --git a/example/client/app/app.go b/example/client/app/app.go index b7f2868..3e5f19c 100644 --- a/example/client/app/app.go +++ b/example/client/app/app.go @@ -11,9 +11,9 @@ import ( "github.com/google/uuid" "github.com/sirupsen/logrus" - "github.com/zitadel/oidc/pkg/client/rp" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) var ( diff --git a/example/client/github/github.go b/example/client/github/github.go index feb3e26..57bb3ae 100644 --- a/example/client/github/github.go +++ b/example/client/github/github.go @@ -10,9 +10,9 @@ import ( "golang.org/x/oauth2" githubOAuth "golang.org/x/oauth2/github" - "github.com/zitadel/oidc/pkg/client/rp" - "github.com/zitadel/oidc/pkg/client/rp/cli" - "github.com/zitadel/oidc/pkg/http" + "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/client/rp/cli" + "github.com/zitadel/oidc/v2/pkg/http" ) var ( diff --git a/example/client/service/service.go b/example/client/service/service.go index b3819d5..9526174 100644 --- a/example/client/service/service.go +++ b/example/client/service/service.go @@ -13,7 +13,7 @@ import ( "github.com/sirupsen/logrus" "golang.org/x/oauth2" - "github.com/zitadel/oidc/pkg/client/profile" + "github.com/zitadel/oidc/v2/pkg/client/profile" ) var client = http.DefaultClient diff --git a/example/doc.go b/example/doc.go index 7212a7d..fd4f038 100644 --- a/example/doc.go +++ b/example/doc.go @@ -5,7 +5,6 @@ Package example contains some example of the various use of this library: /app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile) /github example of the extended OAuth2 library, providing an HTTP client with a reuse token source /service demonstration of JWT Profile Authorization Grant -/server example of an OpenID Provider implementation including some very basic login UI - +/server examples of an OpenID Provider implementations (including dynamic) with some very basic */ package example diff --git a/example/server/dynamic/login.go b/example/server/dynamic/login.go new file mode 100644 index 0000000..e7c6e5f --- /dev/null +++ b/example/server/dynamic/login.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "fmt" + "html/template" + "net/http" + + "github.com/gorilla/mux" + + "github.com/zitadel/oidc/v2/pkg/op" +) + +const ( + queryAuthRequestID = "authRequestID" +) + +var ( + loginTmpl, _ = template.New("login").Parse(` + + + + + Login + + +
+ +
+ + +
+
+ + +
+

{{.Error}}

+ +
+ + `) +) + +type login struct { + authenticate authenticate + router *mux.Router + callback func(context.Context, string) string +} + +func NewLogin(authenticate authenticate, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) *login { + l := &login{ + authenticate: authenticate, + callback: callback, + } + l.createRouter(issuerInterceptor) + return l +} + +func (l *login) createRouter(issuerInterceptor *op.IssuerInterceptor) { + l.router = mux.NewRouter() + l.router.Path("/username").Methods("GET").HandlerFunc(l.loginHandler) + l.router.Path("/username").Methods("POST").HandlerFunc(issuerInterceptor.HandlerFunc(l.checkLoginHandler)) +} + +type authenticate interface { + CheckUsernamePassword(ctx context.Context, username, password, id string) error +} + +func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError) + return + } + //the oidc package will pass the id of the auth request as query parameter + //we will use this id through the login process and therefore pass it to the login page + renderLogin(w, r.FormValue(queryAuthRequestID), nil) +} + +func renderLogin(w http.ResponseWriter, id string, err error) { + var errMsg string + if err != nil { + errMsg = err.Error() + } + data := &struct { + ID string + Error string + }{ + ID: id, + Error: errMsg, + } + err = loginTmpl.Execute(w, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError) + return + } + username := r.FormValue("username") + password := r.FormValue("password") + id := r.FormValue("id") + err = l.authenticate.CheckUsernamePassword(r.Context(), username, password, id) + if err != nil { + renderLogin(w, id, err) + return + } + http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound) +} diff --git a/example/server/dynamic/op.go b/example/server/dynamic/op.go new file mode 100644 index 0000000..02c12b2 --- /dev/null +++ b/example/server/dynamic/op.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "log" + "net/http" + + "github.com/gorilla/mux" + "golang.org/x/text/language" + + "github.com/zitadel/oidc/v2/example/server/storage" + "github.com/zitadel/oidc/v2/pkg/op" +) + +const ( + pathLoggedOut = "/logged-out" +) + +var ( + hostnames = []string{ + "localhost", //note that calling 127.0.0.1 / ::1 won't work as the hostname does not match + "oidc.local", //add this to your hosts file (pointing to 127.0.0.1) + //feel free to add more... + } +) + +func init() { + storage.RegisterClients( + storage.NativeClient("native"), + storage.WebClient("web", "secret"), + storage.WebClient("api", "secret"), + ) +} + +func main() { + ctx := context.Background() + + port := "9998" + issuers := make([]string, len(hostnames)) + for i, hostname := range hostnames { + issuers[i] = fmt.Sprintf("http://%s:%s/", hostname, port) + } + + //the OpenID Provider requires a 32-byte key for (token) encryption + //be sure to create a proper crypto random key and manage it securely! + key := sha256.Sum256([]byte("test")) + + router := mux.NewRouter() + + //for simplicity, we provide a very small default page for users who have signed out + router.HandleFunc(pathLoggedOut, func(w http.ResponseWriter, req *http.Request) { + _, err := w.Write([]byte("signed out successfully")) + if err != nil { + log.Printf("error serving logged out page: %v", err) + } + }) + + //the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations + //this might be the layer for accessing your database + //in this example it will be handled in-memory + //the NewMultiStorage is able to handle multiple issuers + storage := storage.NewMultiStorage(issuers) + + //creation of the OpenIDProvider with the just created in-memory Storage + provider, err := newDynamicOP(ctx, storage, key) + if err != nil { + log.Fatal(err) + } + + //the provider will only take care of the OpenID Protocol, so there must be some sort of UI for the login process + //for the simplicity of the example this means a simple page with username and password field + //be sure to provide an IssuerInterceptor with the IssuerFromRequest from the OP so the login can select / and pass it to the storage + l := NewLogin(storage, op.AuthCallbackURL(provider), op.NewIssuerInterceptor(provider.IssuerFromRequest)) + + //regardless of how many pages / steps there are in the process, the UI must be registered in the router, + //so we will direct all calls to /login to the login UI + router.PathPrefix("/login/").Handler(http.StripPrefix("/login", l.router)) + + //we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration) + //is served on the correct path + // + //if your issuer ends with a path (e.g. http://localhost:9998/custom/path/), + //then you would have to set the path prefix (/custom/path/): + //router.PathPrefix("/custom/path/").Handler(http.StripPrefix("/custom/path", provider.HttpHandler())) + router.PathPrefix("/").Handler(provider.HttpHandler()) + + server := &http.Server{ + Addr: ":" + port, + Handler: router, + } + err = server.ListenAndServe() + if err != nil { + log.Fatal(err) + } + <-ctx.Done() +} + +// newDynamicOP will create an OpenID Provider for localhost on a specified port with a given encryption key +// and a predefined default logout uri +// it will enable all options (see descriptions) +func newDynamicOP(ctx context.Context, storage op.Storage, key [32]byte) (*op.Provider, error) { + config := &op.Config{ + CryptoKey: key, + + //will be used if the end_session endpoint is called without a post_logout_redirect_uri + DefaultLogoutRedirectURI: pathLoggedOut, + + //enables code_challenge_method S256 for PKCE (and therefore PKCE in general) + CodeMethodS256: true, + + //enables additional client_id/client_secret authentication by form post (not only HTTP Basic Auth) + AuthMethodPost: true, + + //enables additional authentication by using private_key_jwt + AuthMethodPrivateKeyJWT: true, + + //enables refresh_token grant use + GrantTypeRefreshToken: true, + + //enables use of the `request` Object parameter + RequestObjectSupported: true, + + //this example has only static texts (in English), so we'll set the here accordingly + SupportedUILocales: []language.Tag{language.English}, + } + handler, err := op.NewDynamicOpenIDProvider(ctx, "/", config, storage, + //we must explicitly allow the use of the http issuer + op.WithAllowInsecure(), + //as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth + op.WithCustomAuthEndpoint(op.NewEndpoint("auth")), + ) + if err != nil { + return nil, err + } + return handler, nil +} diff --git a/example/server/exampleop/login.go b/example/server/exampleop/login.go index fd3dead..5da86d1 100644 --- a/example/server/exampleop/login.go +++ b/example/server/exampleop/login.go @@ -1,6 +1,7 @@ package exampleop import ( + "context" "fmt" "html/template" "net/http" @@ -44,10 +45,10 @@ var loginTmpl, _ = template.New("login").Parse(` type login struct { authenticate authenticate router *mux.Router - callback func(string) string + callback func(context.Context, string) string } -func NewLogin(authenticate authenticate, callback func(string) string) *login { +func NewLogin(authenticate authenticate, callback func(context.Context, string) string) *login { l := &login{ authenticate: authenticate, callback: callback, @@ -109,5 +110,5 @@ func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) { renderLogin(w, id, err) return } - http.Redirect(w, r, l.callback(id), http.StatusFound) + http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound) } diff --git a/example/server/exampleop/op.go b/example/server/exampleop/op.go index 4794d8a..d3a450c 100644 --- a/example/server/exampleop/op.go +++ b/example/server/exampleop/op.go @@ -5,13 +5,12 @@ import ( "crypto/sha256" "log" "net/http" - "os" "github.com/gorilla/mux" "golang.org/x/text/language" - "github.com/zitadel/oidc/example/server/storage" - "github.com/zitadel/oidc/pkg/op" + "github.com/zitadel/oidc/v2/example/server/storage" + "github.com/zitadel/oidc/v2/pkg/op" ) const ( @@ -35,9 +34,6 @@ type Storage interface { // // Use one of the pre-made clients in storage/clients.go or register a new one. func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Router { - // this will allow us to use an issuer with http:// instead of https:// - os.Setenv(op.OidcDevMode, "true") - // the OpenID Provider requires a 32-byte key for (token) encryption // be sure to create a proper crypto random key and manage it securely! key := sha256.Sum256([]byte("test")) @@ -81,7 +77,6 @@ func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Route // it will enable all options (see descriptions) func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte) (op.OpenIDProvider, error) { config := &op.Config{ - Issuer: issuer, CryptoKey: key, // will be used if the end_session endpoint is called without a post_logout_redirect_uri @@ -105,7 +100,9 @@ func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte) // this example has only static texts (in English), so we'll set the here accordingly SupportedUILocales: []language.Tag{language.English}, } - handler, err := op.NewOpenIDProvider(ctx, config, storage, + handler, err := op.NewOpenIDProvider(ctx, issuer, config, storage, + //we must explicitly allow the use of the http issuer + op.WithAllowInsecure(), // as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth op.WithCustomAuthEndpoint(op.NewEndpoint("auth")), ) diff --git a/example/server/main.go b/example/server/main.go index 3cfd20d..327e294 100644 --- a/example/server/main.go +++ b/example/server/main.go @@ -2,23 +2,28 @@ package main import ( "context" + "fmt" "log" "net/http" - "github.com/zitadel/oidc/example/server/exampleop" - "github.com/zitadel/oidc/example/server/storage" + "github.com/zitadel/oidc/v2/example/server/exampleop" + "github.com/zitadel/oidc/v2/example/server/storage" ) func main() { ctx := context.Background() + //we will run on :9998 + port := "9998" + //which gives us the issuer: //http://localhost:9998/ + issuer := fmt.Sprintf("http://localhost:%s/", port) + // the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations // this might be the layer for accessing your database // in this example it will be handled in-memory - storage := storage.NewStorage(storage.NewUserStore()) + storage := storage.NewStorage(storage.NewUserStore(issuer)) - port := "9998" - router := exampleop.SetupServer(ctx, "http://localhost:"+port, storage) + router := exampleop.SetupServer(ctx, issuer, storage) server := &http.Server{ Addr: ":" + port, diff --git a/example/server/storage/client.go b/example/server/storage/client.go index 0f3a703..bd6ff3c 100644 --- a/example/server/storage/client.go +++ b/example/server/storage/client.go @@ -3,8 +3,8 @@ package storage import ( "time" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/op" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" ) var ( @@ -113,14 +113,14 @@ func (c *Client) IsScopeAllowed(scope string) bool { // IDTokenUserinfoClaimsAssertion allows specifying if claims of scope profile, email, phone and address are asserted into the id_token // even if an access token if issued which violates the OIDC Core spec -//(5.4. Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) +// (5.4. Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) // some clients though require that e.g. email is always in the id_token when requested even if an access_token is issued func (c *Client) IDTokenUserinfoClaimsAssertion() bool { return c.idTokenUserinfoClaimsAssertion } // ClockSkew enables clients to instruct the OP to apply a clock skew on the various times and expirations -//(subtract from issued_at, add to expiration, ...) +// (subtract from issued_at, add to expiration, ...) func (c *Client) ClockSkew() time.Duration { return c.clockSkew } @@ -141,7 +141,7 @@ func RegisterClients(registerClients ...*Client) { // user-defined redirectURIs may include: // - http://localhost without port specification (e.g. http://localhost/auth/callback) // - custom protocol (e.g. custom://auth/callback) -//(the examples will be used as default, if none is provided) +// (the examples will be used as default, if none is provided) func NativeClient(id string, redirectURIs ...string) *Client { if len(redirectURIs) == 0 { redirectURIs = []string{ @@ -168,7 +168,7 @@ func NativeClient(id string, redirectURIs ...string) *Client { // WebClient will create a client of type web, which will always use Basic Auth and allow the use of refresh tokens // user-defined redirectURIs may include: // - http://localhost with port specification (e.g. http://localhost:9999/auth/callback) -//(the example will be used as default, if none is provided) +// (the example will be used as default, if none is provided) func WebClient(id, secret string, redirectURIs ...string) *Client { if len(redirectURIs) == 0 { redirectURIs = []string{ diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go index 91afd90..505ab72 100644 --- a/example/server/storage/oidc.go +++ b/example/server/storage/oidc.go @@ -5,9 +5,8 @@ import ( "golang.org/x/text/language" - "github.com/zitadel/oidc/pkg/op" - - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" ) const ( diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 130822e..64bffc8 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -12,8 +12,8 @@ import ( "github.com/google/uuid" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/op" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" ) // serviceKey1 is a public key which will be used for the JWT Profile Authorization Grant @@ -45,9 +45,41 @@ type Storage struct { } type signingKey struct { - ID string - Algorithm string - Key *rsa.PrivateKey + id string + algorithm jose.SignatureAlgorithm + key *rsa.PrivateKey +} + +func (s *signingKey) SignatureAlgorithm() jose.SignatureAlgorithm { + return s.algorithm +} + +func (s *signingKey) Key() interface{} { + return s.key +} + +func (s *signingKey) ID() string { + return s.id +} + +type publicKey struct { + signingKey +} + +func (s *publicKey) ID() string { + return s.id +} + +func (s *publicKey) Algorithm() jose.SignatureAlgorithm { + return s.algorithm +} + +func (s *publicKey) Use() string { + return "sig" +} + +func (s *publicKey) Key() interface{} { + return &s.key.PublicKey } func NewStorage(userStore UserStore) *Storage { @@ -67,9 +99,9 @@ func NewStorage(userStore UserStore) *Storage { }, }, signingKey: signingKey{ - ID: "id", - Algorithm: "RS256", - Key: key, + id: uuid.NewString(), + algorithm: jose.RS256, + key: key, }, } } @@ -288,41 +320,29 @@ func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID return nil } -// GetSigningKey implements the op.Storage interface +// SigningKey implements the op.Storage interface // it will be called when creating the OpenID Provider -func (s *Storage) GetSigningKey(ctx context.Context, keyCh chan<- jose.SigningKey) { +func (s *Storage) SigningKey(ctx context.Context) (op.SigningKey, error) { // in this example the signing key is a static rsa.PrivateKey and the algorithm used is RS256 // you would obviously have a more complex implementation and store / retrieve the key from your database as well - // - // the idea of the signing key channel is, that you can (with what ever mechanism) rotate your signing key and - // switch the key of the signer via this channel - keyCh <- jose.SigningKey{ - Algorithm: jose.SignatureAlgorithm(s.signingKey.Algorithm), // always tell the signer with algorithm to use - Key: jose.JSONWebKey{ - KeyID: s.signingKey.ID, // always give the key an id so, that it will include it in the token header as `kid` claim - Key: s.signingKey.Key, - }, - } + return &s.signingKey, nil } -// GetKeySet implements the op.Storage interface +// SignatureAlgorithms implements the op.Storage interface +// it will be called to get the sign +func (s *Storage) SignatureAlgorithms(context.Context) ([]jose.SignatureAlgorithm, error) { + return []jose.SignatureAlgorithm{s.signingKey.algorithm}, nil +} + +// KeySet implements the op.Storage interface // it will be called to get the current (public) keys, among others for the keys_endpoint or for validating access_tokens on the userinfo_endpoint, ... -func (s *Storage) GetKeySet(ctx context.Context) (*jose.JSONWebKeySet, error) { +func (s *Storage) KeySet(ctx context.Context) ([]op.Key, error) { // as mentioned above, this example only has a single signing key without key rotation, // so it will directly use its public key // // when using key rotation you typically would store the public keys alongside the private keys in your database - // and give both of them an expiration date, with the public key having a longer lifetime (e.g. rotate private key every - return &jose.JSONWebKeySet{ - Keys: []jose.JSONWebKey{ - { - KeyID: s.signingKey.ID, - Algorithm: s.signingKey.Algorithm, - Use: oidc.KeyUseSignature, - Key: &s.signingKey.Key.PublicKey, - }, - }, - }, nil + //and give both of them an expiration date, with the public key having a longer lifetime + return []op.Key{&publicKey{s.signingKey}}, nil } // GetClientByClientID implements the op.Storage interface diff --git a/example/server/storage/storage_dynamic.go b/example/server/storage/storage_dynamic.go new file mode 100644 index 0000000..ec6a92e --- /dev/null +++ b/example/server/storage/storage_dynamic.go @@ -0,0 +1,260 @@ +package storage + +import ( + "context" + "time" + + "gopkg.in/square/go-jose.v2" + + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" +) + +type multiStorage struct { + issuers map[string]*Storage +} + +// NewMultiStorage implements the op.Storage interface by wrapping multiple storage structs +// and selecting them by the calling issuer +func NewMultiStorage(issuers []string) *multiStorage { + s := make(map[string]*Storage) + for _, issuer := range issuers { + s[issuer] = NewStorage(NewUserStore(issuer)) + } + return &multiStorage{issuers: s} +} + +// CheckUsernamePassword implements the `authenticate` interface of the login +func (s *multiStorage) CheckUsernamePassword(ctx context.Context, username, password, id string) error { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.CheckUsernamePassword(username, password, id) +} + +// CreateAuthRequest implements the op.Storage interface +// it will be called after parsing and validation of the authentication request +func (s *multiStorage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, userID string) (op.AuthRequest, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.CreateAuthRequest(ctx, authReq, userID) +} + +// AuthRequestByID implements the op.Storage interface +// it will be called after the Login UI redirects back to the OIDC endpoint +func (s *multiStorage) AuthRequestByID(ctx context.Context, id string) (op.AuthRequest, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.AuthRequestByID(ctx, id) +} + +// AuthRequestByCode implements the op.Storage interface +// it will be called after parsing and validation of the token request (in an authorization code flow) +func (s *multiStorage) AuthRequestByCode(ctx context.Context, code string) (op.AuthRequest, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.AuthRequestByCode(ctx, code) +} + +// SaveAuthCode implements the op.Storage interface +// it will be called after the authentication has been successful and before redirecting the user agent to the redirect_uri +// (in an authorization code flow) +func (s *multiStorage) SaveAuthCode(ctx context.Context, id string, code string) error { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.SaveAuthCode(ctx, id, code) +} + +// DeleteAuthRequest implements the op.Storage interface +// it will be called after creating the token response (id and access tokens) for a valid +// - authentication request (in an implicit flow) +// - token request (in an authorization code flow) +func (s *multiStorage) DeleteAuthRequest(ctx context.Context, id string) error { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.DeleteAuthRequest(ctx, id) +} + +// CreateAccessToken implements the op.Storage interface +// it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...) +func (s *multiStorage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return "", time.Time{}, err + } + return storage.CreateAccessToken(ctx, request) +} + +// CreateAccessAndRefreshTokens implements the op.Storage interface +// it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request) +func (s *multiStorage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return "", "", time.Time{}, err + } + return storage.CreateAccessAndRefreshTokens(ctx, request, currentRefreshToken) +} + +// TokenRequestByRefreshToken implements the op.Storage interface +// it will be called after parsing and validation of the refresh token request +func (s *multiStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.TokenRequestByRefreshToken(ctx, refreshToken) +} + +// TerminateSession implements the op.Storage interface +// it will be called after the user signed out, therefore the access and refresh token of the user of this client must be removed +func (s *multiStorage) TerminateSession(ctx context.Context, userID string, clientID string) error { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.TerminateSession(ctx, userID, clientID) +} + +// RevokeToken implements the op.Storage interface +// it will be called after parsing and validation of the token revocation request +func (s *multiStorage) RevokeToken(ctx context.Context, token string, userID string, clientID string) *oidc.Error { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.RevokeToken(ctx, token, userID, clientID) +} + +// SigningKey implements the op.Storage interface +// it will be called when creating the OpenID Provider +func (s *multiStorage) SigningKey(ctx context.Context) (op.SigningKey, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.SigningKey(ctx) +} + +// SignatureAlgorithms implements the op.Storage interface +// it will be called to get the sign +func (s *multiStorage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.SignatureAlgorithms(ctx) +} + +// KeySet implements the op.Storage interface +// it will be called to get the current (public) keys, among others for the keys_endpoint or for validating access_tokens on the userinfo_endpoint, ... +func (s *multiStorage) KeySet(ctx context.Context) ([]op.Key, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.KeySet(ctx) +} + +// GetClientByClientID implements the op.Storage interface +// it will be called whenever information (type, redirect_uris, ...) about the client behind the client_id is needed +func (s *multiStorage) GetClientByClientID(ctx context.Context, clientID string) (op.Client, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.GetClientByClientID(ctx, clientID) +} + +// AuthorizeClientIDSecret implements the op.Storage interface +// it will be called for validating the client_id, client_secret on token or introspection requests +func (s *multiStorage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.AuthorizeClientIDSecret(ctx, clientID, clientSecret) +} + +// 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 { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.SetUserinfoFromScopes(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 *multiStorage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.SetUserinfoFromToken(ctx, userinfo, tokenID, subject, origin) +} + +// 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 { + storage, err := s.storageFromContext(ctx) + if err != nil { + return err + } + return storage.SetIntrospectionFromToken(ctx, introspection, tokenID, subject, clientID) +} + +// GetPrivateClaimsFromScopes implements the op.Storage interface +// it will be called for the creation of a JWT access token to assert claims for custom scopes +func (s *multiStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.GetPrivateClaimsFromScopes(ctx, userID, clientID, scopes) +} + +// GetKeyByIDAndUserID implements the op.Storage interface +// it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication) +func (s *multiStorage) GetKeyByIDAndUserID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.GetKeyByIDAndUserID(ctx, keyID, userID) +} + +// ValidateJWTProfileScopes implements the op.Storage interface +// it will be called to validate the scopes of a JWT Profile Authorization Grant request +func (s *multiStorage) ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return nil, err + } + return storage.ValidateJWTProfileScopes(ctx, userID, scopes) +} + +// Health implements the op.Storage interface +func (s *multiStorage) Health(ctx context.Context) error { + return nil +} + +func (s *multiStorage) storageFromContext(ctx context.Context) (*Storage, *oidc.Error) { + storage, ok := s.issuers[op.IssuerFromContext(ctx)] + if !ok { + return nil, oidc.ErrInvalidRequest().WithDescription("invalid issuer") + } + return storage, nil +} diff --git a/example/server/storage/user.go b/example/server/storage/user.go index 423af59..82c06d0 100644 --- a/example/server/storage/user.go +++ b/example/server/storage/user.go @@ -2,6 +2,7 @@ package storage import ( "crypto/rsa" + "strings" "golang.org/x/text/language" ) @@ -33,12 +34,13 @@ type userStore struct { users map[string]*User } -func NewUserStore() UserStore { +func NewUserStore(issuer string) UserStore { + hostname := strings.Split(strings.Split(issuer, "://")[1], ":")[0] return userStore{ users: map[string]*User{ "id1": { ID: "id1", - Username: "test-user", + Username: "test-user@" + hostname, Password: "verysecure", FirstName: "Test", LastName: "User", diff --git a/go.mod b/go.mod index 18ae410..2691e57 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/zitadel/oidc +module github.com/zitadel/oidc/v2 go 1.16 @@ -15,9 +15,8 @@ require ( github.com/rs/cors v1.8.3 github.com/sirupsen/logrus v1.9.0 github.com/stretchr/testify v1.8.1 - github.com/zitadel/logging v0.3.4 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 - golang.org/x/text v0.5.0 + 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 ) diff --git a/go.sum b/go.sum index ec7b82e..c73eb9d 100644 --- a/go.sum +++ b/go.sum @@ -131,13 +131,11 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/cors v1.8.3 h1:O+qNyWn7Z+F9M0ILBHgMVPuB1xTOucVd5gtaYyXBpRo= github.com/rs/cors v1.8.3/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -149,8 +147,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de 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= -github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM= -github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0= 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= @@ -252,7 +248,6 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -272,7 +267,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w 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-20220207234003-57398862261d/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= @@ -285,8 +279,8 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 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.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= -golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +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= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -420,10 +414,7 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/client/client.go b/pkg/client/client.go index 62f1019..eaa1a80 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -14,9 +14,9 @@ import ( "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/crypto" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/crypto" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) var Encoder = func() httphelper.Encoder { diff --git a/pkg/client/jwt_profile.go b/pkg/client/jwt_profile.go index a711de9..1686de6 100644 --- a/pkg/client/jwt_profile.go +++ b/pkg/client/jwt_profile.go @@ -5,8 +5,8 @@ import ( "golang.org/x/oauth2" - "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) // JWTProfileExchange handles the oauth2 jwt profile exchange diff --git a/pkg/client/profile/jwt_profile.go b/pkg/client/profile/jwt_profile.go index b29fcaa..a934f7d 100644 --- a/pkg/client/profile/jwt_profile.go +++ b/pkg/client/profile/jwt_profile.go @@ -7,8 +7,8 @@ import ( "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/client" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/client" + "github.com/zitadel/oidc/v2/pkg/oidc" ) // jwtProfileTokenSource implement the oauth2.TokenSource diff --git a/pkg/client/rp/cli/cli.go b/pkg/client/rp/cli/cli.go index 6e30e4e..936f319 100644 --- a/pkg/client/rp/cli/cli.go +++ b/pkg/client/rp/cli/cli.go @@ -4,9 +4,9 @@ import ( "context" "net/http" - "github.com/zitadel/oidc/pkg/client/rp" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) const ( diff --git a/pkg/client/rp/delegation.go b/pkg/client/rp/delegation.go index a2b1f00..b16a39e 100644 --- a/pkg/client/rp/delegation.go +++ b/pkg/client/rp/delegation.go @@ -1,13 +1,13 @@ package rp import ( - "github.com/zitadel/oidc/pkg/oidc/grants/tokenexchange" + "github.com/zitadel/oidc/v2/pkg/oidc/grants/tokenexchange" ) // DelegationTokenRequest is an implementation of TokenExchangeRequest // it exchanges an "urn:ietf:params:oauth:token-type:access_token" with an optional -//"urn:ietf:params:oauth:token-type:access_token" actor token for an -//"urn:ietf:params:oauth:token-type:access_token" delegation token +// "urn:ietf:params:oauth:token-type:access_token" actor token for an +// "urn:ietf:params:oauth:token-type:access_token" delegation token func DelegationTokenRequest(subjectToken string, opts ...tokenexchange.TokenExchangeOption) *tokenexchange.TokenExchangeRequest { return tokenexchange.NewTokenExchangeRequest(subjectToken, tokenexchange.AccessTokenType, opts...) } diff --git a/pkg/client/rp/integration_test.go b/pkg/client/rp/integration_test.go index 6f5f489..e29ddd3 100644 --- a/pkg/client/rp/integration_test.go +++ b/pkg/client/rp/integration_test.go @@ -15,28 +15,27 @@ import ( "testing" "time" - "github.com/zitadel/oidc/example/server/exampleop" - "github.com/zitadel/oidc/example/server/storage" - "github.com/jeremija/gosubmit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/oidc/pkg/client/rp" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/example/server/exampleop" + "github.com/zitadel/oidc/v2/example/server/storage" + "github.com/zitadel/oidc/v2/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) func TestRelyingPartySession(t *testing.T) { t.Log("------- start example OP ------") ctx := context.Background() - exampleStorage := storage.NewStorage(storage.NewUserStore()) + targetURL := "http://local-site" + exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) var dh deferredHandler opServer := httptest.NewServer(&dh) defer opServer.Close() t.Logf("auth server at %s", opServer.URL) dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) - targetURL := "http://local-site" localURL, err := url.Parse(targetURL + "/login?requestID=1234") require.NoError(t, err, "local url") @@ -109,7 +108,7 @@ func TestRelyingPartySession(t *testing.T) { t.Log("------- post to login form, get redirect to OP ------") postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageURL, - gosubmit.Set("username", "test-user"), + gosubmit.Set("username", "test-user@local-site"), gosubmit.Set("password", "verysecure")) t.Logf("Get redirect from %s", postLoginRedirectURL) diff --git a/pkg/client/rp/jwks.go b/pkg/client/rp/jwks.go index cc49eb7..3438bd6 100644 --- a/pkg/client/rp/jwks.go +++ b/pkg/client/rp/jwks.go @@ -9,8 +9,8 @@ import ( "gopkg.in/square/go-jose.v2" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) func NewRemoteKeySet(client *http.Client, jwksURL string, opts ...func(*remoteKeySet)) oidc.KeySet { diff --git a/pkg/client/rp/mock/verifier.mock.go b/pkg/client/rp/mock/verifier.mock.go index b20db68..9d1daa1 100644 --- a/pkg/client/rp/mock/verifier.mock.go +++ b/pkg/client/rp/mock/verifier.mock.go @@ -10,7 +10,7 @@ import ( "github.com/golang/mock/gomock" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" ) // MockVerifier is a mock of Verifier interface diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go index 86b65da..d2e3cf7 100644 --- a/pkg/client/rp/relying_party.go +++ b/pkg/client/rp/relying_party.go @@ -14,9 +14,9 @@ import ( "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/client" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/client" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) const ( @@ -255,7 +255,7 @@ func WithVerifierOpts(opts ...VerifierOption) Option { // WithClientKey specifies the path to the key.json to be used for the JWT Profile Client Authentication on the token endpoint // -//deprecated: use WithJWTProfile(SignerFromKeyPath(path)) instead +// deprecated: use WithJWTProfile(SignerFromKeyPath(path)) instead func WithClientKey(path string) Option { return WithJWTProfile(SignerFromKeyPath(path)) } @@ -304,7 +304,7 @@ func SignerFromKeyAndKeyID(key []byte, keyID string) SignerFromKey { // Discover calls the discovery endpoint of the provided issuer and returns the found endpoints // -//deprecated: use client.Discover +// deprecated: use client.Discover func Discover(issuer string, httpClient *http.Client) (Endpoints, error) { wellKnown := strings.TrimSuffix(issuer, "/") + oidc.DiscoveryEndpoint req, err := http.NewRequest("GET", wellKnown, nil) @@ -323,7 +323,7 @@ func Discover(issuer string, httpClient *http.Client) (Endpoints, error) { } // AuthURL returns the auth request url -//(wrapping the oauth2 `AuthCodeURL`) +// (wrapping the oauth2 `AuthCodeURL`) func AuthURL(state string, rp RelyingParty, opts ...AuthURLOpt) string { authOpts := make([]oauth2.AuthCodeOption, 0) for _, opt := range opts { diff --git a/pkg/client/rp/tockenexchange.go b/pkg/client/rp/tockenexchange.go index 3950fe1..c1ac88d 100644 --- a/pkg/client/rp/tockenexchange.go +++ b/pkg/client/rp/tockenexchange.go @@ -5,7 +5,7 @@ import ( "golang.org/x/oauth2" - "github.com/zitadel/oidc/pkg/oidc/grants/tokenexchange" + "github.com/zitadel/oidc/v2/pkg/oidc/grants/tokenexchange" ) // TokenExchangeRP extends the `RelyingParty` interface for the *draft* oauth2 `Token Exchange` diff --git a/pkg/client/rp/verifier.go b/pkg/client/rp/verifier.go index 6b3b3fd..f3db128 100644 --- a/pkg/client/rp/verifier.go +++ b/pkg/client/rp/verifier.go @@ -6,7 +6,7 @@ import ( "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type IDTokenVerifier interface { @@ -20,7 +20,7 @@ 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 +// 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) if err != nil { @@ -33,7 +33,7 @@ func VerifyTokens(ctx context.Context, accessToken, idTokenString string, v IDTo } // VerifyIDToken validates the id token according to -//https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation +// 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() @@ -89,7 +89,7 @@ func VerifyIDToken(ctx context.Context, token string, v IDTokenVerifier) (oidc.I } // VerifyAccessToken validates the access token according to -//https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowTokenValidation +// https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowTokenValidation func VerifyAccessToken(accessToken, atHash string, sigAlgorithm jose.SignatureAlgorithm) error { if atHash == "" { return nil diff --git a/pkg/client/rs/resource_server.go b/pkg/client/rs/resource_server.go index b1bc47e..1d9860f 100644 --- a/pkg/client/rs/resource_server.go +++ b/pkg/client/rs/resource_server.go @@ -6,9 +6,9 @@ import ( "net/http" "time" - "github.com/zitadel/oidc/pkg/client" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/client" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type ResourceServer interface { diff --git a/pkg/oidc/code_challenge.go b/pkg/oidc/code_challenge.go index e1e459c..37c1783 100644 --- a/pkg/oidc/code_challenge.go +++ b/pkg/oidc/code_challenge.go @@ -3,7 +3,7 @@ package oidc import ( "crypto/sha256" - "github.com/zitadel/oidc/pkg/crypto" + "github.com/zitadel/oidc/v2/pkg/crypto" ) const ( diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index 784684d..198049d 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -9,8 +9,8 @@ import ( "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/crypto" - "github.com/zitadel/oidc/pkg/http" + "github.com/zitadel/oidc/v2/pkg/crypto" + "github.com/zitadel/oidc/v2/pkg/http" ) const ( diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index ec11057..2b56535 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -214,8 +214,10 @@ type TokenExchangeRequest struct { } type ClientCredentialsRequest struct { - GrantType GrantType `schema:"grant_type"` - Scope SpaceDelimitedArray `schema:"scope"` - ClientID string `schema:"client_id"` - ClientSecret string `schema:"client_secret"` + GrantType GrantType `schema:"grant_type"` + Scope SpaceDelimitedArray `schema:"scope"` + ClientID string `schema:"client_id"` + ClientSecret string `schema:"client_secret"` + ClientAssertion string `schema:"client_assertion"` + ClientAssertionType string `schema:"client_assertion_type"` } diff --git a/pkg/oidc/verifier.go b/pkg/oidc/verifier.go index cc18c80..1757651 100644 --- a/pkg/oidc/verifier.go +++ b/pkg/oidc/verifier.go @@ -12,7 +12,7 @@ import ( "gopkg.in/square/go-jose.v2" - str "github.com/zitadel/oidc/pkg/strings" + str "github.com/zitadel/oidc/v2/pkg/strings" ) type Claims interface { diff --git a/pkg/op/auth_request.go b/pkg/op/auth_request.go index d8c960e..b13f642 100644 --- a/pkg/op/auth_request.go +++ b/pkg/op/auth_request.go @@ -11,9 +11,9 @@ import ( "github.com/gorilla/mux" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" - str "github.com/zitadel/oidc/pkg/strings" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" + str "github.com/zitadel/oidc/v2/pkg/strings" ) type AuthRequest interface { @@ -38,10 +38,8 @@ type Authorizer interface { Storage() Storage Decoder() httphelper.Decoder Encoder() httphelper.Encoder - Signer() Signer - IDTokenHintVerifier() IDTokenHintVerifier + IDTokenHintVerifier(context.Context) IDTokenHintVerifier Crypto() Crypto - Issuer() string RequestObjectSupported() bool } @@ -72,8 +70,9 @@ func Authorize(w http.ResponseWriter, r *http.Request, authorizer Authorizer) { AuthRequestError(w, r, authReq, err, authorizer.Encoder()) return } + ctx := r.Context() if authReq.RequestParam != "" && authorizer.RequestObjectSupported() { - authReq, err = ParseRequestObject(r.Context(), authReq, authorizer.Storage(), authorizer.Issuer()) + authReq, err = ParseRequestObject(ctx, authReq, authorizer.Storage(), IssuerFromContext(ctx)) if err != nil { AuthRequestError(w, r, authReq, err, authorizer.Encoder()) return @@ -91,7 +90,7 @@ func Authorize(w http.ResponseWriter, r *http.Request, authorizer Authorizer) { if validater, ok := authorizer.(AuthorizeValidator); ok { validation = validater.ValidateAuthRequest } - userID, err := validation(r.Context(), authReq, authorizer.Storage(), authorizer.IDTokenHintVerifier()) + userID, err := validation(ctx, authReq, authorizer.Storage(), authorizer.IDTokenHintVerifier(ctx)) if err != nil { AuthRequestError(w, r, authReq, err, authorizer.Encoder()) return @@ -100,12 +99,12 @@ func Authorize(w http.ResponseWriter, r *http.Request, authorizer Authorizer) { AuthRequestError(w, r, authReq, oidc.ErrRequestNotSupported(), authorizer.Encoder()) return } - req, err := authorizer.Storage().CreateAuthRequest(r.Context(), authReq, userID) + req, err := authorizer.Storage().CreateAuthRequest(ctx, authReq, userID) if err != nil { AuthRequestError(w, r, authReq, oidc.DefaultToServerError(err, "unable to save auth request"), authorizer.Encoder()) return } - client, err := authorizer.Storage().GetClientByClientID(r.Context(), req.GetClientID()) + client, err := authorizer.Storage().GetClientByClientID(ctx, req.GetClientID()) if err != nil { AuthRequestError(w, r, req, oidc.DefaultToServerError(err, "unable to retrieve client by id"), authorizer.Encoder()) return diff --git a/pkg/op/auth_request_test.go b/pkg/op/auth_request_test.go index dc6f655..7a9701b 100644 --- a/pkg/op/auth_request_test.go +++ b/pkg/op/auth_request_test.go @@ -13,10 +13,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/op" - "github.com/zitadel/oidc/pkg/op/mock" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" + "github.com/zitadel/oidc/v2/pkg/op/mock" ) // diff --git a/pkg/op/client.go b/pkg/op/client.go index d9f7ab0..e8a3347 100644 --- a/pkg/op/client.go +++ b/pkg/op/client.go @@ -3,7 +3,7 @@ package op import ( "time" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" ) //go:generate go get github.com/dmarkham/enumer diff --git a/pkg/op/config.go b/pkg/op/config.go index 82cbb47..c40fa2d 100644 --- a/pkg/op/config.go +++ b/pkg/op/config.go @@ -2,20 +2,24 @@ package op import ( "errors" + "net/http" "net/url" - "os" + "strings" "golang.org/x/text/language" ) -const ( - OidcDevMode = "ZITADEL_OIDC_DEV" - // deprecated: use OidcDevMode (ZITADEL_OIDC_DEV=true) - devMode = "CAOS_OIDC_DEV" +var ( + ErrInvalidIssuerPath = errors.New("no fragments or query allowed for issuer") + ErrInvalidIssuerNoIssuer = errors.New("missing issuer") + ErrInvalidIssuerURL = errors.New("invalid url for issuer") + ErrInvalidIssuerMissingHost = errors.New("host for issuer missing") + ErrInvalidIssuerHTTPS = errors.New("scheme for issuer must be `https`") ) type Configuration interface { - Issuer() string + IssuerFromRequest(r *http.Request) string + Insecure() bool AuthorizationEndpoint() Endpoint TokenEndpoint() Endpoint IntrospectionEndpoint() Endpoint @@ -42,36 +46,74 @@ type Configuration interface { SupportedUILocales() []language.Tag } -func ValidateIssuer(issuer string) error { +type IssuerFromRequest func(r *http.Request) string + +func IssuerFromHost(path string) func(bool) (IssuerFromRequest, error) { + return func(allowInsecure bool) (IssuerFromRequest, error) { + issuerPath, err := url.Parse(path) + if err != nil { + return nil, ErrInvalidIssuerURL + } + if err := ValidateIssuerPath(issuerPath); err != nil { + return nil, err + } + return func(r *http.Request) string { + return dynamicIssuer(r.Host, path, allowInsecure) + }, nil + } +} + +func StaticIssuer(issuer string) func(bool) (IssuerFromRequest, error) { + return func(allowInsecure bool) (IssuerFromRequest, error) { + if err := ValidateIssuer(issuer, allowInsecure); err != nil { + return nil, err + } + return func(_ *http.Request) string { + return issuer + }, nil + } +} + +func ValidateIssuer(issuer string, allowInsecure bool) error { if issuer == "" { - return errors.New("missing issuer") + return ErrInvalidIssuerNoIssuer } u, err := url.Parse(issuer) if err != nil { - return errors.New("invalid url for issuer") + return ErrInvalidIssuerURL } if u.Host == "" { - return errors.New("host for issuer missing") + return ErrInvalidIssuerMissingHost } if u.Scheme != "https" { - if !devLocalAllowed(u) { - return errors.New("scheme for issuer must be `https`") + if !devLocalAllowed(u, allowInsecure) { + return ErrInvalidIssuerHTTPS } } - if u.Fragment != "" || len(u.Query()) > 0 { - return errors.New("no fragments or query allowed for issuer") + return ValidateIssuerPath(u) +} + +func ValidateIssuerPath(issuer *url.URL) error { + if issuer.Fragment != "" || len(issuer.Query()) > 0 { + return ErrInvalidIssuerPath } return nil } -func devLocalAllowed(url *url.URL) bool { - _, b := os.LookupEnv(OidcDevMode) - if !b { - // check the old / current env var as well - _, b = os.LookupEnv(devMode) - if !b { - return b - } +func devLocalAllowed(url *url.URL, allowInsecure bool) bool { + if !allowInsecure { + return false } return url.Scheme == "http" } + +func dynamicIssuer(issuer, path string, allowInsecure bool) string { + schema := "https" + if allowInsecure { + schema = "http" + } + if len(path) > 0 && !strings.HasPrefix(path, "/") { + path = "/" + path + } + return schema + "://" + issuer + path +} diff --git a/pkg/op/config_test.go b/pkg/op/config_test.go index 9ff75f1..cfe4e61 100644 --- a/pkg/op/config_test.go +++ b/pkg/op/config_test.go @@ -1,13 +1,17 @@ package op import ( - "os" + "net/http/httptest" + "net/url" "testing" + + "github.com/stretchr/testify/assert" ) func TestValidateIssuer(t *testing.T) { type args struct { - issuer string + issuer string + allowInsecure bool } tests := []struct { name string @@ -16,65 +20,97 @@ func TestValidateIssuer(t *testing.T) { }{ { "missing issuer fails", - args{""}, + args{ + issuer: "", + }, true, }, { "invalid url for issuer fails", - args{":issuer"}, - true, - }, - { - "invalid url for issuer fails", - args{":issuer"}, + args{ + issuer: ":issuer", + }, true, }, { "host for issuer missing fails", - args{"https:///issuer"}, - true, - }, - { - "host for not https fails", - args{"http://issuer.com"}, + args{ + issuer: "https:///issuer", + }, true, }, { "host with fragment fails", - args{"https://issuer.com/#issuer"}, + args{ + issuer: "https://issuer.com/#issuer", + }, true, }, { "host with query fails", - args{"https://issuer.com?issuer=me"}, + args{ + issuer: "https://issuer.com?issuer=me", + }, + true, + }, + { + "host with http fails", + args{ + issuer: "http://issuer.com", + }, true, }, { "host with https ok", - args{"https://issuer.com"}, + args{ + issuer: "https://issuer.com", + }, false, }, { - "localhost with http fails", - args{"http://localhost:9999"}, + "custom scheme fails", + args{ + issuer: "custom://localhost:9999", + }, + true, + }, + { + "http with allowInsecure ok", + args{ + issuer: "http://localhost:9999", + allowInsecure: true, + }, + false, + }, + { + "https with allowInsecure ok", + args{ + issuer: "https://localhost:9999", + allowInsecure: true, + }, + false, + }, + { + "custom scheme with allowInsecure fails", + args{ + issuer: "custom://localhost:9999", + allowInsecure: true, + }, true, }, } - // ensure env is not set - //nolint:errcheck - os.Unsetenv(OidcDevMode) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := ValidateIssuer(tt.args.issuer); (err != nil) != tt.wantErr { + if err := ValidateIssuer(tt.args.issuer, tt.args.allowInsecure); (err != nil) != tt.wantErr { t.Errorf("ValidateIssuer() error = %v, wantErr %v", err, tt.wantErr) } }) } } -func TestValidateIssuerDevLocalAllowed(t *testing.T) { +func TestValidateIssuerPath(t *testing.T) { type args struct { - issuer string + issuerPath *url.URL } tests := []struct { name string @@ -82,17 +118,217 @@ func TestValidateIssuerDevLocalAllowed(t *testing.T) { wantErr bool }{ { - "localhost with http with dev ok", - args{"http://localhost:9999"}, + "empty ok", + args{func() *url.URL { + u, _ := url.Parse("") + return u + }()}, false, }, + { + "custom ok", + args{func() *url.URL { + u, _ := url.Parse("/custom") + return u + }()}, + false, + }, + { + "fragment fails", + args{func() *url.URL { + u, _ := url.Parse("#fragment") + return u + }()}, + true, + }, + { + "query fails", + args{func() *url.URL { + u, _ := url.Parse("?query=value") + return u + }()}, + true, + }, } - //nolint:errcheck - os.Setenv(OidcDevMode, "true") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := ValidateIssuer(tt.args.issuer); (err != nil) != tt.wantErr { - t.Errorf("ValidateIssuer() error = %v, wantErr %v", err, tt.wantErr) + if err := ValidateIssuerPath(tt.args.issuerPath); (err != nil) != tt.wantErr { + t.Errorf("ValidateIssuerPath() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestIssuerFromHost(t *testing.T) { + type args struct { + path string + allowInsecure bool + target string + } + type res struct { + issuer string + err error + } + tests := []struct { + name string + args args + res res + }{ + { + "invalid issuer path", + args{ + path: "/#fragment", + allowInsecure: false, + }, + res{ + issuer: "", + err: ErrInvalidIssuerPath, + }, + }, + { + "empty path secure", + args{ + path: "", + allowInsecure: false, + target: "https://issuer.com", + }, + res{ + issuer: "https://issuer.com", + err: nil, + }, + }, + { + "custom path secure", + args{ + path: "/custom/", + allowInsecure: false, + target: "https://issuer.com", + }, + res{ + issuer: "https://issuer.com/custom/", + err: nil, + }, + }, + { + "custom path no leading slash", + args{ + path: "custom/", + allowInsecure: false, + target: "https://issuer.com", + }, + res{ + issuer: "https://issuer.com/custom/", + err: nil, + }, + }, + { + "empty path unsecure", + args{ + path: "", + allowInsecure: true, + target: "http://issuer.com", + }, + res{ + issuer: "http://issuer.com", + err: nil, + }, + }, + { + "custom path unsecure", + args{ + path: "/custom/", + allowInsecure: true, + target: "http://issuer.com", + }, + res{ + issuer: "http://issuer.com/custom/", + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issuer, err := IssuerFromHost(tt.args.path)(tt.args.allowInsecure) + if tt.res.err == nil { + assert.NoError(t, err) + req := httptest.NewRequest("", tt.args.target, nil) + assert.Equal(t, tt.res.issuer, issuer(req)) + } + if tt.res.err != nil { + assert.ErrorIs(t, err, tt.res.err) + } + }) + } +} + +func TestStaticIssuer(t *testing.T) { + type args struct { + issuer string + allowInsecure bool + } + type res struct { + issuer string + err error + } + tests := []struct { + name string + args args + res res + }{ + { + "invalid issuer", + args{ + issuer: "", + allowInsecure: false, + }, + res{ + issuer: "", + err: ErrInvalidIssuerNoIssuer, + }, + }, + { + "empty path secure", + args{ + issuer: "https://issuer.com", + allowInsecure: false, + }, + res{ + issuer: "https://issuer.com", + err: nil, + }, + }, + { + "custom path secure", + args{ + issuer: "https://issuer.com/custom/", + allowInsecure: false, + }, + res{ + issuer: "https://issuer.com/custom/", + err: nil, + }, + }, + { + "unsecure", + args{ + issuer: "http://issuer.com", + allowInsecure: true, + }, + res{ + issuer: "http://issuer.com", + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issuer, err := StaticIssuer(tt.args.issuer)(tt.args.allowInsecure) + if tt.res.err == nil { + assert.NoError(t, err) + assert.Equal(t, tt.res.issuer, issuer(nil)) + } + if tt.res.err != nil { + assert.ErrorIs(t, err, tt.res.err) } }) } diff --git a/pkg/op/context.go b/pkg/op/context.go new file mode 100644 index 0000000..4406273 --- /dev/null +++ b/pkg/op/context.go @@ -0,0 +1,49 @@ +package op + +import ( + "context" + "net/http" +) + +type key int + +var ( + issuer key = 0 +) + +type IssuerInterceptor struct { + issuerFromRequest IssuerFromRequest +} + +//NewIssuerInterceptor will set the issuer into the context +//by the provided IssuerFromRequest (e.g. returned from StaticIssuer or IssuerFromHost) +func NewIssuerInterceptor(issuerFromRequest IssuerFromRequest) *IssuerInterceptor { + return &IssuerInterceptor{ + issuerFromRequest: issuerFromRequest, + } +} + +func (i *IssuerInterceptor) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + i.setIssuerCtx(w, r, next) + }) +} + +func (i *IssuerInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + i.setIssuerCtx(w, r, next) + } +} + +//IssuerFromContext reads the issuer from the context (set by an IssuerInterceptor) +//it will return an empty string if not found +func IssuerFromContext(ctx context.Context) string { + ctxIssuer, _ := ctx.Value(issuer).(string) + return ctxIssuer +} + +func (i *IssuerInterceptor) setIssuerCtx(w http.ResponseWriter, r *http.Request, next http.Handler) { + ctx := context.WithValue(r.Context(), issuer, i.issuerFromRequest(r)) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) +} diff --git a/pkg/op/context_test.go b/pkg/op/context_test.go new file mode 100644 index 0000000..e6bfcec --- /dev/null +++ b/pkg/op/context_test.go @@ -0,0 +1,76 @@ +package op + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIssuerInterceptor(t *testing.T) { + type fields struct { + issuerFromRequest IssuerFromRequest + } + type args struct { + r *http.Request + next http.Handler + } + type res struct { + issuer string + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "empty", + fields{ + func(r *http.Request) string { + return "" + }, + }, + args{}, + res{ + issuer: "", + }, + }, + { + "static", + fields{ + func(r *http.Request) string { + return "static" + }, + }, + args{}, + res{ + issuer: "static", + }, + }, + { + "host", + fields{ + func(r *http.Request) string { + return r.Host + }, + }, + args{}, + res{ + issuer: "issuer.com", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := NewIssuerInterceptor(tt.fields.issuerFromRequest) + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + assert.Equal(t, tt.res.issuer, IssuerFromContext(r.Context())) + }) + req := httptest.NewRequest("", "https://issuer.com", nil) + i.Handler(next).ServeHTTP(nil, req) + i.HandlerFunc(next).ServeHTTP(nil, req) + }) + } +} diff --git a/pkg/op/crypto.go b/pkg/op/crypto.go index f14b1de..6786022 100644 --- a/pkg/op/crypto.go +++ b/pkg/op/crypto.go @@ -1,7 +1,7 @@ package op import ( - "github.com/zitadel/oidc/pkg/crypto" + "github.com/zitadel/oidc/v2/pkg/crypto" ) type Crypto interface { diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go index 100bfc8..9a25afc 100644 --- a/pkg/op/discovery.go +++ b/pkg/op/discovery.go @@ -1,49 +1,17 @@ package op import ( + "context" "net/http" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + "gopkg.in/square/go-jose.v2" + + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) -func discoveryHandler(c Configuration, s Signer) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - Discover(w, CreateDiscoveryConfig(c, s)) - } -} - -func Discover(w http.ResponseWriter, config *oidc.DiscoveryConfiguration) { - httphelper.MarshalJSON(w, config) -} - -func CreateDiscoveryConfig(c Configuration, s Signer) *oidc.DiscoveryConfiguration { - return &oidc.DiscoveryConfiguration{ - Issuer: c.Issuer(), - AuthorizationEndpoint: c.AuthorizationEndpoint().Absolute(c.Issuer()), - TokenEndpoint: c.TokenEndpoint().Absolute(c.Issuer()), - IntrospectionEndpoint: c.IntrospectionEndpoint().Absolute(c.Issuer()), - UserinfoEndpoint: c.UserinfoEndpoint().Absolute(c.Issuer()), - RevocationEndpoint: c.RevocationEndpoint().Absolute(c.Issuer()), - EndSessionEndpoint: c.EndSessionEndpoint().Absolute(c.Issuer()), - JwksURI: c.KeysEndpoint().Absolute(c.Issuer()), - ScopesSupported: Scopes(c), - ResponseTypesSupported: ResponseTypes(c), - GrantTypesSupported: GrantTypes(c), - SubjectTypesSupported: SubjectTypes(c), - IDTokenSigningAlgValuesSupported: SigAlgorithms(s), - RequestObjectSigningAlgValuesSupported: RequestObjectSigAlgorithms(c), - TokenEndpointAuthMethodsSupported: AuthMethodsTokenEndpoint(c), - TokenEndpointAuthSigningAlgValuesSupported: TokenSigAlgorithms(c), - IntrospectionEndpointAuthSigningAlgValuesSupported: IntrospectionSigAlgorithms(c), - IntrospectionEndpointAuthMethodsSupported: AuthMethodsIntrospectionEndpoint(c), - RevocationEndpointAuthSigningAlgValuesSupported: RevocationSigAlgorithms(c), - RevocationEndpointAuthMethodsSupported: AuthMethodsRevocationEndpoint(c), - ClaimsSupported: SupportedClaims(c), - CodeChallengeMethodsSupported: CodeChallengeMethods(c), - UILocalesSupported: c.SupportedUILocales(), - RequestParameterSupported: c.RequestObjectSupported(), - } +type DiscoverStorage interface { + SignatureAlgorithms(context.Context) ([]jose.SignatureAlgorithm, error) } var DefaultSupportedScopes = []string{ @@ -55,6 +23,46 @@ var DefaultSupportedScopes = []string{ oidc.ScopeOfflineAccess, } +func discoveryHandler(c Configuration, s DiscoverStorage) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + Discover(w, CreateDiscoveryConfig(r, c, s)) + } +} + +func Discover(w http.ResponseWriter, config *oidc.DiscoveryConfiguration) { + httphelper.MarshalJSON(w, config) +} + +func CreateDiscoveryConfig(r *http.Request, config Configuration, storage DiscoverStorage) *oidc.DiscoveryConfiguration { + issuer := config.IssuerFromRequest(r) + return &oidc.DiscoveryConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: config.AuthorizationEndpoint().Absolute(issuer), + TokenEndpoint: config.TokenEndpoint().Absolute(issuer), + IntrospectionEndpoint: config.IntrospectionEndpoint().Absolute(issuer), + UserinfoEndpoint: config.UserinfoEndpoint().Absolute(issuer), + RevocationEndpoint: config.RevocationEndpoint().Absolute(issuer), + EndSessionEndpoint: config.EndSessionEndpoint().Absolute(issuer), + JwksURI: config.KeysEndpoint().Absolute(issuer), + ScopesSupported: Scopes(config), + ResponseTypesSupported: ResponseTypes(config), + GrantTypesSupported: GrantTypes(config), + SubjectTypesSupported: SubjectTypes(config), + IDTokenSigningAlgValuesSupported: SigAlgorithms(r.Context(), storage), + RequestObjectSigningAlgValuesSupported: RequestObjectSigAlgorithms(config), + TokenEndpointAuthMethodsSupported: AuthMethodsTokenEndpoint(config), + TokenEndpointAuthSigningAlgValuesSupported: TokenSigAlgorithms(config), + IntrospectionEndpointAuthSigningAlgValuesSupported: IntrospectionSigAlgorithms(config), + IntrospectionEndpointAuthMethodsSupported: AuthMethodsIntrospectionEndpoint(config), + RevocationEndpointAuthSigningAlgValuesSupported: RevocationSigAlgorithms(config), + RevocationEndpointAuthMethodsSupported: AuthMethodsRevocationEndpoint(config), + ClaimsSupported: SupportedClaims(config), + CodeChallengeMethodsSupported: CodeChallengeMethods(config), + UILocalesSupported: config.SupportedUILocales(), + RequestParameterSupported: config.RequestObjectSupported(), + } +} + func Scopes(c Configuration) []string { return DefaultSupportedScopes // TODO: config } @@ -87,6 +95,88 @@ func GrantTypes(c Configuration) []oidc.GrantType { return grantTypes } +func SubjectTypes(c Configuration) []string { + return []string{"public"} //TODO: config +} + +func SigAlgorithms(ctx context.Context, storage DiscoverStorage) []string { + algorithms, err := storage.SignatureAlgorithms(ctx) + if err != nil { + return nil + } + algs := make([]string, len(algorithms)) + for i, algorithm := range algorithms { + algs[i] = string(algorithm) + } + return algs +} + +func RequestObjectSigAlgorithms(c Configuration) []string { + if !c.RequestObjectSupported() { + return nil + } + return c.RequestObjectSigningAlgorithmsSupported() +} + +func AuthMethodsTokenEndpoint(c Configuration) []oidc.AuthMethod { + authMethods := []oidc.AuthMethod{ + oidc.AuthMethodNone, + oidc.AuthMethodBasic, + } + if c.AuthMethodPostSupported() { + authMethods = append(authMethods, oidc.AuthMethodPost) + } + if c.AuthMethodPrivateKeyJWTSupported() { + authMethods = append(authMethods, oidc.AuthMethodPrivateKeyJWT) + } + return authMethods +} + +func TokenSigAlgorithms(c Configuration) []string { + if !c.AuthMethodPrivateKeyJWTSupported() { + return nil + } + return c.TokenEndpointSigningAlgorithmsSupported() +} + +func IntrospectionSigAlgorithms(c Configuration) []string { + if !c.IntrospectionAuthMethodPrivateKeyJWTSupported() { + return nil + } + return c.IntrospectionEndpointSigningAlgorithmsSupported() +} + +func AuthMethodsIntrospectionEndpoint(c Configuration) []oidc.AuthMethod { + authMethods := []oidc.AuthMethod{ + oidc.AuthMethodBasic, + } + if c.AuthMethodPrivateKeyJWTSupported() { + authMethods = append(authMethods, oidc.AuthMethodPrivateKeyJWT) + } + return authMethods +} + +func RevocationSigAlgorithms(c Configuration) []string { + if !c.RevocationAuthMethodPrivateKeyJWTSupported() { + return nil + } + return c.RevocationEndpointSigningAlgorithmsSupported() +} + +func AuthMethodsRevocationEndpoint(c Configuration) []oidc.AuthMethod { + authMethods := []oidc.AuthMethod{ + oidc.AuthMethodNone, + oidc.AuthMethodBasic, + } + if c.AuthMethodPostSupported() { + authMethods = append(authMethods, oidc.AuthMethodPost) + } + if c.AuthMethodPrivateKeyJWTSupported() { + authMethods = append(authMethods, oidc.AuthMethodPrivateKeyJWT) + } + return authMethods +} + func SupportedClaims(c Configuration) []string { return []string{ // TODO: config "sub", @@ -116,59 +206,6 @@ func SupportedClaims(c Configuration) []string { } } -func SigAlgorithms(s Signer) []string { - return []string{string(s.SignatureAlgorithm())} -} - -func SubjectTypes(c Configuration) []string { - return []string{"public"} // TODO: config -} - -func AuthMethodsTokenEndpoint(c Configuration) []oidc.AuthMethod { - authMethods := []oidc.AuthMethod{ - oidc.AuthMethodNone, - oidc.AuthMethodBasic, - } - if c.AuthMethodPostSupported() { - authMethods = append(authMethods, oidc.AuthMethodPost) - } - if c.AuthMethodPrivateKeyJWTSupported() { - authMethods = append(authMethods, oidc.AuthMethodPrivateKeyJWT) - } - return authMethods -} - -func TokenSigAlgorithms(c Configuration) []string { - if !c.AuthMethodPrivateKeyJWTSupported() { - return nil - } - return c.TokenEndpointSigningAlgorithmsSupported() -} - -func AuthMethodsIntrospectionEndpoint(c Configuration) []oidc.AuthMethod { - authMethods := []oidc.AuthMethod{ - oidc.AuthMethodBasic, - } - if c.AuthMethodPrivateKeyJWTSupported() { - authMethods = append(authMethods, oidc.AuthMethodPrivateKeyJWT) - } - return authMethods -} - -func AuthMethodsRevocationEndpoint(c Configuration) []oidc.AuthMethod { - authMethods := []oidc.AuthMethod{ - oidc.AuthMethodNone, - oidc.AuthMethodBasic, - } - if c.AuthMethodPostSupported() { - authMethods = append(authMethods, oidc.AuthMethodPost) - } - if c.AuthMethodPrivateKeyJWTSupported() { - authMethods = append(authMethods, oidc.AuthMethodPrivateKeyJWT) - } - return authMethods -} - func CodeChallengeMethods(c Configuration) []oidc.CodeChallengeMethod { codeMethods := make([]oidc.CodeChallengeMethod, 0, 1) if c.CodeMethodS256Supported() { @@ -176,24 +213,3 @@ func CodeChallengeMethods(c Configuration) []oidc.CodeChallengeMethod { } return codeMethods } - -func IntrospectionSigAlgorithms(c Configuration) []string { - if !c.IntrospectionAuthMethodPrivateKeyJWTSupported() { - return nil - } - return c.IntrospectionEndpointSigningAlgorithmsSupported() -} - -func RevocationSigAlgorithms(c Configuration) []string { - if !c.RevocationAuthMethodPrivateKeyJWTSupported() { - return nil - } - return c.RevocationEndpointSigningAlgorithmsSupported() -} - -func RequestObjectSigAlgorithms(c Configuration) []string { - if !c.RequestObjectSupported() { - return nil - } - return c.RequestObjectSigningAlgorithmsSupported() -} diff --git a/pkg/op/discovery_test.go b/pkg/op/discovery_test.go index 1d74f75..e1b07dd 100644 --- a/pkg/op/discovery_test.go +++ b/pkg/op/discovery_test.go @@ -1,18 +1,19 @@ package op_test import ( + "context" "net/http" "net/http/httptest" - "reflect" "testing" "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/op" - "github.com/zitadel/oidc/pkg/op/mock" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" + "github.com/zitadel/oidc/v2/pkg/op/mock" ) func TestDiscover(t *testing.T) { @@ -47,8 +48,9 @@ func TestDiscover(t *testing.T) { func TestCreateDiscoveryConfig(t *testing.T) { type args struct { - c op.Configuration - s op.Signer + request *http.Request + c op.Configuration + s op.DiscoverStorage } tests := []struct { name string @@ -59,9 +61,8 @@ func TestCreateDiscoveryConfig(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := op.CreateDiscoveryConfig(tt.args.c, tt.args.s); !reflect.DeepEqual(got, tt.want) { - t.Errorf("CreateDiscoveryConfig() = %v, want %v", got, tt.want) - } + got := op.CreateDiscoveryConfig(tt.args.request, tt.args.c, tt.args.s) + assert.Equal(t, tt.want, got) }) } } @@ -83,9 +84,8 @@ func Test_scopes(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := op.Scopes(tt.args.c); !reflect.DeepEqual(got, tt.want) { - t.Errorf("scopes() = %v, want %v", got, tt.want) - } + got := op.Scopes(tt.args.c) + assert.Equal(t, tt.want, got) }) } } @@ -99,13 +99,16 @@ func Test_ResponseTypes(t *testing.T) { args args want []string }{ - // TODO: Add test cases. + { + "code and implicit flow", + args{}, + []string{"code", "id_token", "id_token token"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := op.ResponseTypes(tt.args.c); !reflect.DeepEqual(got, tt.want) { - t.Errorf("responseTypes() = %v, want %v", got, tt.want) - } + got := op.ResponseTypes(tt.args.c) + assert.Equal(t, tt.want, got) }) } } @@ -117,63 +120,51 @@ func Test_GrantTypes(t *testing.T) { tests := []struct { name string args args - want []string - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := op.GrantTypes(tt.args.c); !reflect.DeepEqual(got, tt.want) { - t.Errorf("grantTypes() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestSupportedClaims(t *testing.T) { - type args struct { - c op.Configuration - } - tests := []struct { - name string - args args - want []string - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := op.SupportedClaims(tt.args.c); !reflect.DeepEqual(got, tt.want) { - t.Errorf("SupportedClaims() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_SigAlgorithms(t *testing.T) { - m := mock.NewMockSigner(gomock.NewController(t)) - type args struct { - s op.Signer - } - tests := []struct { - name string - args args - want []string + want []oidc.GrantType }{ { - "", - args{func() op.Signer { - m.EXPECT().SignatureAlgorithm().Return(jose.RS256) - return m - }()}, - []string{"RS256"}, + "code and implicit flow", + args{ + func() op.Configuration { + c := mock.NewMockConfiguration(gomock.NewController(t)) + c.EXPECT().GrantTypeRefreshTokenSupported().Return(false) + c.EXPECT().GrantTypeTokenExchangeSupported().Return(false) + c.EXPECT().GrantTypeJWTAuthorizationSupported().Return(false) + c.EXPECT().GrantTypeClientCredentialsSupported().Return(false) + return c + }(), + }, + []oidc.GrantType{ + oidc.GrantTypeCode, + oidc.GrantTypeImplicit, + }, + }, + { + "code, implicit flow, refresh token, token exchange, jwt profile, client_credentials", + args{ + func() op.Configuration { + c := mock.NewMockConfiguration(gomock.NewController(t)) + c.EXPECT().GrantTypeRefreshTokenSupported().Return(true) + c.EXPECT().GrantTypeTokenExchangeSupported().Return(true) + c.EXPECT().GrantTypeJWTAuthorizationSupported().Return(true) + c.EXPECT().GrantTypeClientCredentialsSupported().Return(true) + return c + }(), + }, + []oidc.GrantType{ + oidc.GrantTypeCode, + oidc.GrantTypeImplicit, + oidc.GrantTypeRefreshToken, + oidc.GrantTypeClientCredentials, + oidc.GrantTypeTokenExchange, + oidc.GrantTypeBearer, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := op.SigAlgorithms(tt.args.s); !reflect.DeepEqual(got, tt.want) { - t.Errorf("sigAlgorithms() = %v, want %v", got, tt.want) - } + got := op.GrantTypes(tt.args.c) + assert.Equal(t, tt.want, got) }) } } @@ -195,9 +186,80 @@ func Test_SubjectTypes(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := op.SubjectTypes(tt.args.c); !reflect.DeepEqual(got, tt.want) { - t.Errorf("subjectTypes() = %v, want %v", got, tt.want) - } + got := op.SubjectTypes(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_SigAlgorithms(t *testing.T) { + m := mock.NewMockDiscoverStorage(gomock.NewController(t)) + type args struct { + s op.DiscoverStorage + } + tests := []struct { + name string + args args + want []string + }{ + { + "", + args{func() op.DiscoverStorage { + m.EXPECT().SignatureAlgorithms(gomock.Any()).Return([]jose.SignatureAlgorithm{jose.RS256}, nil) + return m + }()}, + []string{"RS256"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.SigAlgorithms(context.Background(), tt.args.s) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_RequestObjectSigAlgorithms(t *testing.T) { + m := mock.NewMockConfiguration(gomock.NewController(t)) + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []string + }{ + { + "not supported, empty", + args{func() op.Configuration { + m.EXPECT().RequestObjectSupported().Return(false) + return m + }()}, + nil, + }, + { + "supported, empty", + args{func() op.Configuration { + m.EXPECT().RequestObjectSupported().Return(true) + m.EXPECT().RequestObjectSigningAlgorithmsSupported().Return(nil) + return m + }()}, + nil, + }, + { + "supported, list", + args{func() op.Configuration { + m.EXPECT().RequestObjectSupported().Return(true) + m.EXPECT().RequestObjectSigningAlgorithmsSupported().Return([]string{"RS256"}) + return m + }()}, + []string{"RS256"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.RequestObjectSigAlgorithms(tt.args.c) + assert.Equal(t, tt.want, got) }) } } @@ -244,9 +306,311 @@ func Test_AuthMethodsTokenEndpoint(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := op.AuthMethodsTokenEndpoint(tt.args.c); !reflect.DeepEqual(got, tt.want) { - t.Errorf("authMethods() = %v, want %v", got, tt.want) - } + got := op.AuthMethodsTokenEndpoint(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_TokenSigAlgorithms(t *testing.T) { + m := mock.NewMockConfiguration(gomock.NewController(t)) + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []string + }{ + { + "not supported, empty", + args{func() op.Configuration { + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(false) + return m + }()}, + nil, + }, + { + "supported, empty", + args{func() op.Configuration { + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(true) + m.EXPECT().TokenEndpointSigningAlgorithmsSupported().Return(nil) + return m + }()}, + nil, + }, + { + "supported, list", + args{func() op.Configuration { + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(true) + m.EXPECT().TokenEndpointSigningAlgorithmsSupported().Return([]string{"RS256"}) + return m + }()}, + []string{"RS256"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.TokenSigAlgorithms(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_IntrospectionSigAlgorithms(t *testing.T) { + m := mock.NewMockConfiguration(gomock.NewController(t)) + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []string + }{ + { + "not supported, empty", + args{func() op.Configuration { + m.EXPECT().IntrospectionAuthMethodPrivateKeyJWTSupported().Return(false) + return m + }()}, + nil, + }, + { + "supported, empty", + args{func() op.Configuration { + m.EXPECT().IntrospectionAuthMethodPrivateKeyJWTSupported().Return(true) + m.EXPECT().IntrospectionEndpointSigningAlgorithmsSupported().Return(nil) + return m + }()}, + nil, + }, + { + "supported, list", + args{func() op.Configuration { + m.EXPECT().IntrospectionAuthMethodPrivateKeyJWTSupported().Return(true) + m.EXPECT().IntrospectionEndpointSigningAlgorithmsSupported().Return([]string{"RS256"}) + return m + }()}, + []string{"RS256"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.IntrospectionSigAlgorithms(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_AuthMethodsIntrospectionEndpoint(t *testing.T) { + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []oidc.AuthMethod + }{ + { + "basic only", + args{func() op.Configuration { + m := mock.NewMockConfiguration(gomock.NewController(t)) + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(false) + return m + }()}, + []oidc.AuthMethod{oidc.AuthMethodBasic}, + }, + { + "basic and private_key_jwt", + args{func() op.Configuration { + m := mock.NewMockConfiguration(gomock.NewController(t)) + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(true) + return m + }()}, + []oidc.AuthMethod{oidc.AuthMethodBasic, oidc.AuthMethodPrivateKeyJWT}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.AuthMethodsIntrospectionEndpoint(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_RevocationSigAlgorithms(t *testing.T) { + m := mock.NewMockConfiguration(gomock.NewController(t)) + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []string + }{ + { + "not supported, empty", + args{func() op.Configuration { + m.EXPECT().RevocationAuthMethodPrivateKeyJWTSupported().Return(false) + return m + }()}, + nil, + }, + { + "supported, empty", + args{func() op.Configuration { + m.EXPECT().RevocationAuthMethodPrivateKeyJWTSupported().Return(true) + m.EXPECT().RevocationEndpointSigningAlgorithmsSupported().Return(nil) + return m + }()}, + nil, + }, + { + "supported, list", + args{func() op.Configuration { + m.EXPECT().RevocationAuthMethodPrivateKeyJWTSupported().Return(true) + m.EXPECT().RevocationEndpointSigningAlgorithmsSupported().Return([]string{"RS256"}) + return m + }()}, + []string{"RS256"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.RevocationSigAlgorithms(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_AuthMethodsRevocationEndpoint(t *testing.T) { + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []oidc.AuthMethod + }{ + { + "none and basic", + args{func() op.Configuration { + m := mock.NewMockConfiguration(gomock.NewController(t)) + m.EXPECT().AuthMethodPostSupported().Return(false) + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(false) + return m + }()}, + []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic}, + }, + { + "none, basic and post", + args{func() op.Configuration { + m := mock.NewMockConfiguration(gomock.NewController(t)) + m.EXPECT().AuthMethodPostSupported().Return(true) + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(false) + return m + }()}, + []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost}, + }, + { + "none, basic, post and private_key_jwt", + args{func() op.Configuration { + m := mock.NewMockConfiguration(gomock.NewController(t)) + m.EXPECT().AuthMethodPostSupported().Return(true) + m.EXPECT().AuthMethodPrivateKeyJWTSupported().Return(true) + return m + }()}, + []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost, oidc.AuthMethodPrivateKeyJWT}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.AuthMethodsRevocationEndpoint(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSupportedClaims(t *testing.T) { + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []string + }{ + { + "scopes", + args{}, + []string{ + "sub", + "aud", + "exp", + "iat", + "iss", + "auth_time", + "nonce", + "acr", + "amr", + "c_hash", + "at_hash", + "act", + "scopes", + "client_id", + "azp", + "preferred_username", + "name", + "family_name", + "given_name", + "locale", + "email", + "email_verified", + "phone_number", + "phone_number_verified", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.SupportedClaims(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_CodeChallengeMethods(t *testing.T) { + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want []oidc.CodeChallengeMethod + }{ + { + "not supported", + args{func() op.Configuration { + m := mock.NewMockConfiguration(gomock.NewController(t)) + m.EXPECT().CodeMethodS256Supported().Return(false) + return m + }()}, + []oidc.CodeChallengeMethod{}, + }, + { + "S256", + args{func() op.Configuration { + m := mock.NewMockConfiguration(gomock.NewController(t)) + m.EXPECT().CodeMethodS256Supported().Return(true) + return m + }()}, + []oidc.CodeChallengeMethod{oidc.CodeChallengeMethodS256}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.CodeChallengeMethods(tt.args.c) + assert.Equal(t, tt.want, got) }) } } diff --git a/pkg/op/endpoint_test.go b/pkg/op/endpoint_test.go index 7c8d1ce..50de89c 100644 --- a/pkg/op/endpoint_test.go +++ b/pkg/op/endpoint_test.go @@ -3,7 +3,7 @@ package op_test import ( "testing" - "github.com/zitadel/oidc/pkg/op" + "github.com/zitadel/oidc/v2/pkg/op" ) func TestEndpoint_Path(t *testing.T) { diff --git a/pkg/op/error.go b/pkg/op/error.go index 3c820d6..acca4ab 100644 --- a/pkg/op/error.go +++ b/pkg/op/error.go @@ -3,8 +3,8 @@ package op import ( "net/http" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type ErrAuthRequest interface { diff --git a/pkg/op/keys.go b/pkg/op/keys.go index a80211e..239ecbd 100644 --- a/pkg/op/keys.go +++ b/pkg/op/keys.go @@ -6,11 +6,11 @@ import ( "gopkg.in/square/go-jose.v2" - httphelper "github.com/zitadel/oidc/pkg/http" + httphelper "github.com/zitadel/oidc/v2/pkg/http" ) type KeyProvider interface { - GetKeySet(context.Context) (*jose.JSONWebKeySet, error) + KeySet(context.Context) ([]Key, error) } func keysHandler(k KeyProvider) func(http.ResponseWriter, *http.Request) { @@ -20,10 +20,23 @@ func keysHandler(k KeyProvider) func(http.ResponseWriter, *http.Request) { } func Keys(w http.ResponseWriter, r *http.Request, k KeyProvider) { - keySet, err := k.GetKeySet(r.Context()) + keySet, err := k.KeySet(r.Context()) if err != nil { httphelper.MarshalJSONWithStatus(w, err, http.StatusInternalServerError) return } - httphelper.MarshalJSON(w, keySet) + httphelper.MarshalJSON(w, jsonWebKeySet(keySet)) +} + +func jsonWebKeySet(keys []Key) *jose.JSONWebKeySet { + webKeys := make([]jose.JSONWebKey, len(keys)) + for i, key := range keys { + webKeys[i] = jose.JSONWebKey{ + KeyID: key.ID(), + Algorithm: string(key.Algorithm()), + Use: key.Use(), + Key: key.Key(), + } + } + return &jose.JSONWebKeySet{Keys: webKeys} } diff --git a/pkg/op/keys_test.go b/pkg/op/keys_test.go index 7618589..2e56b78 100644 --- a/pkg/op/keys_test.go +++ b/pkg/op/keys_test.go @@ -11,9 +11,9 @@ import ( "github.com/stretchr/testify/assert" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/op" - "github.com/zitadel/oidc/pkg/op/mock" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" + "github.com/zitadel/oidc/v2/pkg/op/mock" ) func TestKeys(t *testing.T) { @@ -35,7 +35,7 @@ func TestKeys(t *testing.T) { args: args{ k: func() op.KeyProvider { m := mock.NewMockKeyProvider(gomock.NewController(t)) - m.EXPECT().GetKeySet(gomock.Any()).Return(nil, oidc.ErrServerError()) + m.EXPECT().KeySet(gomock.Any()).Return(nil, oidc.ErrServerError()) return m }(), }, @@ -51,39 +51,39 @@ func TestKeys(t *testing.T) { args: args{ k: func() op.KeyProvider { m := mock.NewMockKeyProvider(gomock.NewController(t)) - m.EXPECT().GetKeySet(gomock.Any()).Return(nil, nil) + m.EXPECT().KeySet(gomock.Any()).Return(nil, nil) return m }(), }, res: res{ statusCode: http.StatusOK, contentType: "application/json", + body: `{"keys":[]} +`, }, }, { name: "list", args: args{ k: func() op.KeyProvider { - m := mock.NewMockKeyProvider(gomock.NewController(t)) - m.EXPECT().GetKeySet(gomock.Any()).Return( - &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ - { - Key: &rsa.PublicKey{ - N: big.NewInt(1), - E: 1, - }, - KeyID: "id", - }, - }}, - nil, - ) + ctrl := gomock.NewController(t) + m := mock.NewMockKeyProvider(ctrl) + k := mock.NewMockKey(ctrl) + k.EXPECT().Key().Return(&rsa.PublicKey{ + N: big.NewInt(1), + E: 1, + }) + k.EXPECT().ID().Return("id") + k.EXPECT().Algorithm().Return(jose.RS256) + k.EXPECT().Use().Return("sig") + m.EXPECT().KeySet(gomock.Any()).Return([]op.Key{k}, nil) return m }(), }, res: res{ statusCode: http.StatusOK, contentType: "application/json", - body: `{"keys":[{"kty":"RSA","kid":"id","n":"AQ","e":"AQ"}]} + body: `{"keys":[{"use":"sig","kty":"RSA","kid":"id","alg":"RS256","n":"AQ","e":"AQ"}]} `, }, }, diff --git a/pkg/op/mock/authorizer.mock.go b/pkg/op/mock/authorizer.mock.go index 52f3877..cc913ee 100644 --- a/pkg/op/mock/authorizer.mock.go +++ b/pkg/op/mock/authorizer.mock.go @@ -1,15 +1,16 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/op (interfaces: Authorizer) +// Source: github.com/zitadel/oidc/v2/pkg/op (interfaces: Authorizer) // Package mock is a generated GoMock package. package mock import ( + context "context" reflect "reflect" gomock "github.com/golang/mock/gomock" - http "github.com/zitadel/oidc/pkg/http" - op "github.com/zitadel/oidc/pkg/op" + http "github.com/zitadel/oidc/v2/pkg/http" + op "github.com/zitadel/oidc/v2/pkg/op" ) // MockAuthorizer is a mock of Authorizer interface. @@ -78,31 +79,17 @@ func (mr *MockAuthorizerMockRecorder) Encoder() *gomock.Call { } // IDTokenHintVerifier mocks base method. -func (m *MockAuthorizer) IDTokenHintVerifier() op.IDTokenHintVerifier { +func (m *MockAuthorizer) IDTokenHintVerifier(arg0 context.Context) op.IDTokenHintVerifier { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IDTokenHintVerifier") + ret := m.ctrl.Call(m, "IDTokenHintVerifier", arg0) ret0, _ := ret[0].(op.IDTokenHintVerifier) return ret0 } // IDTokenHintVerifier indicates an expected call of IDTokenHintVerifier. -func (mr *MockAuthorizerMockRecorder) IDTokenHintVerifier() *gomock.Call { +func (mr *MockAuthorizerMockRecorder) IDTokenHintVerifier(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IDTokenHintVerifier", reflect.TypeOf((*MockAuthorizer)(nil).IDTokenHintVerifier)) -} - -// Issuer mocks base method. -func (m *MockAuthorizer) 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 *MockAuthorizerMockRecorder) Issuer() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Issuer", reflect.TypeOf((*MockAuthorizer)(nil).Issuer)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IDTokenHintVerifier", reflect.TypeOf((*MockAuthorizer)(nil).IDTokenHintVerifier), arg0) } // RequestObjectSupported mocks base method. @@ -119,20 +106,6 @@ func (mr *MockAuthorizerMockRecorder) RequestObjectSupported() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestObjectSupported", reflect.TypeOf((*MockAuthorizer)(nil).RequestObjectSupported)) } -// Signer mocks base method. -func (m *MockAuthorizer) Signer() op.Signer { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Signer") - ret0, _ := ret[0].(op.Signer) - return ret0 -} - -// Signer indicates an expected call of Signer. -func (mr *MockAuthorizerMockRecorder) Signer() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Signer", reflect.TypeOf((*MockAuthorizer)(nil).Signer)) -} - // Storage mocks base method. func (m *MockAuthorizer) Storage() op.Storage { m.ctrl.T.Helper() diff --git a/pkg/op/mock/authorizer.mock.impl.go b/pkg/op/mock/authorizer.mock.impl.go index d4f29d5..3f1d525 100644 --- a/pkg/op/mock/authorizer.mock.impl.go +++ b/pkg/op/mock/authorizer.mock.impl.go @@ -8,8 +8,8 @@ import ( "github.com/gorilla/schema" "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/op" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" ) func NewAuthorizer(t *testing.T) op.Authorizer { @@ -20,23 +20,13 @@ func NewAuthorizerExpectValid(t *testing.T, wantErr bool) op.Authorizer { m := NewAuthorizer(t) ExpectDecoder(m) ExpectEncoder(m) - ExpectSigner(m, t) + //ExpectSigner(m, t) ExpectStorage(m, t) ExpectVerifier(m, t) // ExpectErrorHandler(m, t, wantErr) return m } -// func NewAuthorizerExpectDecoderFails(t *testing.T) op.Authorizer { -// m := NewAuthorizer(t) -// ExpectDecoderFails(m) -// ExpectEncoder(m) -// ExpectSigner(m, t) -// ExpectStorage(m, t) -// ExpectErrorHandler(m, t) -// return m -// } - func ExpectDecoder(a op.Authorizer) { mockA := a.(*MockAuthorizer) mockA.EXPECT().Decoder().AnyTimes().Return(schema.NewDecoder()) @@ -47,17 +37,18 @@ func ExpectEncoder(a op.Authorizer) { mockA.EXPECT().Encoder().AnyTimes().Return(schema.NewEncoder()) } -func ExpectSigner(a op.Authorizer, t *testing.T) { - mockA := a.(*MockAuthorizer) - mockA.EXPECT().Signer().DoAndReturn( - func() op.Signer { - return &Sig{} - }) -} +// +//func ExpectSigner(a op.Authorizer, t *testing.T) { +// mockA := a.(*MockAuthorizer) +// mockA.EXPECT().Signer().DoAndReturn( +// func() op.Signer { +// return &Sig{} +// }) +//} func ExpectVerifier(a op.Authorizer, t *testing.T) { mockA := a.(*MockAuthorizer) - mockA.EXPECT().IDTokenHintVerifier().DoAndReturn( + mockA.EXPECT().IDTokenHintVerifier(gomock.Any()).DoAndReturn( func() op.IDTokenHintVerifier { return op.NewIDTokenHintVerifier("", nil) }) diff --git a/pkg/op/mock/client.go b/pkg/op/mock/client.go index 3b16e5e..36df84a 100644 --- a/pkg/op/mock/client.go +++ b/pkg/op/mock/client.go @@ -5,8 +5,8 @@ import ( "github.com/golang/mock/gomock" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/op" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" ) func NewClient(t *testing.T) op.Client { diff --git a/pkg/op/mock/client.mock.go b/pkg/op/mock/client.mock.go index cfe3703..e3d19fb 100644 --- a/pkg/op/mock/client.mock.go +++ b/pkg/op/mock/client.mock.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/op (interfaces: Client) +// Source: github.com/zitadel/oidc/v2/pkg/op (interfaces: Client) // Package mock is a generated GoMock package. package mock @@ -9,8 +9,8 @@ import ( time "time" gomock "github.com/golang/mock/gomock" - oidc "github.com/zitadel/oidc/pkg/oidc" - op "github.com/zitadel/oidc/pkg/op" + oidc "github.com/zitadel/oidc/v2/pkg/oidc" + op "github.com/zitadel/oidc/v2/pkg/op" ) // MockClient is a mock of Client interface. diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go index e0c90dc..fc3158a 100644 --- a/pkg/op/mock/configuration.mock.go +++ b/pkg/op/mock/configuration.mock.go @@ -1,14 +1,15 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/op (interfaces: Configuration) +// Source: github.com/zitadel/oidc/v2/pkg/op (interfaces: Configuration) // Package mock is a generated GoMock package. package mock import ( + http "net/http" reflect "reflect" gomock "github.com/golang/mock/gomock" - op "github.com/zitadel/oidc/pkg/op" + op "github.com/zitadel/oidc/v2/pkg/op" language "golang.org/x/text/language" ) @@ -161,6 +162,20 @@ func (mr *MockConfigurationMockRecorder) GrantTypeTokenExchangeSupported() *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GrantTypeTokenExchangeSupported", reflect.TypeOf((*MockConfiguration)(nil).GrantTypeTokenExchangeSupported)) } +// Insecure mocks base method. +func (m *MockConfiguration) Insecure() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Insecure") + ret0, _ := ret[0].(bool) + return ret0 +} + +// Insecure indicates an expected call of Insecure. +func (mr *MockConfigurationMockRecorder) Insecure() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insecure", reflect.TypeOf((*MockConfiguration)(nil).Insecure)) +} + // IntrospectionAuthMethodPrivateKeyJWTSupported mocks base method. func (m *MockConfiguration) IntrospectionAuthMethodPrivateKeyJWTSupported() bool { m.ctrl.T.Helper() @@ -203,18 +218,18 @@ func (mr *MockConfigurationMockRecorder) IntrospectionEndpointSigningAlgorithmsS return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IntrospectionEndpointSigningAlgorithmsSupported", reflect.TypeOf((*MockConfiguration)(nil).IntrospectionEndpointSigningAlgorithmsSupported)) } -// Issuer mocks base method. -func (m *MockConfiguration) Issuer() string { +// IssuerFromRequest mocks base method. +func (m *MockConfiguration) IssuerFromRequest(arg0 *http.Request) string { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Issuer") + ret := m.ctrl.Call(m, "IssuerFromRequest", arg0) ret0, _ := ret[0].(string) return ret0 } -// Issuer indicates an expected call of Issuer. -func (mr *MockConfigurationMockRecorder) Issuer() *gomock.Call { +// IssuerFromRequest indicates an expected call of IssuerFromRequest. +func (mr *MockConfigurationMockRecorder) IssuerFromRequest(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Issuer", reflect.TypeOf((*MockConfiguration)(nil).Issuer)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssuerFromRequest", reflect.TypeOf((*MockConfiguration)(nil).IssuerFromRequest), arg0) } // KeysEndpoint mocks base method. diff --git a/pkg/op/mock/discovery.mock.go b/pkg/op/mock/discovery.mock.go new file mode 100644 index 0000000..0c78d52 --- /dev/null +++ b/pkg/op/mock/discovery.mock.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/oidc/v2/pkg/op (interfaces: DiscoverStorage) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + jose "gopkg.in/square/go-jose.v2" +) + +// MockDiscoverStorage is a mock of DiscoverStorage interface. +type MockDiscoverStorage struct { + ctrl *gomock.Controller + recorder *MockDiscoverStorageMockRecorder +} + +// MockDiscoverStorageMockRecorder is the mock recorder for MockDiscoverStorage. +type MockDiscoverStorageMockRecorder struct { + mock *MockDiscoverStorage +} + +// NewMockDiscoverStorage creates a new mock instance. +func NewMockDiscoverStorage(ctrl *gomock.Controller) *MockDiscoverStorage { + mock := &MockDiscoverStorage{ctrl: ctrl} + mock.recorder = &MockDiscoverStorageMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDiscoverStorage) EXPECT() *MockDiscoverStorageMockRecorder { + return m.recorder +} + +// SignatureAlgorithms mocks base method. +func (m *MockDiscoverStorage) SignatureAlgorithms(arg0 context.Context) ([]jose.SignatureAlgorithm, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SignatureAlgorithms", arg0) + ret0, _ := ret[0].([]jose.SignatureAlgorithm) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SignatureAlgorithms indicates an expected call of SignatureAlgorithms. +func (mr *MockDiscoverStorageMockRecorder) SignatureAlgorithms(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignatureAlgorithms", reflect.TypeOf((*MockDiscoverStorage)(nil).SignatureAlgorithms), arg0) +} diff --git a/pkg/op/mock/generate.go b/pkg/op/mock/generate.go index c9c7efa..0066571 100644 --- a/pkg/op/mock/generate.go +++ b/pkg/op/mock/generate.go @@ -1,8 +1,9 @@ package mock -//go:generate mockgen -package mock -destination ./storage.mock.go github.com/zitadel/oidc/pkg/op Storage -//go:generate mockgen -package mock -destination ./authorizer.mock.go github.com/zitadel/oidc/pkg/op Authorizer -//go:generate mockgen -package mock -destination ./client.mock.go github.com/zitadel/oidc/pkg/op Client -//go:generate mockgen -package mock -destination ./configuration.mock.go github.com/zitadel/oidc/pkg/op Configuration -//go:generate mockgen -package mock -destination ./signer.mock.go github.com/zitadel/oidc/pkg/op Signer -//go:generate mockgen -package mock -destination ./key.mock.go github.com/zitadel/oidc/pkg/op KeyProvider +//go:generate mockgen -package mock -destination ./storage.mock.go github.com/zitadel/oidc/v2/pkg/op Storage +//go:generate mockgen -package mock -destination ./authorizer.mock.go github.com/zitadel/oidc/v2/pkg/op Authorizer +//go:generate mockgen -package mock -destination ./client.mock.go github.com/zitadel/oidc/v2/pkg/op Client +//go:generate mockgen -package mock -destination ./configuration.mock.go github.com/zitadel/oidc/v2/pkg/op Configuration +//go:generate mockgen -package mock -destination ./discovery.mock.go github.com/zitadel/oidc/v2/pkg/op DiscoverStorage +//go:generate mockgen -package mock -destination ./signer.mock.go github.com/zitadel/oidc/v2/pkg/op SigningKey,Key +//go:generate mockgen -package mock -destination ./key.mock.go github.com/zitadel/oidc/v2/pkg/op KeyProvider diff --git a/pkg/op/mock/key.mock.go b/pkg/op/mock/key.mock.go index 56d12dc..8831651 100644 --- a/pkg/op/mock/key.mock.go +++ b/pkg/op/mock/key.mock.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/op (interfaces: KeyProvider) +// Source: github.com/zitadel/oidc/v2/pkg/op (interfaces: KeyProvider) // Package mock is a generated GoMock package. package mock @@ -9,7 +9,7 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - jose "gopkg.in/square/go-jose.v2" + op "github.com/zitadel/oidc/v2/pkg/op" ) // MockKeyProvider is a mock of KeyProvider interface. @@ -35,17 +35,17 @@ func (m *MockKeyProvider) EXPECT() *MockKeyProviderMockRecorder { return m.recorder } -// GetKeySet mocks base method. -func (m *MockKeyProvider) GetKeySet(arg0 context.Context) (*jose.JSONWebKeySet, error) { +// KeySet mocks base method. +func (m *MockKeyProvider) KeySet(arg0 context.Context) ([]op.Key, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetKeySet", arg0) - ret0, _ := ret[0].(*jose.JSONWebKeySet) + ret := m.ctrl.Call(m, "KeySet", arg0) + ret0, _ := ret[0].([]op.Key) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetKeySet indicates an expected call of GetKeySet. -func (mr *MockKeyProviderMockRecorder) GetKeySet(arg0 interface{}) *gomock.Call { +// KeySet indicates an expected call of KeySet. +func (mr *MockKeyProviderMockRecorder) KeySet(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeySet", reflect.TypeOf((*MockKeyProvider)(nil).GetKeySet), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KeySet", reflect.TypeOf((*MockKeyProvider)(nil).KeySet), arg0) } diff --git a/pkg/op/mock/signer.mock.go b/pkg/op/mock/signer.mock.go index 42a92fb..78c0efe 100644 --- a/pkg/op/mock/signer.mock.go +++ b/pkg/op/mock/signer.mock.go @@ -1,56 +1,69 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/op (interfaces: Signer) +// Source: github.com/zitadel/oidc/v2/pkg/op (interfaces: SigningKey,Key) // Package mock is a generated GoMock package. package mock import ( - context "context" reflect "reflect" gomock "github.com/golang/mock/gomock" jose "gopkg.in/square/go-jose.v2" ) -// MockSigner is a mock of Signer interface. -type MockSigner struct { +// MockSigningKey is a mock of SigningKey interface. +type MockSigningKey struct { ctrl *gomock.Controller - recorder *MockSignerMockRecorder + recorder *MockSigningKeyMockRecorder } -// MockSignerMockRecorder is the mock recorder for MockSigner. -type MockSignerMockRecorder struct { - mock *MockSigner +// MockSigningKeyMockRecorder is the mock recorder for MockSigningKey. +type MockSigningKeyMockRecorder struct { + mock *MockSigningKey } -// NewMockSigner creates a new mock instance. -func NewMockSigner(ctrl *gomock.Controller) *MockSigner { - mock := &MockSigner{ctrl: ctrl} - mock.recorder = &MockSignerMockRecorder{mock} +// NewMockSigningKey creates a new mock instance. +func NewMockSigningKey(ctrl *gomock.Controller) *MockSigningKey { + mock := &MockSigningKey{ctrl: ctrl} + mock.recorder = &MockSigningKeyMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSigner) EXPECT() *MockSignerMockRecorder { +func (m *MockSigningKey) EXPECT() *MockSigningKeyMockRecorder { return m.recorder } -// Health mocks base method. -func (m *MockSigner) Health(arg0 context.Context) error { +// ID mocks base method. +func (m *MockSigningKey) ID() string { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Health", arg0) - ret0, _ := ret[0].(error) + ret := m.ctrl.Call(m, "ID") + ret0, _ := ret[0].(string) return ret0 } -// Health indicates an expected call of Health. -func (mr *MockSignerMockRecorder) Health(arg0 interface{}) *gomock.Call { +// ID indicates an expected call of ID. +func (mr *MockSigningKeyMockRecorder) ID() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Health", reflect.TypeOf((*MockSigner)(nil).Health), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockSigningKey)(nil).ID)) +} + +// Key mocks base method. +func (m *MockSigningKey) Key() interface{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Key") + ret0, _ := ret[0].(interface{}) + return ret0 +} + +// Key indicates an expected call of Key. +func (mr *MockSigningKeyMockRecorder) Key() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Key", reflect.TypeOf((*MockSigningKey)(nil).Key)) } // SignatureAlgorithm mocks base method. -func (m *MockSigner) SignatureAlgorithm() jose.SignatureAlgorithm { +func (m *MockSigningKey) SignatureAlgorithm() jose.SignatureAlgorithm { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SignatureAlgorithm") ret0, _ := ret[0].(jose.SignatureAlgorithm) @@ -58,21 +71,86 @@ func (m *MockSigner) SignatureAlgorithm() jose.SignatureAlgorithm { } // SignatureAlgorithm indicates an expected call of SignatureAlgorithm. -func (mr *MockSignerMockRecorder) SignatureAlgorithm() *gomock.Call { +func (mr *MockSigningKeyMockRecorder) SignatureAlgorithm() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignatureAlgorithm", reflect.TypeOf((*MockSigner)(nil).SignatureAlgorithm)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignatureAlgorithm", reflect.TypeOf((*MockSigningKey)(nil).SignatureAlgorithm)) } -// Signer mocks base method. -func (m *MockSigner) Signer() jose.Signer { +// MockKey is a mock of Key interface. +type MockKey struct { + ctrl *gomock.Controller + recorder *MockKeyMockRecorder +} + +// MockKeyMockRecorder is the mock recorder for MockKey. +type MockKeyMockRecorder struct { + mock *MockKey +} + +// NewMockKey creates a new mock instance. +func NewMockKey(ctrl *gomock.Controller) *MockKey { + mock := &MockKey{ctrl: ctrl} + mock.recorder = &MockKeyMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockKey) EXPECT() *MockKeyMockRecorder { + return m.recorder +} + +// Algorithm mocks base method. +func (m *MockKey) Algorithm() jose.SignatureAlgorithm { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Signer") - ret0, _ := ret[0].(jose.Signer) + ret := m.ctrl.Call(m, "Algorithm") + ret0, _ := ret[0].(jose.SignatureAlgorithm) return ret0 } -// Signer indicates an expected call of Signer. -func (mr *MockSignerMockRecorder) Signer() *gomock.Call { +// Algorithm indicates an expected call of Algorithm. +func (mr *MockKeyMockRecorder) Algorithm() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Signer", reflect.TypeOf((*MockSigner)(nil).Signer)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Algorithm", reflect.TypeOf((*MockKey)(nil).Algorithm)) +} + +// ID mocks base method. +func (m *MockKey) ID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ID") + ret0, _ := ret[0].(string) + return ret0 +} + +// ID indicates an expected call of ID. +func (mr *MockKeyMockRecorder) ID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockKey)(nil).ID)) +} + +// Key mocks base method. +func (m *MockKey) Key() interface{} { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Key") + ret0, _ := ret[0].(interface{}) + return ret0 +} + +// Key indicates an expected call of Key. +func (mr *MockKeyMockRecorder) Key() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Key", reflect.TypeOf((*MockKey)(nil).Key)) +} + +// Use mocks base method. +func (m *MockKey) Use() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Use") + ret0, _ := ret[0].(string) + return ret0 +} + +// Use indicates an expected call of Use. +func (mr *MockKeyMockRecorder) Use() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Use", reflect.TypeOf((*MockKey)(nil).Use)) } diff --git a/pkg/op/mock/storage.mock.go b/pkg/op/mock/storage.mock.go index 785a643..58cc2a0 100644 --- a/pkg/op/mock/storage.mock.go +++ b/pkg/op/mock/storage.mock.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/op (interfaces: Storage) +// Source: github.com/zitadel/oidc/v2/pkg/op (interfaces: Storage) // Package mock is a generated GoMock package. package mock @@ -10,8 +10,8 @@ import ( time "time" gomock "github.com/golang/mock/gomock" - oidc "github.com/zitadel/oidc/pkg/oidc" - op "github.com/zitadel/oidc/pkg/op" + oidc "github.com/zitadel/oidc/v2/pkg/oidc" + op "github.com/zitadel/oidc/v2/pkg/op" jose "gopkg.in/square/go-jose.v2" ) @@ -174,21 +174,6 @@ func (mr *MockStorageMockRecorder) GetKeyByIDAndUserID(arg0, arg1, arg2 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeyByIDAndUserID", reflect.TypeOf((*MockStorage)(nil).GetKeyByIDAndUserID), arg0, arg1, arg2) } -// GetKeySet mocks base method. -func (m *MockStorage) GetKeySet(arg0 context.Context) (*jose.JSONWebKeySet, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetKeySet", arg0) - ret0, _ := ret[0].(*jose.JSONWebKeySet) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetKeySet indicates an expected call of GetKeySet. -func (mr *MockStorageMockRecorder) GetKeySet(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeySet", reflect.TypeOf((*MockStorage)(nil).GetKeySet), arg0) -} - // GetPrivateClaimsFromScopes mocks base method. func (m *MockStorage) GetPrivateClaimsFromScopes(arg0 context.Context, arg1, arg2 string, arg3 []string) (map[string]interface{}, error) { m.ctrl.T.Helper() @@ -204,18 +189,6 @@ func (mr *MockStorageMockRecorder) GetPrivateClaimsFromScopes(arg0, arg1, arg2, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrivateClaimsFromScopes", reflect.TypeOf((*MockStorage)(nil).GetPrivateClaimsFromScopes), arg0, arg1, arg2, arg3) } -// GetSigningKey mocks base method. -func (m *MockStorage) GetSigningKey(arg0 context.Context, arg1 chan<- jose.SigningKey) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "GetSigningKey", arg0, arg1) -} - -// GetSigningKey indicates an expected call of GetSigningKey. -func (mr *MockStorageMockRecorder) GetSigningKey(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSigningKey", reflect.TypeOf((*MockStorage)(nil).GetSigningKey), arg0, arg1) -} - // Health mocks base method. func (m *MockStorage) Health(arg0 context.Context) error { m.ctrl.T.Helper() @@ -230,6 +203,21 @@ func (mr *MockStorageMockRecorder) Health(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Health", reflect.TypeOf((*MockStorage)(nil).Health), arg0) } +// KeySet mocks base method. +func (m *MockStorage) KeySet(arg0 context.Context) ([]op.Key, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "KeySet", arg0) + ret0, _ := ret[0].([]op.Key) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// KeySet indicates an expected call of KeySet. +func (mr *MockStorageMockRecorder) KeySet(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KeySet", reflect.TypeOf((*MockStorage)(nil).KeySet), arg0) +} + // RevokeToken mocks base method. func (m *MockStorage) RevokeToken(arg0 context.Context, arg1, arg2, arg3 string) *oidc.Error { m.ctrl.T.Helper() @@ -300,6 +288,36 @@ func (mr *MockStorageMockRecorder) SetUserinfoFromToken(arg0, arg1, arg2, arg3, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUserinfoFromToken", reflect.TypeOf((*MockStorage)(nil).SetUserinfoFromToken), arg0, arg1, arg2, arg3, arg4) } +// SignatureAlgorithms mocks base method. +func (m *MockStorage) SignatureAlgorithms(arg0 context.Context) ([]jose.SignatureAlgorithm, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SignatureAlgorithms", arg0) + ret0, _ := ret[0].([]jose.SignatureAlgorithm) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SignatureAlgorithms indicates an expected call of SignatureAlgorithms. +func (mr *MockStorageMockRecorder) SignatureAlgorithms(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignatureAlgorithms", reflect.TypeOf((*MockStorage)(nil).SignatureAlgorithms), arg0) +} + +// SigningKey mocks base method. +func (m *MockStorage) SigningKey(arg0 context.Context) (op.SigningKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SigningKey", arg0) + ret0, _ := ret[0].(op.SigningKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SigningKey indicates an expected call of SigningKey. +func (mr *MockStorageMockRecorder) SigningKey(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SigningKey", reflect.TypeOf((*MockStorage)(nil).SigningKey), arg0) +} + // TerminateSession mocks base method. func (m *MockStorage) TerminateSession(arg0 context.Context, arg1, arg2 string) error { m.ctrl.T.Helper() diff --git a/pkg/op/mock/storage.mock.impl.go b/pkg/op/mock/storage.mock.impl.go index 946cee0..9269f89 100644 --- a/pkg/op/mock/storage.mock.impl.go +++ b/pkg/op/mock/storage.mock.impl.go @@ -6,13 +6,10 @@ import ( "testing" "time" - "github.com/zitadel/oidc/pkg/oidc" - - "gopkg.in/square/go-jose.v2" - "github.com/golang/mock/gomock" - "github.com/zitadel/oidc/pkg/op" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" ) func NewStorage(t *testing.T) op.Storage { @@ -41,13 +38,13 @@ func NewMockStorageAny(t *testing.T) op.Storage { func NewMockStorageSigningKeyInvalid(t *testing.T) op.Storage { m := NewStorage(t) - ExpectSigningKeyInvalid(m) + //ExpectSigningKeyInvalid(m) return m } func NewMockStorageSigningKey(t *testing.T) op.Storage { m := NewStorage(t) - ExpectSigningKey(m) + //ExpectSigningKey(m) return m } @@ -85,24 +82,6 @@ func ExpectValidClientID(s op.Storage) { }) } -func ExpectSigningKeyInvalid(s op.Storage) { - mockS := s.(*MockStorage) - mockS.EXPECT().GetSigningKey(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, keyCh chan<- jose.SigningKey) { - keyCh <- jose.SigningKey{} - }, - ) -} - -func ExpectSigningKey(s op.Storage) { - mockS := s.(*MockStorage) - mockS.EXPECT().GetSigningKey(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, keyCh chan<- jose.SigningKey) { - keyCh <- jose.SigningKey{Algorithm: jose.HS256, Key: []byte("key")} - }, - ) -} - type ConfClient struct { id string appType op.ApplicationType diff --git a/pkg/op/op.go b/pkg/op/op.go index d85dcd6..acedcb6 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -12,8 +12,8 @@ import ( "golang.org/x/text/language" "gopkg.in/square/go-jose.v2" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) const ( @@ -29,78 +29,79 @@ const ( defaultKeysEndpoint = "keys" ) -var DefaultEndpoints = &endpoints{ - Authorization: NewEndpoint(defaultAuthorizationEndpoint), - Token: NewEndpoint(defaultTokenEndpoint), - Introspection: NewEndpoint(defaultIntrospectEndpoint), - Userinfo: NewEndpoint(defaultUserinfoEndpoint), - Revocation: NewEndpoint(defaultRevocationEndpoint), - EndSession: NewEndpoint(defaultEndSessionEndpoint), - JwksURI: NewEndpoint(defaultKeysEndpoint), -} +var ( + DefaultEndpoints = &endpoints{ + Authorization: NewEndpoint(defaultAuthorizationEndpoint), + Token: NewEndpoint(defaultTokenEndpoint), + Introspection: NewEndpoint(defaultIntrospectEndpoint), + Userinfo: NewEndpoint(defaultUserinfoEndpoint), + Revocation: NewEndpoint(defaultRevocationEndpoint), + EndSession: NewEndpoint(defaultEndSessionEndpoint), + JwksURI: NewEndpoint(defaultKeysEndpoint), + } + + defaultCORSOptions = cors.Options{ + AllowCredentials: true, + AllowedHeaders: []string{ + "Origin", + "Accept", + "Accept-Language", + "Authorization", + "Content-Type", + "X-Requested-With", + }, + AllowedMethods: []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + }, + ExposedHeaders: []string{ + "Location", + "Content-Length", + }, + AllowOriginFunc: func(_ string) bool { + return true + }, + } +) type OpenIDProvider interface { Configuration Storage() Storage Decoder() httphelper.Decoder Encoder() httphelper.Encoder - IDTokenHintVerifier() IDTokenHintVerifier - AccessTokenVerifier() AccessTokenVerifier + IDTokenHintVerifier(context.Context) IDTokenHintVerifier + AccessTokenVerifier(context.Context) AccessTokenVerifier Crypto() Crypto DefaultLogoutRedirectURI() string - Signer() Signer Probes() []ProbesFn HttpHandler() http.Handler } type HttpInterceptor func(http.Handler) http.Handler -var defaultCORSOptions = cors.Options{ - AllowCredentials: true, - AllowedHeaders: []string{ - "Origin", - "Accept", - "Accept-Language", - "Authorization", - "Content-Type", - "X-Requested-With", - }, - AllowedMethods: []string{ - http.MethodGet, - http.MethodHead, - http.MethodPost, - }, - ExposedHeaders: []string{ - "Location", - "Content-Length", - }, - AllowOriginFunc: func(_ string) bool { - return true - }, -} - func CreateRouter(o OpenIDProvider, interceptors ...HttpInterceptor) *mux.Router { - intercept := buildInterceptor(interceptors...) router := mux.NewRouter() router.Use(cors.New(defaultCORSOptions).Handler) + router.Use(intercept(o.IssuerFromRequest, interceptors...)) router.HandleFunc(healthEndpoint, healthHandler) router.HandleFunc(readinessEndpoint, readyHandler(o.Probes())) - router.HandleFunc(oidc.DiscoveryEndpoint, discoveryHandler(o, o.Signer())) - router.Handle(o.AuthorizationEndpoint().Relative(), intercept(authorizeHandler(o))) - router.NewRoute().Path(authCallbackPath(o)).Queries("id", "{id}").Handler(intercept(authorizeCallbackHandler(o))) - router.Handle(o.TokenEndpoint().Relative(), intercept(tokenHandler(o))) + router.HandleFunc(oidc.DiscoveryEndpoint, discoveryHandler(o, o.Storage())) + router.HandleFunc(o.AuthorizationEndpoint().Relative(), authorizeHandler(o)) + router.NewRoute().Path(authCallbackPath(o)).Queries("id", "{id}").HandlerFunc(authorizeCallbackHandler(o)) + router.HandleFunc(o.TokenEndpoint().Relative(), tokenHandler(o)) router.HandleFunc(o.IntrospectionEndpoint().Relative(), introspectionHandler(o)) router.HandleFunc(o.UserinfoEndpoint().Relative(), userinfoHandler(o)) router.HandleFunc(o.RevocationEndpoint().Relative(), revocationHandler(o)) - router.Handle(o.EndSessionEndpoint().Relative(), intercept(endSessionHandler(o))) + router.HandleFunc(o.EndSessionEndpoint().Relative(), endSessionHandler(o)) router.HandleFunc(o.KeysEndpoint().Relative(), keysHandler(o.Storage())) return router } // AuthCallbackURL builds the url for the redirect (with the requestID) after a successful login -func AuthCallbackURL(o OpenIDProvider) func(string) string { - return func(requestID string) string { - return o.AuthorizationEndpoint().Absolute(o.Issuer()) + authCallbackPathSuffix + "?id=" + requestID +func AuthCallbackURL(o OpenIDProvider) func(context.Context, string) string { + return func(ctx context.Context, requestID string) string { + return o.AuthorizationEndpoint().Absolute(IssuerFromContext(ctx)) + authCallbackPathSuffix + "?id=" + requestID } } @@ -109,7 +110,6 @@ func authCallbackPath(o OpenIDProvider) string { } type Config struct { - Issuer string CryptoKey [32]byte DefaultLogoutRedirectURI string CodeMethodS256 bool @@ -133,29 +133,34 @@ type endpoints struct { // NewOpenIDProvider creates a provider. The provider provides (with HttpHandler()) // a http.Router that handles a suite of endpoints (some paths can be overridden): -// /healthz -// /ready -// /.well-known/openid-configuration -// /oauth/token -// /oauth/introspect -// /callback -// /authorize -// /userinfo -// /revoke -// /end_session -// /keys +// +// /healthz +// /ready +// /.well-known/openid-configuration +// /oauth/token +// /oauth/introspect +// /callback +// /authorize +// /userinfo +// /revoke +// /end_session +// /keys +// // This does not include login. Login is handled with a redirect that includes the // request ID. The redirect for logins is specified per-client by Client.LoginURL(). // Successful logins should mark the request as authorized and redirect back to to // op.AuthCallbackURL(provider) which is probably /callback. On the redirect back // to the AuthCallbackURL, the request id should be passed as the "id" parameter. -func NewOpenIDProvider(ctx context.Context, config *Config, storage Storage, opOpts ...Option) (OpenIDProvider, error) { - err := ValidateIssuer(config.Issuer) - if err != nil { - return nil, err - } +func NewOpenIDProvider(ctx context.Context, issuer string, config *Config, storage Storage, opOpts ...Option) (*Provider, error) { + return newProvider(ctx, config, storage, StaticIssuer(issuer), opOpts...) +} - o := &openidProvider{ +func NewDynamicOpenIDProvider(ctx context.Context, path string, config *Config, storage Storage, opOpts ...Option) (*Provider, error) { + return newProvider(ctx, config, storage, IssuerFromHost(path), opOpts...) +} + +func newProvider(ctx context.Context, config *Config, storage Storage, issuer func(bool) (IssuerFromRequest, error), opOpts ...Option) (_ *Provider, err error) { + o := &Provider{ config: config, storage: storage, endpoints: DefaultEndpoints, @@ -168,9 +173,10 @@ func NewOpenIDProvider(ctx context.Context, config *Config, storage Storage, opO } } - keyCh := make(chan jose.SigningKey) - go storage.GetSigningKey(ctx, keyCh) - o.signer = NewSigner(ctx, storage, keyCh) + o.issuer, err = issuer(o.insecure) + if err != nil { + return nil, err + } o.httpHandler = CreateRouter(o, o.interceptors...) @@ -182,22 +188,17 @@ func NewOpenIDProvider(ctx context.Context, config *Config, storage Storage, opO o.crypto = NewAESCrypto(config.CryptoKey) // Avoid potential race conditions by calling these early - _ = o.AccessTokenVerifier() // sets accessTokenVerifier - _ = o.IDTokenHintVerifier() // sets idTokenHintVerifier - _ = o.JWTProfileVerifier() // sets jwtProfileVerifier - _ = o.openIDKeySet() // sets keySet + _ = o.openIDKeySet() // sets keySet return o, nil } -type openidProvider struct { +type Provider struct { config *Config + issuer IssuerFromRequest + insecure bool endpoints *endpoints storage Storage - signer Signer - idTokenHintVerifier IDTokenHintVerifier - jwtProfileVerifier JWTProfileVerifier - accessTokenVerifier AccessTokenVerifier keySet *openIDKeySet crypto Crypto httpHandler http.Handler @@ -209,159 +210,149 @@ type openidProvider struct { idTokenHintVerifierOpts []IDTokenHintVerifierOpt } -func (o *openidProvider) Issuer() string { - return o.config.Issuer +func (o *Provider) IssuerFromRequest(r *http.Request) string { + return o.issuer(r) } -func (o *openidProvider) AuthorizationEndpoint() Endpoint { +func (o *Provider) Insecure() bool { + return o.insecure +} + +func (o *Provider) AuthorizationEndpoint() Endpoint { return o.endpoints.Authorization } -func (o *openidProvider) TokenEndpoint() Endpoint { +func (o *Provider) TokenEndpoint() Endpoint { return o.endpoints.Token } -func (o *openidProvider) IntrospectionEndpoint() Endpoint { +func (o *Provider) IntrospectionEndpoint() Endpoint { return o.endpoints.Introspection } -func (o *openidProvider) UserinfoEndpoint() Endpoint { +func (o *Provider) UserinfoEndpoint() Endpoint { return o.endpoints.Userinfo } -func (o *openidProvider) RevocationEndpoint() Endpoint { +func (o *Provider) RevocationEndpoint() Endpoint { return o.endpoints.Revocation } -func (o *openidProvider) EndSessionEndpoint() Endpoint { +func (o *Provider) EndSessionEndpoint() Endpoint { return o.endpoints.EndSession } -func (o *openidProvider) KeysEndpoint() Endpoint { +func (o *Provider) KeysEndpoint() Endpoint { return o.endpoints.JwksURI } -func (o *openidProvider) AuthMethodPostSupported() bool { +func (o *Provider) AuthMethodPostSupported() bool { return o.config.AuthMethodPost } -func (o *openidProvider) CodeMethodS256Supported() bool { +func (o *Provider) CodeMethodS256Supported() bool { return o.config.CodeMethodS256 } -func (o *openidProvider) AuthMethodPrivateKeyJWTSupported() bool { +func (o *Provider) AuthMethodPrivateKeyJWTSupported() bool { return o.config.AuthMethodPrivateKeyJWT } -func (o *openidProvider) TokenEndpointSigningAlgorithmsSupported() []string { +func (o *Provider) TokenEndpointSigningAlgorithmsSupported() []string { return []string{"RS256"} } -func (o *openidProvider) GrantTypeRefreshTokenSupported() bool { +func (o *Provider) GrantTypeRefreshTokenSupported() bool { return o.config.GrantTypeRefreshToken } -func (o *openidProvider) GrantTypeTokenExchangeSupported() bool { +func (o *Provider) GrantTypeTokenExchangeSupported() bool { return false } -func (o *openidProvider) GrantTypeJWTAuthorizationSupported() bool { +func (o *Provider) GrantTypeJWTAuthorizationSupported() bool { return true } -func (o *openidProvider) GrantTypeClientCredentialsSupported() bool { +func (o *Provider) IntrospectionAuthMethodPrivateKeyJWTSupported() bool { + return true +} + +func (o *Provider) IntrospectionEndpointSigningAlgorithmsSupported() []string { + return []string{"RS256"} +} + +func (o *Provider) GrantTypeClientCredentialsSupported() bool { _, ok := o.storage.(ClientCredentialsStorage) return ok } -func (o *openidProvider) IntrospectionAuthMethodPrivateKeyJWTSupported() bool { +func (o *Provider) RevocationAuthMethodPrivateKeyJWTSupported() bool { return true } -func (o *openidProvider) IntrospectionEndpointSigningAlgorithmsSupported() []string { +func (o *Provider) RevocationEndpointSigningAlgorithmsSupported() []string { return []string{"RS256"} } -func (o *openidProvider) RevocationAuthMethodPrivateKeyJWTSupported() bool { - return true -} - -func (o *openidProvider) RevocationEndpointSigningAlgorithmsSupported() []string { - return []string{"RS256"} -} - -func (o *openidProvider) RequestObjectSupported() bool { +func (o *Provider) RequestObjectSupported() bool { return o.config.RequestObjectSupported } -func (o *openidProvider) RequestObjectSigningAlgorithmsSupported() []string { +func (o *Provider) RequestObjectSigningAlgorithmsSupported() []string { return []string{"RS256"} } -func (o *openidProvider) SupportedUILocales() []language.Tag { +func (o *Provider) SupportedUILocales() []language.Tag { return o.config.SupportedUILocales } -func (o *openidProvider) Storage() Storage { +func (o *Provider) Storage() Storage { return o.storage } -func (o *openidProvider) Decoder() httphelper.Decoder { +func (o *Provider) Decoder() httphelper.Decoder { return o.decoder } -func (o *openidProvider) Encoder() httphelper.Encoder { +func (o *Provider) Encoder() httphelper.Encoder { return o.encoder } -func (o *openidProvider) IDTokenHintVerifier() IDTokenHintVerifier { - if o.idTokenHintVerifier == nil { - o.idTokenHintVerifier = NewIDTokenHintVerifier(o.Issuer(), o.openIDKeySet(), o.idTokenHintVerifierOpts...) - } - return o.idTokenHintVerifier +func (o *Provider) IDTokenHintVerifier(ctx context.Context) IDTokenHintVerifier { + return NewIDTokenHintVerifier(IssuerFromContext(ctx), o.openIDKeySet(), o.idTokenHintVerifierOpts...) } -func (o *openidProvider) JWTProfileVerifier() JWTProfileVerifier { - if o.jwtProfileVerifier == nil { - o.jwtProfileVerifier = NewJWTProfileVerifier(o.Storage(), o.Issuer(), 1*time.Hour, time.Second) - } - return o.jwtProfileVerifier +func (o *Provider) JWTProfileVerifier(ctx context.Context) JWTProfileVerifier { + return NewJWTProfileVerifier(o.Storage(), IssuerFromContext(ctx), 1*time.Hour, time.Second) } -func (o *openidProvider) AccessTokenVerifier() AccessTokenVerifier { - if o.accessTokenVerifier == nil { - o.accessTokenVerifier = NewAccessTokenVerifier(o.Issuer(), o.openIDKeySet(), o.accessTokenVerifierOpts...) - } - return o.accessTokenVerifier +func (o *Provider) AccessTokenVerifier(ctx context.Context) AccessTokenVerifier { + return NewAccessTokenVerifier(IssuerFromContext(ctx), o.openIDKeySet(), o.accessTokenVerifierOpts...) } -func (o *openidProvider) openIDKeySet() oidc.KeySet { +func (o *Provider) openIDKeySet() oidc.KeySet { if o.keySet == nil { o.keySet = &openIDKeySet{o.Storage()} } return o.keySet } -func (o *openidProvider) Crypto() Crypto { +func (o *Provider) Crypto() Crypto { return o.crypto } -func (o *openidProvider) DefaultLogoutRedirectURI() string { +func (o *Provider) DefaultLogoutRedirectURI() string { return o.config.DefaultLogoutRedirectURI } -func (o *openidProvider) Signer() Signer { - return o.signer -} - -func (o *openidProvider) Probes() []ProbesFn { +func (o *Provider) Probes() []ProbesFn { return []ProbesFn{ - ReadySigner(o.Signer()), ReadyStorage(o.Storage()), } } -func (o *openidProvider) HttpHandler() http.Handler { +func (o *Provider) HttpHandler() http.Handler { return o.httpHandler } @@ -372,22 +363,31 @@ type openIDKeySet struct { // VerifySignature implements the oidc.KeySet interface // providing an implementation for the keys stored in the OP Storage interface func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) ([]byte, error) { - keySet, err := o.Storage.GetKeySet(ctx) + keySet, err := o.Storage.KeySet(ctx) if err != nil { return nil, fmt.Errorf("error fetching keys: %w", err) } keyID, alg := oidc.GetKeyIDAndAlg(jws) - key, err := oidc.FindMatchingKey(keyID, oidc.KeyUseSignature, alg, keySet.Keys...) + key, err := oidc.FindMatchingKey(keyID, oidc.KeyUseSignature, alg, jsonWebKeySet(keySet).Keys...) if err != nil { return nil, fmt.Errorf("invalid signature: %w", err) } return jws.Verify(&key) } -type Option func(o *openidProvider) error +type Option func(o *Provider) error + +// WithAllowInsecure allows the use of http (instead of https) for issuers +// this is not recommended for production use and violates the OIDC specification +func WithAllowInsecure() Option { + return func(o *Provider) error { + o.insecure = true + return nil + } +} func WithCustomAuthEndpoint(endpoint Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { if err := endpoint.Validate(); err != nil { return err } @@ -397,7 +397,7 @@ func WithCustomAuthEndpoint(endpoint Endpoint) Option { } func WithCustomTokenEndpoint(endpoint Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { if err := endpoint.Validate(); err != nil { return err } @@ -407,7 +407,7 @@ func WithCustomTokenEndpoint(endpoint Endpoint) Option { } func WithCustomIntrospectionEndpoint(endpoint Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { if err := endpoint.Validate(); err != nil { return err } @@ -417,7 +417,7 @@ func WithCustomIntrospectionEndpoint(endpoint Endpoint) Option { } func WithCustomUserinfoEndpoint(endpoint Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { if err := endpoint.Validate(); err != nil { return err } @@ -427,7 +427,7 @@ func WithCustomUserinfoEndpoint(endpoint Endpoint) Option { } func WithCustomRevocationEndpoint(endpoint Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { if err := endpoint.Validate(); err != nil { return err } @@ -437,7 +437,7 @@ func WithCustomRevocationEndpoint(endpoint Endpoint) Option { } func WithCustomEndSessionEndpoint(endpoint Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { if err := endpoint.Validate(); err != nil { return err } @@ -447,7 +447,7 @@ func WithCustomEndSessionEndpoint(endpoint Endpoint) Option { } func WithCustomKeysEndpoint(endpoint Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { if err := endpoint.Validate(); err != nil { return err } @@ -457,7 +457,7 @@ func WithCustomKeysEndpoint(endpoint Endpoint) Option { } func WithCustomEndpoints(auth, token, userInfo, revocation, endSession, keys Endpoint) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { o.endpoints.Authorization = auth o.endpoints.Token = token o.endpoints.Userinfo = userInfo @@ -469,38 +469,32 @@ func WithCustomEndpoints(auth, token, userInfo, revocation, endSession, keys End } func WithHttpInterceptors(interceptors ...HttpInterceptor) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { o.interceptors = append(o.interceptors, interceptors...) return nil } } func WithAccessTokenVerifierOpts(opts ...AccessTokenVerifierOpt) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { o.accessTokenVerifierOpts = opts return nil } } func WithIDTokenHintVerifierOpts(opts ...IDTokenHintVerifierOpt) Option { - return func(o *openidProvider) error { + return func(o *Provider) error { o.idTokenHintVerifierOpts = opts return nil } } -func buildInterceptor(interceptors ...HttpInterceptor) func(http.HandlerFunc) http.Handler { - return func(handlerFunc http.HandlerFunc) http.Handler { - handler := handlerFuncToHandler(handlerFunc) +func intercept(i IssuerFromRequest, interceptors ...HttpInterceptor) func(handler http.Handler) http.Handler { + issuerInterceptor := NewIssuerInterceptor(i) + return func(handler http.Handler) http.Handler { for i := len(interceptors) - 1; i >= 0; i-- { handler = interceptors[i](handler) } - return handler + return cors.New(defaultCORSOptions).Handler(issuerInterceptor.Handler(handler)) } } - -func handlerFuncToHandler(handlerFunc http.HandlerFunc) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handlerFunc(w, r) - }) -} diff --git a/pkg/op/probes.go b/pkg/op/probes.go index 7b80fb4..a56c92b 100644 --- a/pkg/op/probes.go +++ b/pkg/op/probes.go @@ -5,7 +5,7 @@ import ( "errors" "net/http" - httphelper "github.com/zitadel/oidc/pkg/http" + httphelper "github.com/zitadel/oidc/v2/pkg/http" ) type ProbesFn func(context.Context) error @@ -31,15 +31,6 @@ func Readiness(w http.ResponseWriter, r *http.Request, probes ...ProbesFn) { ok(w) } -func ReadySigner(s Signer) ProbesFn { - return func(ctx context.Context) error { - if s == nil { - return errors.New("no signer") - } - return s.Health(ctx) - } -} - func ReadyStorage(s Storage) ProbesFn { return func(ctx context.Context) error { if s == nil { diff --git a/pkg/op/session.go b/pkg/op/session.go index c4984fc..e1cc595 100644 --- a/pkg/op/session.go +++ b/pkg/op/session.go @@ -5,14 +5,14 @@ import ( "net/http" "net/url" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type SessionEnder interface { Decoder() httphelper.Decoder Storage() Storage - IDTokenHintVerifier() IDTokenHintVerifier + IDTokenHintVerifier(context.Context) IDTokenHintVerifier DefaultLogoutRedirectURI() string } @@ -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()) + claims, err := VerifyIDTokenHint(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/signer.go b/pkg/op/signer.go index 828876e..22ef8ca 100644 --- a/pkg/op/signer.go +++ b/pkg/op/signer.go @@ -1,88 +1,38 @@ package op import ( - "context" "errors" - "sync" - "github.com/zitadel/logging" "gopkg.in/square/go-jose.v2" ) -type Signer interface { - Health(ctx context.Context) error - Signer() jose.Signer +var ( + ErrSignerCreationFailed = errors.New("signer creation failed") +) + +type SigningKey interface { SignatureAlgorithm() jose.SignatureAlgorithm + Key() interface{} + ID() string } -type tokenSigner struct { - signer jose.Signer - storage AuthStorage - alg jose.SignatureAlgorithm - lock sync.RWMutex -} - -func NewSigner(ctx context.Context, storage AuthStorage, keyCh <-chan jose.SigningKey) Signer { - s := &tokenSigner{ - storage: storage, - } - - select { - case <-ctx.Done(): - return nil - case key := <-keyCh: - s.exchangeSigningKey(key) - } - go s.refreshSigningKey(ctx, keyCh) - - return s -} - -func (s *tokenSigner) Health(_ context.Context) error { - if s.signer == nil { - return errors.New("no signer") - } - if string(s.alg) == "" { - return errors.New("no signing algorithm") - } - return nil -} - -func (s *tokenSigner) Signer() jose.Signer { - s.lock.RLock() - defer s.lock.RUnlock() - return s.signer -} - -func (s *tokenSigner) refreshSigningKey(ctx context.Context, keyCh <-chan jose.SigningKey) { - for { - select { - case <-ctx.Done(): - return - case key := <-keyCh: - s.exchangeSigningKey(key) - } - } -} - -func (s *tokenSigner) exchangeSigningKey(key jose.SigningKey) { - s.lock.Lock() - defer s.lock.Unlock() - s.alg = key.Algorithm - if key.Algorithm == "" || key.Key == nil { - s.signer = nil - logging.Warn("signer has no key") - return - } - var err error - s.signer, err = jose.NewSigner(key, &jose.SignerOptions{}) +func SignerFromKey(key SigningKey) (jose.Signer, error) { + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: key.SignatureAlgorithm(), + Key: &jose.JSONWebKey{ + Key: key.Key(), + KeyID: key.ID(), + }, + }, &jose.SignerOptions{}) if err != nil { - logging.New().WithError(err).Error("error creating signer") - return + return nil, ErrSignerCreationFailed //TODO: log / wrap error? } - logging.Info("signer exchanged signing key") + return signer, nil } -func (s *tokenSigner) SignatureAlgorithm() jose.SignatureAlgorithm { - return s.alg +type Key interface { + ID() string + Algorithm() jose.SignatureAlgorithm + Use() string + Key() interface{} } diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 153cd21..b040b72 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -7,7 +7,7 @@ import ( "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type AuthStorage interface { @@ -47,8 +47,14 @@ type AuthStorage interface { // tokenOrTokenID will be the refresh token, not its ID. RevokeToken(ctx context.Context, tokenOrTokenID string, userID string, clientID string) *oidc.Error - GetSigningKey(context.Context, chan<- jose.SigningKey) - GetKeySet(context.Context) (*jose.JSONWebKeySet, error) + SigningKey(context.Context) (SigningKey, error) + SignatureAlgorithms(context.Context) ([]jose.SignatureAlgorithm, error) + KeySet(context.Context) ([]Key, error) +} + +type ClientCredentialsStorage interface { + ClientCredentials(ctx context.Context, clientID, clientSecret string) (Client, error) + ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (TokenRequest, error) } // CanRefreshTokenInfo is an optional additional interface that Storage can support. @@ -62,10 +68,6 @@ type CanRefreshTokenInfo interface { var ErrInvalidRefreshToken = errors.New("invalid_refresh_token") -type ClientCredentialsStorage interface { - ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (TokenRequest, error) -} - type OPStorage interface { GetClientByClientID(ctx context.Context, clientID string) (Client, error) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error @@ -80,6 +82,12 @@ type OPStorage interface { ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error) } +// JWTProfileTokenStorage is an additional, optional storage to implement +// implementing it, allows specifying the [AccessTokenType] of the access_token returned form the JWT Profile TokenRequest +type JWTProfileTokenStorage interface { + JWTProfileTokenType(ctx context.Context, request TokenRequest) (AccessTokenType, error) +} + // Storage is a required parameter for NewOpenIDProvider(). In addition to the // embedded interfaces below, if the passed Storage implements ClientCredentialsStorage // then the grant type "client_credentials" will be supported. In that case, the access diff --git a/pkg/op/token.go b/pkg/op/token.go index 3a72261..4d3e620 100644 --- a/pkg/op/token.go +++ b/pkg/op/token.go @@ -4,14 +4,12 @@ import ( "context" "time" - "github.com/zitadel/oidc/pkg/crypto" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/strings" + "github.com/zitadel/oidc/v2/pkg/crypto" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/strings" ) type TokenCreator interface { - Issuer() string - Signer() Signer Storage() Storage Crypto() Crypto } @@ -22,6 +20,13 @@ type TokenRequest interface { GetScopes() []string } +type AccessTokenClient interface { + GetID() string + ClockSkew() time.Duration + RestrictAdditionalAccessTokenScopes() func(scopes []string) []string + GrantTypes() []oidc.GrantType +} + func CreateTokenResponse(ctx context.Context, request IDTokenRequest, client Client, creator TokenCreator, createAccessToken bool, code, refreshToken string) (*oidc.AccessTokenResponse, error) { var accessToken, newRefreshToken string var validity time.Duration @@ -32,7 +37,7 @@ func CreateTokenResponse(ctx context.Context, request IDTokenRequest, client Cli return nil, err } } - idToken, err := CreateIDToken(ctx, creator.Issuer(), request, client.IDTokenLifetime(), accessToken, code, creator.Storage(), creator.Signer(), client) + idToken, err := CreateIDToken(ctx, IssuerFromContext(ctx), request, client.IDTokenLifetime(), accessToken, code, creator.Storage(), client) if err != nil { return nil, err } @@ -57,7 +62,7 @@ func CreateTokenResponse(ctx context.Context, request IDTokenRequest, client Cli }, nil } -func createTokens(ctx context.Context, tokenRequest TokenRequest, storage Storage, refreshToken string, client Client) (id, newRefreshToken string, exp time.Time, err error) { +func createTokens(ctx context.Context, tokenRequest TokenRequest, storage Storage, refreshToken string, client AccessTokenClient) (id, newRefreshToken string, exp time.Time, err error) { if needsRefreshToken(tokenRequest, client) { return storage.CreateAccessAndRefreshTokens(ctx, tokenRequest, refreshToken) } @@ -65,7 +70,7 @@ func createTokens(ctx context.Context, tokenRequest TokenRequest, storage Storag return } -func needsRefreshToken(tokenRequest TokenRequest, client Client) bool { +func needsRefreshToken(tokenRequest TokenRequest, client AccessTokenClient) bool { switch req := tokenRequest.(type) { case AuthRequest: return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && req.GetResponseType() == oidc.ResponseTypeCode && ValidateGrantType(client, oidc.GrantTypeRefreshToken) @@ -76,7 +81,7 @@ func needsRefreshToken(tokenRequest TokenRequest, client Client) bool { } } -func CreateAccessToken(ctx context.Context, tokenRequest TokenRequest, accessTokenType AccessTokenType, creator TokenCreator, client Client, refreshToken string) (accessToken, newRefreshToken string, validity time.Duration, err error) { +func CreateAccessToken(ctx context.Context, tokenRequest TokenRequest, accessTokenType AccessTokenType, creator TokenCreator, client AccessTokenClient, refreshToken string) (accessToken, newRefreshToken string, validity time.Duration, err error) { id, newRefreshToken, exp, err := createTokens(ctx, tokenRequest, creator.Storage(), refreshToken, client) if err != nil { return "", "", 0, err @@ -87,7 +92,7 @@ func CreateAccessToken(ctx context.Context, tokenRequest TokenRequest, accessTok } validity = exp.Add(clockSkew).Sub(time.Now().UTC()) if accessTokenType == AccessTokenTypeJWT { - accessToken, err = CreateJWT(ctx, creator.Issuer(), tokenRequest, exp, id, creator.Signer(), client, creator.Storage()) + accessToken, err = CreateJWT(ctx, IssuerFromContext(ctx), tokenRequest, exp, id, client, creator.Storage()) return } accessToken, err = CreateBearerToken(id, tokenRequest.GetSubject(), creator.Crypto()) @@ -98,7 +103,7 @@ func CreateBearerToken(tokenID, subject string, crypto Crypto) (string, error) { return crypto.Encrypt(tokenID + ":" + subject) } -func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, exp time.Time, id string, signer Signer, client Client, storage Storage) (string, error) { +func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, exp time.Time, id string, client AccessTokenClient, storage Storage) (string, error) { claims := oidc.NewAccessTokenClaims(issuer, tokenRequest.GetSubject(), tokenRequest.GetAudience(), exp, id, client.GetID(), client.ClockSkew()) if client != nil { restrictedScopes := client.RestrictAdditionalAccessTokenScopes()(tokenRequest.GetScopes()) @@ -108,7 +113,15 @@ func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, ex } claims.SetPrivateClaims(privateClaims) } - return crypto.Sign(claims, signer.Signer()) + signingKey, err := storage.SigningKey(ctx) + if err != nil { + return "", err + } + signer, err := SignerFromKey(signingKey) + if err != nil { + return "", err + } + return crypto.Sign(claims, signer) } type IDTokenRequest interface { @@ -120,7 +133,7 @@ type IDTokenRequest interface { GetSubject() string } -func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, validity time.Duration, accessToken, code string, storage Storage, signer Signer, client Client) (string, error) { +func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, validity time.Duration, accessToken, code string, storage Storage, client Client) (string, error) { exp := time.Now().UTC().Add(client.ClockSkew()).Add(validity) var acr, nonce string if authRequest, ok := request.(AuthRequest); ok { @@ -129,8 +142,12 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v } claims := oidc.NewIDTokenClaims(issuer, request.GetSubject(), request.GetAudience(), exp, request.GetAuthTime(), nonce, acr, request.GetAMR(), request.GetClientID(), client.ClockSkew()) scopes := client.RestrictAdditionalIdTokenScopes()(request.GetScopes()) + signingKey, err := storage.SigningKey(ctx) + if err != nil { + return "", err + } if accessToken != "" { - atHash, err := oidc.ClaimHash(accessToken, signer.SignatureAlgorithm()) + atHash, err := oidc.ClaimHash(accessToken, signingKey.SignatureAlgorithm()) if err != nil { return "", err } @@ -148,14 +165,17 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v claims.SetUserinfo(userInfo) } if code != "" { - codeHash, err := oidc.ClaimHash(code, signer.SignatureAlgorithm()) + codeHash, err := oidc.ClaimHash(code, signingKey.SignatureAlgorithm()) if err != nil { return "", err } claims.SetCodeHash(codeHash) } - - return crypto.Sign(claims, signer.Signer()) + signer, err := SignerFromKey(signingKey) + if err != nil { + return "", err + } + return crypto.Sign(claims, signer) } func removeUserinfoScopes(scopes []string) []string { diff --git a/pkg/op/token_client_credentials.go b/pkg/op/token_client_credentials.go index 3787667..fc31d57 100644 --- a/pkg/op/token_client_credentials.go +++ b/pkg/op/token_client_credentials.go @@ -5,8 +5,8 @@ import ( "net/http" "net/url" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) // ClientCredentialsExchange handles the OAuth 2.0 client_credentials grant, including @@ -63,15 +63,15 @@ func ParseClientCredentialsRequest(r *http.Request, decoder httphelper.Decoder) return request, nil } -// ValidateClientCredentialsRequest validates the refresh_token request parameters including authorization check of the client -// and returns the data representing the original auth request corresponding to the refresh_token +// ValidateClientCredentialsRequest validates the client_credentials request parameters including authorization check of the client +// and returns a TokenRequest and Client implementation to be used in the client_credentials response, resp. creation of the corresponding access_token. func ValidateClientCredentialsRequest(ctx context.Context, request *oidc.ClientCredentialsRequest, exchanger Exchanger) (TokenRequest, Client, error) { storage, ok := exchanger.Storage().(ClientCredentialsStorage) if !ok { return nil, nil, oidc.ErrUnsupportedGrantType().WithDescription("client_credentials grant not supported") } - client, err := AuthorizeClientCredentialsClient(ctx, request, exchanger) + client, err := AuthorizeClientCredentialsClient(ctx, request, storage) if err != nil { return nil, nil, err } @@ -84,12 +84,8 @@ func ValidateClientCredentialsRequest(ctx context.Context, request *oidc.ClientC return tokenRequest, client, nil } -func AuthorizeClientCredentialsClient(ctx context.Context, request *oidc.ClientCredentialsRequest, exchanger Exchanger) (Client, error) { - if err := AuthorizeClientIDSecret(ctx, request.ClientID, request.ClientSecret, exchanger.Storage()); err != nil { - return nil, err - } - - client, err := exchanger.Storage().GetClientByClientID(ctx, request.ClientID) +func AuthorizeClientCredentialsClient(ctx context.Context, request *oidc.ClientCredentialsRequest, storage ClientCredentialsStorage) (Client, error) { + client, err := storage.ClientCredentials(ctx, request.ClientID, request.ClientSecret) if err != nil { return nil, oidc.ErrInvalidClient().WithParent(err) } @@ -102,7 +98,7 @@ func AuthorizeClientCredentialsClient(ctx context.Context, request *oidc.ClientC } func CreateClientCredentialsTokenResponse(ctx context.Context, tokenRequest TokenRequest, creator TokenCreator, client Client) (*oidc.AccessTokenResponse, error) { - accessToken, _, validity, err := CreateAccessToken(ctx, tokenRequest, AccessTokenTypeJWT, creator, client, "") + accessToken, _, validity, err := CreateAccessToken(ctx, tokenRequest, client.AccessTokenType(), creator, client, "") if err != nil { return nil, err } diff --git a/pkg/op/token_code.go b/pkg/op/token_code.go index ec48233..565a477 100644 --- a/pkg/op/token_code.go +++ b/pkg/op/token_code.go @@ -4,8 +4,8 @@ import ( "context" "net/http" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) // CodeExchange handles the OAuth 2.0 authorization_code grant, including diff --git a/pkg/op/token_intospection.go b/pkg/op/token_intospection.go index f402c8b..dfc8954 100644 --- a/pkg/op/token_intospection.go +++ b/pkg/op/token_intospection.go @@ -1,24 +1,25 @@ package op import ( + "context" "errors" "net/http" "net/url" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type Introspector interface { Decoder() httphelper.Decoder Crypto() Crypto Storage() Storage - AccessTokenVerifier() AccessTokenVerifier + AccessTokenVerifier(context.Context) AccessTokenVerifier } type IntrospectorJWTProfile interface { Introspector - JWTProfileVerifier() JWTProfileVerifier + JWTProfileVerifier(context.Context) JWTProfileVerifier } func introspectionHandler(introspector Introspector) func(http.ResponseWriter, *http.Request) { @@ -62,7 +63,7 @@ func ParseTokenIntrospectionRequest(r *http.Request, introspector Introspector) return "", "", errors.New("unable to parse request") } if introspectorJWTProfile, ok := introspector.(IntrospectorJWTProfile); ok && req.ClientAssertion != "" { - profile, err := VerifyJWTAssertion(r.Context(), req.ClientAssertion, introspectorJWTProfile.JWTProfileVerifier()) + profile, err := VerifyJWTAssertion(r.Context(), req.ClientAssertion, introspectorJWTProfile.JWTProfileVerifier(r.Context())) if err == nil { return req.Token, profile.Issuer, nil } diff --git a/pkg/op/token_jwt_profile.go b/pkg/op/token_jwt_profile.go index eb21517..23bac9a 100644 --- a/pkg/op/token_jwt_profile.go +++ b/pkg/op/token_jwt_profile.go @@ -5,13 +5,13 @@ import ( "net/http" "time" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type JWTAuthorizationGrantExchanger interface { Exchanger - JWTProfileVerifier() JWTProfileVerifier + JWTProfileVerifier(context.Context) JWTProfileVerifier } // JWTProfile handles the OAuth 2.0 JWT Profile Authorization Grant https://tools.ietf.org/html/rfc7523#section-2.1 @@ -21,7 +21,7 @@ func JWTProfile(w http.ResponseWriter, r *http.Request, exchanger JWTAuthorizati RequestError(w, r, err) } - tokenRequest, err := VerifyJWTAssertion(r.Context(), profileRequest.Assertion, exchanger.JWTProfileVerifier()) + tokenRequest, err := VerifyJWTAssertion(r.Context(), profileRequest.Assertion, exchanger.JWTProfileVerifier(r.Context())) if err != nil { RequestError(w, r, err) return @@ -53,27 +53,65 @@ func ParseJWTProfileGrantRequest(r *http.Request, decoder httphelper.Decoder) (* return tokenReq, nil } -// CreateJWTTokenResponse creates +// CreateJWTTokenResponse creates an access_token response for a JWT Profile Grant request +// by default the access_token is an opaque string, but can be specified by implementing the JWTProfileTokenStorage interface func CreateJWTTokenResponse(ctx context.Context, tokenRequest TokenRequest, creator TokenCreator) (*oidc.AccessTokenResponse, error) { - id, exp, err := creator.Storage().CreateAccessToken(ctx, tokenRequest) - if err != nil { - return nil, err - } - accessToken, err := CreateBearerToken(id, tokenRequest.GetSubject(), creator.Crypto()) - if err != nil { - return nil, err + // return an opaque token as default to not break current implementations + tokenType := AccessTokenTypeBearer + + // the current CreateAccessToken function, esp. CreateJWT requires an implementation of an AccessTokenClient + client := &jwtProfileClient{ + id: tokenRequest.GetSubject(), } + // by implementing the JWTProfileTokenStorage the storage can specify the AccessTokenType to be returned + tokenStorage, ok := creator.Storage().(JWTProfileTokenStorage) + if ok { + var err error + tokenType, err = tokenStorage.JWTProfileTokenType(ctx, tokenRequest) + if err != nil { + return nil, err + } + } + + accessToken, _, validity, err := CreateAccessToken(ctx, tokenRequest, tokenType, creator, client, "") + if err != nil { + return nil, err + } return &oidc.AccessTokenResponse{ AccessToken: accessToken, TokenType: oidc.BearerToken, - ExpiresIn: uint64(exp.Sub(time.Now().UTC()).Seconds()), + ExpiresIn: uint64(validity.Seconds()), }, nil } // ParseJWTProfileRequest has been renamed to ParseJWTProfileGrantRequest // -//deprecated: use ParseJWTProfileGrantRequest +// deprecated: use ParseJWTProfileGrantRequest func ParseJWTProfileRequest(r *http.Request, decoder httphelper.Decoder) (*oidc.JWTProfileGrantRequest, error) { return ParseJWTProfileGrantRequest(r, decoder) } + +type jwtProfileClient struct { + id string +} + +func (j *jwtProfileClient) GetID() string { + return j.id +} + +func (j *jwtProfileClient) ClockSkew() time.Duration { + return 0 +} + +func (j *jwtProfileClient) RestrictAdditionalAccessTokenScopes() func(scopes []string) []string { + return func(scopes []string) []string { + return scopes + } +} + +func (j *jwtProfileClient) GrantTypes() []oidc.GrantType { + return []oidc.GrantType{ + oidc.GrantTypeBearer, + } +} diff --git a/pkg/op/token_refresh.go b/pkg/op/token_refresh.go index 7251eeb..148d2a4 100644 --- a/pkg/op/token_refresh.go +++ b/pkg/op/token_refresh.go @@ -6,9 +6,9 @@ import ( "net/http" "time" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" - "github.com/zitadel/oidc/pkg/strings" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/strings" ) type RefreshTokenRequest interface { diff --git a/pkg/op/token_request.go b/pkg/op/token_request.go index 6ccd489..190e812 100644 --- a/pkg/op/token_request.go +++ b/pkg/op/token_request.go @@ -5,15 +5,13 @@ import ( "net/http" "net/url" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type Exchanger interface { - Issuer() string Storage() Storage Decoder() httphelper.Decoder - Signer() Signer Crypto() Crypto AuthMethodPostSupported() bool AuthMethodPrivateKeyJWTSupported() bool @@ -122,7 +120,7 @@ func AuthorizeCodeChallenge(tokenReq *oidc.AccessTokenRequest, challenge *oidc.C // AuthorizePrivateJWTKey authorizes a client by validating the client_assertion's signature with a previously // registered public key (JWT Profile) func AuthorizePrivateJWTKey(ctx context.Context, clientAssertion string, exchanger JWTAuthorizationGrantExchanger) (Client, error) { - jwtReq, err := VerifyJWTAssertion(ctx, clientAssertion, exchanger.JWTProfileVerifier()) + jwtReq, err := VerifyJWTAssertion(ctx, clientAssertion, exchanger.JWTProfileVerifier(ctx)) if err != nil { return nil, err } @@ -136,8 +134,8 @@ func AuthorizePrivateJWTKey(ctx context.Context, clientAssertion string, exchang return client, nil } -// ValidateGrantType ensures that the requested grant_type is allowed by the Client -func ValidateGrantType(client Client, grantType oidc.GrantType) bool { +// ValidateGrantType ensures that the requested grant_type is allowed by the client +func ValidateGrantType(client interface{ GrantTypes() []oidc.GrantType }, grantType oidc.GrantType) bool { if client == nil { return false } diff --git a/pkg/op/token_revocation.go b/pkg/op/token_revocation.go index 9dd0295..7dbd4a7 100644 --- a/pkg/op/token_revocation.go +++ b/pkg/op/token_revocation.go @@ -7,22 +7,22 @@ import ( "net/url" "strings" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type Revoker interface { Decoder() httphelper.Decoder Crypto() Crypto Storage() Storage - AccessTokenVerifier() AccessTokenVerifier + AccessTokenVerifier(context.Context) AccessTokenVerifier AuthMethodPrivateKeyJWTSupported() bool AuthMethodPostSupported() bool } type RevokerJWTProfile interface { Revoker - JWTProfileVerifier() JWTProfileVerifier + JWTProfileVerifier(context.Context) JWTProfileVerifier } func revocationHandler(revoker Revoker) func(http.ResponseWriter, *http.Request) { @@ -87,7 +87,7 @@ func ParseTokenRevocationRequest(r *http.Request, revoker Revoker) (token, token if !ok || !revoker.AuthMethodPrivateKeyJWTSupported() { return "", "", "", oidc.ErrInvalidClient().WithDescription("auth_method private_key_jwt not supported") } - profile, err := VerifyJWTAssertion(r.Context(), req.ClientAssertion, revokerJWTProfile.JWTProfileVerifier()) + profile, err := VerifyJWTAssertion(r.Context(), req.ClientAssertion, revokerJWTProfile.JWTProfileVerifier(r.Context())) if err == nil { return req.Token, req.TokenTypeHint, profile.Issuer, nil } @@ -151,7 +151,7 @@ func getTokenIDAndSubjectForRevocation(ctx context.Context, userinfoProvider Use } return splitToken[0], splitToken[1], true } - accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier()) + accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) if err != nil { return "", "", false } diff --git a/pkg/op/userinfo.go b/pkg/op/userinfo.go index 4bd03e2..cb8f0ae 100644 --- a/pkg/op/userinfo.go +++ b/pkg/op/userinfo.go @@ -6,15 +6,15 @@ import ( "net/http" "strings" - httphelper "github.com/zitadel/oidc/pkg/http" - "github.com/zitadel/oidc/pkg/oidc" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type UserinfoProvider interface { Decoder() httphelper.Decoder Crypto() Crypto Storage() Storage - AccessTokenVerifier() AccessTokenVerifier + AccessTokenVerifier(context.Context) AccessTokenVerifier } func userinfoHandler(userinfoProvider UserinfoProvider) func(http.ResponseWriter, *http.Request) { @@ -81,7 +81,7 @@ func getTokenIDAndSubject(ctx context.Context, userinfoProvider UserinfoProvider } return splitToken[0], splitToken[1], true } - accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier()) + accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) if err != nil { return "", "", false } diff --git a/pkg/op/verifier_access_token.go b/pkg/op/verifier_access_token.go index 1729c23..1d53adb 100644 --- a/pkg/op/verifier_access_token.go +++ b/pkg/op/verifier_access_token.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type AccessTokenVerifier interface { diff --git a/pkg/op/verifier_id_token_hint.go b/pkg/op/verifier_id_token_hint.go index d36bbd8..9320106 100644 --- a/pkg/op/verifier_id_token_hint.go +++ b/pkg/op/verifier_id_token_hint.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type IDTokenHintVerifier interface { @@ -73,7 +73,7 @@ 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 +// 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() diff --git a/pkg/op/verifier_jwt_profile.go b/pkg/op/verifier_jwt_profile.go index 0215e84..9befb64 100644 --- a/pkg/op/verifier_jwt_profile.go +++ b/pkg/op/verifier_jwt_profile.go @@ -8,7 +8,7 @@ import ( "gopkg.in/square/go-jose.v2" - "github.com/zitadel/oidc/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/oidc" ) type JWTProfileVerifier interface { From 9291ca9908db696c5a8a4d71875f35f68f83dd45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 17 Feb 2023 14:42:02 +0200 Subject: [PATCH 03/21] rp/mock: update go generate package and type Fixes #281 --- pkg/client/rp/mock/generate.go | 2 +- pkg/client/rp/mock/verifier.mock.go | 168 ++++++++++++++++++++++------ 2 files changed, 133 insertions(+), 37 deletions(-) diff --git a/pkg/client/rp/mock/generate.go b/pkg/client/rp/mock/generate.go index 1e05701..7db81ea 100644 --- a/pkg/client/rp/mock/generate.go +++ b/pkg/client/rp/mock/generate.go @@ -1,3 +1,3 @@ package mock -//go:generate mockgen -package mock -destination ./verifier.mock.go github.com/zitadel/oidc/pkg/rp Verifier +//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 index 9d1daa1..eac6a79 100644 --- a/pkg/client/rp/mock/verifier.mock.go +++ b/pkg/client/rp/mock/verifier.mock.go @@ -1,67 +1,163 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/zitadel/oidc/pkg/rp (interfaces: Verifier) +// Source: github.com/zitadel/oidc/v2/pkg/client/rp (interfaces: IDTokenVerifier) // Package mock is a generated GoMock package. package mock import ( - "context" - "reflect" + context "context" + reflect "reflect" + time "time" - "github.com/golang/mock/gomock" - - "github.com/zitadel/oidc/v2/pkg/oidc" + gomock "github.com/golang/mock/gomock" + oidc "github.com/zitadel/oidc/v2/pkg/oidc" ) -// MockVerifier is a mock of Verifier interface -type MockVerifier struct { +// MockIDTokenVerifier is a mock of IDTokenVerifier interface. +type MockIDTokenVerifier struct { ctrl *gomock.Controller - recorder *MockVerifierMockRecorder + recorder *MockIDTokenVerifierMockRecorder } -// MockVerifierMockRecorder is the mock recorder for MockVerifier -type MockVerifierMockRecorder struct { - mock *MockVerifier +// MockIDTokenVerifierMockRecorder is the mock recorder for MockIDTokenVerifier. +type MockIDTokenVerifierMockRecorder struct { + mock *MockIDTokenVerifier } -// NewMockVerifier creates a new mock instance -func NewMockVerifier(ctrl *gomock.Controller) *MockVerifier { - mock := &MockVerifier{ctrl: ctrl} - mock.recorder = &MockVerifierMockRecorder{mock} +// 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 *MockVerifier) EXPECT() *MockVerifierMockRecorder { +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIDTokenVerifier) EXPECT() *MockIDTokenVerifierMockRecorder { return m.recorder } -// Verify mocks base method -func (m *MockVerifier) Verify(arg0 context.Context, arg1, arg2 string) (*oidc.IDTokenClaims, error) { +// ACR mocks base method. +func (m *MockIDTokenVerifier) ACR() oidc.ACRVerifier { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Verify", arg0, arg1, arg2) - ret0, _ := ret[0].(*oidc.IDTokenClaims) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret := m.ctrl.Call(m, "ACR") + ret0, _ := ret[0].(oidc.ACRVerifier) + return ret0 } -// Verify indicates an expected call of Verify -func (mr *MockVerifierMockRecorder) Verify(arg0, arg1, arg2 interface{}) *gomock.Call { +// 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, "Verify", reflect.TypeOf((*MockVerifier)(nil).Verify), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ACR", reflect.TypeOf((*MockIDTokenVerifier)(nil).ACR)) } -// VerifyIDToken mocks base method -func (m *MockVerifier) VerifyIDToken(arg0 context.Context, arg1 string) (*oidc.IDTokenClaims, error) { +// ClientID mocks base method. +func (m *MockIDTokenVerifier) ClientID() string { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "VerifyIDToken", arg0, arg1) - ret0, _ := ret[0].(*oidc.IDTokenClaims) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret := m.ctrl.Call(m, "ClientID") + ret0, _ := ret[0].(string) + return ret0 } -// VerifyIDToken indicates an expected call of VerifyIDToken -func (mr *MockVerifierMockRecorder) VerifyIDToken(arg0, arg1 interface{}) *gomock.Call { +// 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, "VerifyIDToken", reflect.TypeOf((*MockVerifier)(nil).VerifyIDToken), arg0, arg1) + 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)) } From 8e298791d7e6d0d6fdd475c6e2cb1fd94a9bfff7 Mon Sep 17 00:00:00 2001 From: Emil Bektimirov Date: Sun, 19 Feb 2023 14:57:46 +0100 Subject: [PATCH 04/21] feat: Token Exchange (RFC 8693) (#255) This change implements OAuth2 Token Exchange in OP according to RFC 8693 (and client code) Some implementation details: - OP parses and verifies subject/actor tokens natively if they were issued by OP - Third-party tokens verification is also possible by implementing additional storage interface - Token exchange can issue only OP's native tokens (id_token, access_token and refresh_token) with static issuer --- example/client/app/app.go | 25 ++ example/server/storage/client.go | 4 +- example/server/storage/oidc.go | 3 + example/server/storage/storage.go | 135 +++++++- example/server/storage/user.go | 15 + pkg/client/client.go | 15 + pkg/client/{rp => }/integration_test.go | 160 ++++++--- pkg/client/rs/resource_server.go | 5 + pkg/client/tokenexchange/tokenexchange.go | 127 +++++++ pkg/oidc/token.go | 15 + pkg/oidc/token_request.go | 40 ++- pkg/op/op.go | 3 +- pkg/op/storage.go | 43 +++ pkg/op/token.go | 32 +- pkg/op/token_exchange.go | 396 +++++++++++++++++++++- pkg/op/token_request.go | 2 + 16 files changed, 961 insertions(+), 59 deletions(-) rename pkg/client/{rp => }/integration_test.go (72%) create mode 100644 pkg/client/tokenexchange/tokenexchange.go diff --git a/example/client/app/app.go b/example/client/app/app.go index 3e5f19c..97e8948 100644 --- a/example/client/app/app.go +++ b/example/client/app/app.go @@ -80,6 +80,31 @@ func main() { // w.Write(data) //} + // you can also try token exchange flow + // + // requestTokenExchange := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { + // data := make(url.Values) + // data.Set("grant_type", string(oidc.GrantTypeTokenExchange)) + // data.Set("requested_token_type", string(oidc.IDTokenType)) + // data.Set("subject_token", tokens.RefreshToken) + // data.Set("subject_token_type", string(oidc.RefreshTokenType)) + // data.Add("scope", "profile custom_scope:impersonate:id2") + + // client := &http.Client{} + // r2, _ := http.NewRequest(http.MethodPost, issuer+"/oauth/token", strings.NewReader(data.Encode())) + // // r2.Header.Add("Authorization", "Basic "+"d2ViOnNlY3JldA==") + // r2.Header.Add("Content-Type", "application/x-www-form-urlencoded") + // r2.SetBasicAuth("web", "secret") + + // resp, _ := client.Do(r2) + // fmt.Println(resp.Status) + + // b, _ := io.ReadAll(resp.Body) + // resp.Body.Close() + + // w.Write(b) + // } + // register the CodeExchangeHandler at the callbackPath // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function // with the returned tokens from the token endpoint diff --git a/example/server/storage/client.go b/example/server/storage/client.go index bd6ff3c..5a5b33f 100644 --- a/example/server/storage/client.go +++ b/example/server/storage/client.go @@ -158,7 +158,7 @@ func NativeClient(id string, redirectURIs ...string) *Client { loginURL: defaultLoginURL, responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode}, grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken}, - accessTokenType: 0, + accessTokenType: op.AccessTokenTypeBearer, devMode: false, idTokenUserinfoClaimsAssertion: false, clockSkew: 0, @@ -184,7 +184,7 @@ func WebClient(id, secret string, redirectURIs ...string) *Client { loginURL: defaultLoginURL, responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode}, grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken}, - accessTokenType: 0, + accessTokenType: op.AccessTokenTypeBearer, devMode: false, idTokenUserinfoClaimsAssertion: false, clockSkew: 0, diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go index 505ab72..83db739 100644 --- a/example/server/storage/oidc.go +++ b/example/server/storage/oidc.go @@ -16,6 +16,9 @@ const ( // CustomClaim is an example for how to return custom claims with this library CustomClaim = "custom_claim" + + // CustomScopeImpersonatePrefix is an example scope prefix for passing user id to impersonate using token exchage + CustomScopeImpersonatePrefix = "custom_scope:impersonate:" ) type AuthRequest struct { diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 64bffc8..662132c 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -4,8 +4,10 @@ import ( "context" "crypto/rand" "crypto/rsa" + "errors" "fmt" "math/big" + "strings" "sync" "time" @@ -213,11 +215,14 @@ func (s *Storage) DeleteAuthRequest(ctx context.Context, id string) error { // it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...) func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) { var applicationID string - // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token - authReq, ok := request.(*AuthRequest) - if ok { - applicationID = authReq.ApplicationID + switch req := request.(type) { + case *AuthRequest: + // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token + applicationID = req.ApplicationID + case op.TokenExchangeRequest: + applicationID = req.GetClientID() } + token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes()) if err != nil { return "", time.Time{}, err @@ -228,6 +233,11 @@ func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest // CreateAccessAndRefreshTokens implements the op.Storage interface // it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request) func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { + // generate tokens via token exchange flow if request is relevant + if teReq, ok := request.(op.TokenExchangeRequest); ok { + return s.exchangeRefreshToken(ctx, teReq) + } + // get the information depending on the request type / implementation applicationID, authTime, amr := getInfoFromRequest(request) @@ -258,6 +268,24 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T return accessToken.ID, refreshToken, accessToken.Expiration, nil } +func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExchangeRequest) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { + applicationID := request.GetClientID() + authTime := request.GetAuthTime() + + refreshTokenID := uuid.NewString() + accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes()) + if err != nil { + return "", "", time.Time{}, err + } + + refreshToken, err := s.createRefreshToken(accessToken, nil, authTime) + if err != nil { + return "", "", time.Time{}, err + } + + return accessToken.ID, refreshToken, accessToken.Expiration, nil +} + // TokenRequestByRefreshToken implements the op.Storage interface // it will be called after parsing and validation of the refresh token request func (s *Storage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) { @@ -444,6 +472,10 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection o // GetPrivateClaimsFromScopes implements the op.Storage interface // it will be called for the creation of a JWT access token to assert claims for custom scopes func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { + return s.getPrivateClaimsFromScopes(ctx, userID, clientID, scopes) +} + +func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { for _, scope := range scopes { switch scope { case CustomScope: @@ -580,6 +612,101 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, return nil } +// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface +// it will be called to validate parsed Token Exchange Grant request +func (s *Storage) ValidateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error { + if request.GetRequestedTokenType() == "" { + request.SetRequestedTokenType(oidc.RefreshTokenType) + } + + // Just an example, some use cases might need this use case + if request.GetExchangeSubjectTokenType() == oidc.IDTokenType && request.GetRequestedTokenType() == oidc.RefreshTokenType { + return errors.New("exchanging id_token to refresh_token is not supported") + } + + // Check impersonation permissions + if request.GetExchangeActor() == "" && !s.userStore.GetUserByID(request.GetExchangeSubject()).IsAdmin { + return errors.New("user doesn't have impersonation permission") + } + + allowedScopes := make([]string, 0) + for _, scope := range request.GetScopes() { + if scope == oidc.ScopeAddress { + continue + } + + if strings.HasPrefix(scope, CustomScopeImpersonatePrefix) { + subject := strings.TrimPrefix(scope, CustomScopeImpersonatePrefix) + request.SetSubject(subject) + } + + allowedScopes = append(allowedScopes, scope) + } + + request.SetCurrentScopes(allowedScopes) + + return nil +} + +// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface +// Common use case is to store request for audit purposes. For this example we skip the storing. +func (s *Storage) CreateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error { + return nil +} + +// GetPrivateClaimsFromScopesForTokenExchange implements the op.TokenExchangeStorage interface +// it will be called for the creation of an exchanged JWT access token to assert claims for custom scopes +// plus adding token exchange specific claims related to delegation or impersonation +func (s *Storage) GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]interface{}, err error) { + claims, err = s.getPrivateClaimsFromScopes(ctx, "", request.GetClientID(), request.GetScopes()) + if err != nil { + return nil, err + } + + for k, v := range s.getTokenExchangeClaims(ctx, request) { + claims = appendClaim(claims, k, v) + } + + return claims, nil +} + +// 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 { + err := s.setUserinfo(ctx, userinfo, request.GetSubject(), request.GetClientID(), request.GetScopes()) + if err != nil { + return err + } + + for k, v := range s.getTokenExchangeClaims(ctx, request) { + userinfo.AppendClaims(k, v) + } + + return nil +} + +func (s *Storage) getTokenExchangeClaims(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]interface{}) { + for _, scope := range request.GetScopes() { + switch { + case strings.HasPrefix(scope, CustomScopeImpersonatePrefix) && request.GetExchangeActor() == "": + // Set actor subject claim for impersonation flow + claims = appendClaim(claims, "act", map[string]interface{}{ + "sub": request.GetExchangeSubject(), + }) + } + } + + // Set actor subject claim for delegation flow + // if request.GetExchangeActor() != "" { + // claims = appendClaim(claims, "act", map[string]interface{}{ + // "sub": request.GetExchangeActor(), + // }) + // } + + return claims +} + // getInfoFromRequest returns the clientID, authTime and amr depending on the op.TokenRequest type / implementation func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Time, amr []string) { authReq, ok := req.(*AuthRequest) // Code Flow (with scope offline_access) diff --git a/example/server/storage/user.go b/example/server/storage/user.go index 82c06d0..173daef 100644 --- a/example/server/storage/user.go +++ b/example/server/storage/user.go @@ -18,6 +18,7 @@ type User struct { Phone string PhoneVerified bool PreferredLanguage language.Tag + IsAdmin bool } type Service struct { @@ -49,6 +50,20 @@ func NewUserStore(issuer string) UserStore { Phone: "", PhoneVerified: false, PreferredLanguage: language.German, + IsAdmin: true, + }, + "id2": { + ID: "id2", + Username: "test-user2", + Password: "verysecure", + FirstName: "Test", + LastName: "User2", + Email: "test-user2@zitadel.ch", + EmailVerified: true, + Phone: "", + PhoneVerified: false, + PreferredLanguage: language.German, + IsAdmin: false, }, }, } diff --git a/pkg/client/client.go b/pkg/client/client.go index eaa1a80..077baf2 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -90,6 +90,9 @@ func CallEndSessionEndpoint(request interface{}, authFn interface{}, caller EndS return http.ErrUseLastResponse } resp, err := client.Do(req) + if err != nil { + return nil, err + } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 400 { body, err := io.ReadAll(resp.Body) @@ -148,6 +151,18 @@ func CallRevokeEndpoint(request interface{}, authFn interface{}, caller RevokeCa return nil } +func CallTokenExchangeEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) { + req, err := httphelper.FormRequest(caller.TokenEndpoint(), request, Encoder, authFn) + if err != nil { + return nil, err + } + tokenRes := new(oidc.TokenExchangeResponse) + if err := httphelper.HttpRequest(caller.HttpClient(), req, &tokenRes); err != nil { + return nil, err + } + return tokenRes, nil +} + func NewSignerFromPrivateKeyByte(key []byte, keyID string) (jose.Signer, error) { privateKey, err := crypto.BytesToPrivateKey(key) if err != nil { diff --git a/pkg/client/rp/integration_test.go b/pkg/client/integration_test.go similarity index 72% rename from pkg/client/rp/integration_test.go rename to pkg/client/integration_test.go index e29ddd3..e89004a 100644 --- a/pkg/client/rp/integration_test.go +++ b/pkg/client/integration_test.go @@ -1,4 +1,4 @@ -package rp_test +package client_test import ( "bytes" @@ -18,9 +18,12 @@ import ( "github.com/jeremija/gosubmit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/example/server/exampleop" "github.com/zitadel/oidc/v2/example/server/storage" "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/client/rs" + "github.com/zitadel/oidc/v2/pkg/client/tokenexchange" httphelper "github.com/zitadel/oidc/v2/pkg/http" "github.com/zitadel/oidc/v2/pkg/oidc" ) @@ -36,12 +39,120 @@ func TestRelyingPartySession(t *testing.T) { t.Logf("auth server at %s", opServer.URL) dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) - localURL, err := url.Parse(targetURL + "/login?requestID=1234") - require.NoError(t, err, "local url") + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) + clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) + + t.Log("------- run authorization code flow ------") + provider, _, refreshToken, idToken := RunAuthorizationCodeFlow(t, opServer, clientID, "secret") + + t.Log("------- refresh tokens ------") + + newTokens, err := rp.RefreshAccessToken(provider, refreshToken, "", "") + require.NoError(t, err, "refresh token") + assert.NotNil(t, newTokens, "access token") + t.Logf("new access token %s", newTokens.AccessToken) + t.Logf("new refresh token %s", newTokens.RefreshToken) + t.Logf("new token type %s", newTokens.TokenType) + t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339)) + require.NotEmpty(t, newTokens.AccessToken, "new accessToken") + + t.Log("------ end session (logout) ------") + + newLoc, err := rp.EndSession(provider, idToken, "", "") + require.NoError(t, err, "logout") + if newLoc != nil { + t.Logf("redirect to %s", newLoc) + } else { + t.Logf("no redirect") + } + + t.Log("------ attempt refresh again (should fail) ------") + t.Log("trying original refresh token", refreshToken) + _, err = rp.RefreshAccessToken(provider, refreshToken, "", "") + assert.Errorf(t, err, "refresh with original") + if newTokens.RefreshToken != "" { + t.Log("trying replacement refresh token", newTokens.RefreshToken) + _, err = rp.RefreshAccessToken(provider, newTokens.RefreshToken, "", "") + assert.Errorf(t, err, "refresh with replacement") + } +} + +func TestResourceServerTokenExchange(t *testing.T) { + t.Log("------- start example OP ------") + ctx := context.Background() + targetURL := "http://local-site" + exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) + var dh deferredHandler + opServer := httptest.NewServer(&dh) + defer opServer.Close() + t.Logf("auth server at %s", opServer.URL) + dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) - client := storage.WebClient(clientID, "secret", targetURL) + clientSecret := "secret" + + t.Log("------- run authorization code flow ------") + provider, _, refreshToken, idToken := RunAuthorizationCodeFlow(t, opServer, clientID, clientSecret) + + resourceServer, err := rs.NewResourceServerClientCredentials(opServer.URL, clientID, clientSecret) + require.NoError(t, err, "new resource server") + + t.Log("------- exchage refresh tokens (impersonation) ------") + + tokenExchangeResponse, err := tokenexchange.ExchangeToken( + resourceServer, + refreshToken, + oidc.RefreshTokenType, + "", + "", + []string{}, + []string{}, + []string{"profile", "custom_scope:impersonate:id2"}, + oidc.RefreshTokenType, + ) + require.NoError(t, err, "refresh token") + require.NotNil(t, tokenExchangeResponse, "token exchange response") + assert.Equal(t, tokenExchangeResponse.IssuedTokenType, oidc.RefreshTokenType) + assert.NotEmpty(t, tokenExchangeResponse.AccessToken, "access token") + assert.NotEmpty(t, tokenExchangeResponse.RefreshToken, "refresh token") + assert.Equal(t, []string(tokenExchangeResponse.Scopes), []string{"profile", "custom_scope:impersonate:id2"}) + + t.Log("------ end session (logout) ------") + + newLoc, err := rp.EndSession(provider, idToken, "", "") + require.NoError(t, err, "logout") + if newLoc != nil { + t.Logf("redirect to %s", newLoc) + } else { + t.Logf("no redirect") + } + + t.Log("------- attempt exchage again (should fail) ------") + + tokenExchangeResponse, err = tokenexchange.ExchangeToken( + resourceServer, + refreshToken, + oidc.RefreshTokenType, + "", + "", + []string{}, + []string{}, + []string{"profile", "custom_scope:impersonate:id2"}, + oidc.RefreshTokenType, + ) + require.Error(t, err, "refresh token") + assert.Contains(t, err.Error(), "subject_token is invalid") + require.Nil(t, tokenExchangeResponse, "token exchange response") + +} + +func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret string) (provider rp.RelyingParty, accessToken, refreshToken, idToken string) { + targetURL := "http://local-site" + localURL, err := url.Parse(targetURL + "/login?requestID=1234") + require.NoError(t, err, "local url") + + client := storage.WebClient(clientID, clientSecret, targetURL) storage.RegisterClients(client) jar, err := cookiejar.New(nil) @@ -57,10 +168,10 @@ func TestRelyingPartySession(t *testing.T) { t.Log("------- create RP ------") key := []byte("test1234test1234") cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) - provider, err := rp.NewRelyingPartyOIDC( + provider, err = rp.NewRelyingPartyOIDC( opServer.URL, clientID, - "secret", + clientSecret, targetURL, []string{"openid", "email", "profile", "offline_access"}, rp.WithPKCE(cookieHandler), @@ -69,8 +180,10 @@ func TestRelyingPartySession(t *testing.T) { rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"), ), ) + require.NoError(t, err, "new rp") t.Log("------- get redirect from local client (rp) to OP ------") + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) state := "state-" + strconv.FormatInt(seed.Int63(), 25) capturedW := httptest.NewRecorder() get := httptest.NewRequest("GET", localURL.String(), nil) @@ -124,7 +237,7 @@ func TestRelyingPartySession(t *testing.T) { t.Logf("setting cookie %s", cookie) } - var accessToken, refreshToken, idToken, email string + var email string redirect := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { require.NotNil(t, tokens, "tokens") require.NotNil(t, info, "info") @@ -137,7 +250,7 @@ func TestRelyingPartySession(t *testing.T) { refreshToken = tokens.RefreshToken idToken = tokens.IDToken email = info.GetEmail() - http.Redirect(w, r, targetURL, 302) + http.Redirect(w, r, targetURL, http.StatusFound) } rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider)(capturedW, get) @@ -162,36 +275,7 @@ func TestRelyingPartySession(t *testing.T) { assert.NotEmpty(t, accessToken, "access token") assert.NotEmpty(t, email, "email") - t.Log("------- refresh tokens ------") - - newTokens, err := rp.RefreshAccessToken(provider, refreshToken, "", "") - require.NoError(t, err, "refresh token") - assert.NotNil(t, newTokens, "access token") - t.Logf("new access token %s", newTokens.AccessToken) - t.Logf("new refresh token %s", newTokens.RefreshToken) - t.Logf("new token type %s", newTokens.TokenType) - t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339)) - require.NotEmpty(t, newTokens.AccessToken, "new accessToken") - - t.Log("------ end session (logout) ------") - - newLoc, err := rp.EndSession(provider, idToken, "", "") - require.NoError(t, err, "logout") - if newLoc != nil { - t.Logf("redirect to %s", newLoc) - } else { - t.Logf("no redirect") - } - - t.Log("------ attempt refresh again (should fail) ------") - t.Log("trying original refresh token", refreshToken) - _, err = rp.RefreshAccessToken(provider, refreshToken, "", "") - assert.Errorf(t, err, "refresh with original") - if newTokens.RefreshToken != "" { - t.Log("trying replacement refresh token", newTokens.RefreshToken) - _, err = rp.RefreshAccessToken(provider, newTokens.RefreshToken, "", "") - assert.Errorf(t, err, "refresh with replacement") - } + return provider, accessToken, refreshToken, idToken } type deferredHandler struct { diff --git a/pkg/client/rs/resource_server.go b/pkg/client/rs/resource_server.go index 1d9860f..95a0121 100644 --- a/pkg/client/rs/resource_server.go +++ b/pkg/client/rs/resource_server.go @@ -13,6 +13,7 @@ import ( type ResourceServer interface { IntrospectionURL() string + TokenEndpoint() string HttpClient() *http.Client AuthFn() (interface{}, error) } @@ -29,6 +30,10 @@ func (r *resourceServer) IntrospectionURL() string { return r.introspectURL } +func (r *resourceServer) TokenEndpoint() string { + return r.tokenURL +} + func (r *resourceServer) HttpClient() *http.Client { return r.httpClient } diff --git a/pkg/client/tokenexchange/tokenexchange.go b/pkg/client/tokenexchange/tokenexchange.go new file mode 100644 index 0000000..1375f68 --- /dev/null +++ b/pkg/client/tokenexchange/tokenexchange.go @@ -0,0 +1,127 @@ +package tokenexchange + +import ( + "errors" + "net/http" + + "github.com/zitadel/oidc/v2/pkg/client" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +type TokenExchanger interface { + TokenEndpoint() string + HttpClient() *http.Client + AuthFn() (interface{}, error) +} + +type OAuthTokenExchange struct { + httpClient *http.Client + tokenEndpoint string + authFn func() (interface{}, error) +} + +func NewTokenExchanger(issuer string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { + return newOAuthTokenExchange(issuer, nil, options...) +} + +func NewTokenExchangerClientCredentials(issuer, clientID, clientSecret string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { + authorizer := func() (interface{}, error) { + return httphelper.AuthorizeBasic(clientID, clientSecret), nil + } + return newOAuthTokenExchange(issuer, authorizer, options...) +} + +func newOAuthTokenExchange(issuer string, authorizer func() (interface{}, error), options ...func(source *OAuthTokenExchange)) (*OAuthTokenExchange, error) { + te := &OAuthTokenExchange{ + httpClient: httphelper.DefaultHTTPClient, + } + for _, opt := range options { + opt(te) + } + + if te.tokenEndpoint == "" { + config, err := client.Discover(issuer, te.httpClient) + if err != nil { + return nil, err + } + + te.tokenEndpoint = config.TokenEndpoint + } + + if te.tokenEndpoint == "" { + return nil, errors.New("tokenURL is empty: please provide with either `WithStaticTokenEndpoint` or a discovery url") + } + + te.authFn = authorizer + + return te, nil +} + +func WithHTTPClient(client *http.Client) func(*OAuthTokenExchange) { + return func(source *OAuthTokenExchange) { + source.httpClient = client + } +} + +func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(*OAuthTokenExchange) { + return func(source *OAuthTokenExchange) { + source.tokenEndpoint = tokenEndpoint + } +} + +func (te *OAuthTokenExchange) TokenEndpoint() string { + return te.tokenEndpoint +} + +func (te *OAuthTokenExchange) HttpClient() *http.Client { + return te.httpClient +} + +func (te *OAuthTokenExchange) AuthFn() (interface{}, error) { + if te.authFn != nil { + return te.authFn() + } + + return nil, nil +} + +// ExchangeToken sends a token exchange request (rfc 8693) to te's token endpoint. +// SubjectToken and SubjectTokenType are required parameters. +func ExchangeToken( + te TokenExchanger, + SubjectToken string, + SubjectTokenType oidc.TokenType, + ActorToken string, + ActorTokenType oidc.TokenType, + Resource []string, + Audience []string, + Scopes []string, + RequestedTokenType oidc.TokenType, +) (*oidc.TokenExchangeResponse, error) { + if SubjectToken == "" { + return nil, errors.New("empty subject_token") + } + if SubjectTokenType == "" { + return nil, errors.New("empty subject_token_type") + } + + authFn, err := te.AuthFn() + if err != nil { + return nil, err + } + + request := oidc.TokenExchangeRequest{ + GrantType: oidc.GrantTypeTokenExchange, + SubjectToken: SubjectToken, + SubjectTokenType: SubjectTokenType, + ActorToken: ActorToken, + ActorTokenType: ActorTokenType, + Resource: Resource, + Audience: Audience, + Scopes: Scopes, + RequestedTokenType: RequestedTokenType, + } + + return client.CallTokenExchangeEndpoint(request, authFn, te) +} diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index 198049d..b538465 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -31,6 +31,7 @@ type AccessTokenClaims interface { GetSubject() string GetTokenID() string SetPrivateClaims(map[string]interface{}) + GetClaims() map[string]interface{} } type IDTokenClaims interface { @@ -151,6 +152,11 @@ 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 { @@ -612,3 +618,12 @@ func GenerateJWTProfileToken(assertion JWTProfileAssertionClaims) (string, error } return signedAssertion.CompactSerialize() } + +type TokenExchangeResponse struct { + AccessToken string `json:"access_token"` // Can be access token or ID token + IssuedTokenType TokenType `json:"issued_token_type"` + TokenType string `json:"token_type"` + ExpiresIn uint64 `json:"expires_in,omitempty"` + Scopes SpaceDelimitedArray `json:"scope,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index 2b56535..6d8f186 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -40,6 +40,29 @@ var AllGrantTypes = []GrantType{ type GrantType string +const ( + AccessTokenType TokenType = "urn:ietf:params:oauth:token-type:access_token" + RefreshTokenType TokenType = "urn:ietf:params:oauth:token-type:refresh_token" + IDTokenType TokenType = "urn:ietf:params:oauth:token-type:id_token" + JWTTokenType TokenType = "urn:ietf:params:oauth:token-type:jwt" +) + +var AllTokenTypes = []TokenType{ + AccessTokenType, RefreshTokenType, IDTokenType, JWTTokenType, +} + +type TokenType string + +func (t TokenType) IsSupported() bool { + for _, tt := range AllTokenTypes { + if t == tt { + return true + } + } + + return false +} + type TokenRequest interface { // GrantType GrantType `schema:"grant_type"` GrantType() GrantType @@ -203,14 +226,15 @@ func (j *JWTTokenRequest) GetScopes() []string { } type TokenExchangeRequest struct { - subjectToken string `schema:"subject_token"` - subjectTokenType string `schema:"subject_token_type"` - actorToken string `schema:"actor_token"` - actorTokenType string `schema:"actor_token_type"` - resource []string `schema:"resource"` - audience Audience `schema:"audience"` - Scope SpaceDelimitedArray `schema:"scope"` - requestedTokenType string `schema:"requested_token_type"` + GrantType GrantType `schema:"grant_type"` + SubjectToken string `schema:"subject_token"` + SubjectTokenType TokenType `schema:"subject_token_type"` + ActorToken string `schema:"actor_token"` + ActorTokenType TokenType `schema:"actor_token_type"` + Resource []string `schema:"resource"` + Audience Audience `schema:"audience"` + Scopes SpaceDelimitedArray `schema:"scope"` + RequestedTokenType TokenType `schema:"requested_token_type"` } type ClientCredentialsRequest struct { diff --git a/pkg/op/op.go b/pkg/op/op.go index acedcb6..699fb45 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -267,7 +267,8 @@ func (o *Provider) GrantTypeRefreshTokenSupported() bool { } func (o *Provider) GrantTypeTokenExchangeSupported() bool { - return false + _, ok := o.storage.(TokenExchangeStorage) + return ok } func (o *Provider) GrantTypeJWTAuthorizationSupported() bool { diff --git a/pkg/op/storage.go b/pkg/op/storage.go index b040b72..1e19c76 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -25,6 +25,8 @@ type AuthStorage interface { // // * *oidc.JWTTokenRequest from a JWT that is the assertion value of a JWT Profile // Grant: https://datatracker.ietf.org/doc/html/rfc7523#section-2.1 + // + // * TokenExchangeRequest as returned by ValidateTokenExchangeRequest CreateAccessToken(context.Context, TokenRequest) (accessTokenID string, expiration time.Time, err error) // The TokenRequest parameter of CreateAccessAndRefreshTokens can be any of: @@ -36,6 +38,8 @@ type AuthStorage interface { // * AuthRequest as by returned by the AuthRequestByID or AuthRequestByCode (above). // Used for the authorization code flow which requested offline_access scope and // registered the refresh_token grant type in advance + // + // * TokenExchangeRequest as returned by ValidateTokenExchangeRequest CreateAccessAndRefreshTokens(ctx context.Context, request TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshTokenID string, expiration time.Time, err error) TokenRequestByRefreshToken(ctx context.Context, refreshTokenID string) (RefreshTokenRequest, error) @@ -57,6 +61,45 @@ type ClientCredentialsStorage interface { ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (TokenRequest, error) } +type TokenExchangeStorage interface { + // ValidateTokenExchangeRequest will be called to validate parsed (including tokens) Token Exchange Grant request. + // + // Important validations can include: + // - permissions + // - set requested token type to some default value if it is empty (rfc 8693 allows it) using SetRequestedTokenType method. + // Depending on RequestedTokenType - the following tokens will be issued: + // - RefreshTokenType - both access and refresh tokens + // - AccessTokenType - only access token + // - IDTokenType - only id token + // - validation of subject's token type on possibility to be exchanged to the requested token type (according to your requirements) + // - scopes (and update them using SetCurrentScopes method) + // - set new subject if it differs from exchange subject (impersonation flow) + // + // Request will include subject's and/or actor's token claims if correspinding tokens are access/id_token issued by op + // or third party tokens parsed by TokenExchangeTokensVerifierStorage interface methods. + ValidateTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) error + + // CreateTokenExchangeRequest will be called after parsing and validating token exchange request. + // Stored request is not accessed later by op - so it is up to implementer to decide + // should this method actually store the request or not (common use case - store for it for audit purposes) + CreateTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) error + + // GetPrivateClaimsFromTokenExchangeRequest will be called during access token creation. + // Claims evaluation can be based on all validated request data available, including: scopes, resource, audience, etc. + GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) (claims map[string]interface{}, err error) + + // 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 +} + +// TokenExchangeTokensVerifierStorage is an optional interface used in token exchange process to verify tokens +// issued by third-party applications. If interface is not implemented - only tokens issued by op will be exchanged. +type TokenExchangeTokensVerifierStorage interface { + VerifyExchangeSubjectToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, subject string, tokenClaims map[string]interface{}, err error) + VerifyExchangeActorToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, actor string, tokenClaims map[string]interface{}, err error) +} + // CanRefreshTokenInfo is an optional additional interface that Storage can support. // Supporting CanRefreshTokenInfo is required to be able to (revoke) a refresh token that // is neither an encrypted string of : nor a JWT. diff --git a/pkg/op/token.go b/pkg/op/token.go index 4d3e620..3a35062 100644 --- a/pkg/op/token.go +++ b/pkg/op/token.go @@ -74,6 +74,8 @@ func needsRefreshToken(tokenRequest TokenRequest, client AccessTokenClient) bool switch req := tokenRequest.(type) { case AuthRequest: return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && req.GetResponseType() == oidc.ResponseTypeCode && ValidateGrantType(client, oidc.GrantTypeRefreshToken) + case TokenExchangeRequest: + return req.GetRequestedTokenType() == oidc.RefreshTokenType case RefreshTokenRequest: return true default: @@ -107,7 +109,23 @@ func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, ex claims := oidc.NewAccessTokenClaims(issuer, tokenRequest.GetSubject(), tokenRequest.GetAudience(), exp, id, client.GetID(), client.ClockSkew()) if client != nil { restrictedScopes := client.RestrictAdditionalAccessTokenScopes()(tokenRequest.GetScopes()) - privateClaims, err := storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes)) + + var ( + privateClaims map[string]interface{} + err error + ) + + tokenExchangeRequest, okReq := tokenRequest.(TokenExchangeRequest) + teStorage, okStorage := storage.(TokenExchangeStorage) + if okReq && okStorage { + privateClaims, err = teStorage.GetPrivateClaimsFromTokenExchangeRequest( + ctx, + tokenExchangeRequest, + ) + } else { + privateClaims, err = storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes)) + } + if err != nil { return "", err } @@ -156,7 +174,17 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v scopes = removeUserinfoScopes(scopes) } } - if len(scopes) > 0 { + + tokenExchangeRequest, okReq := request.(TokenExchangeRequest) + teStorage, okStorage := storage.(TokenExchangeStorage) + if okReq && okStorage { + userInfo := oidc.NewUserInfo() + err := teStorage.SetUserinfoFromTokenExchangeRequest(ctx, userInfo, tokenExchangeRequest) + if err != nil { + return "", err + } + claims.SetUserinfo(userInfo) + } else if len(scopes) > 0 { userInfo := oidc.NewUserInfo() err := storage.SetUserinfoFromScopes(ctx, userInfo, request.GetSubject(), request.GetClientID(), scopes) if err != nil { diff --git a/pkg/op/token_exchange.go b/pkg/op/token_exchange.go index 7bb6e42..6b918b1 100644 --- a/pkg/op/token_exchange.go +++ b/pkg/op/token_exchange.go @@ -1,11 +1,399 @@ package op import ( - "errors" + "context" "net/http" + "net/url" + "strings" + "time" + + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) -// TokenExchange will handle the OAuth 2.0 token exchange grant ("urn:ietf:params:oauth:grant-type:token-exchange") -func TokenExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) { - RequestError(w, r, errors.New("unimplemented")) +type TokenExchangeRequest interface { + GetAMR() []string + GetAudience() []string + GetResourses() []string + GetAuthTime() time.Time + GetClientID() string + GetScopes() []string + GetSubject() string + GetRequestedTokenType() oidc.TokenType + + GetExchangeSubject() string + GetExchangeSubjectTokenType() oidc.TokenType + GetExchangeSubjectTokenIDOrToken() string + GetExchangeSubjectTokenClaims() map[string]interface{} + + GetExchangeActor() string + GetExchangeActorTokenType() oidc.TokenType + GetExchangeActorTokenIDOrToken() string + GetExchangeActorTokenClaims() map[string]interface{} + + SetCurrentScopes(scopes []string) + SetRequestedTokenType(tt oidc.TokenType) + SetSubject(subject string) +} + +type tokenExchangeRequest struct { + exchangeSubjectTokenIDOrToken string + exchangeSubjectTokenType oidc.TokenType + exchangeSubject string + exchangeSubjectTokenClaims map[string]interface{} + + exchangeActorTokenIDOrToken string + exchangeActorTokenType oidc.TokenType + exchangeActor string + exchangeActorTokenClaims map[string]interface{} + + resource []string + audience oidc.Audience + scopes oidc.SpaceDelimitedArray + requestedTokenType oidc.TokenType + clientID string + authTime time.Time + subject string +} + +func (r *tokenExchangeRequest) GetAMR() []string { + return []string{} +} + +func (r *tokenExchangeRequest) GetAudience() []string { + return r.audience +} + +func (r *tokenExchangeRequest) GetResourses() []string { + return r.resource +} + +func (r *tokenExchangeRequest) GetAuthTime() time.Time { + return r.authTime +} + +func (r *tokenExchangeRequest) GetClientID() string { + return r.clientID +} + +func (r *tokenExchangeRequest) GetScopes() []string { + return r.scopes +} + +func (r *tokenExchangeRequest) GetRequestedTokenType() oidc.TokenType { + return r.requestedTokenType +} + +func (r *tokenExchangeRequest) GetExchangeSubject() string { + return r.exchangeSubject +} + +func (r *tokenExchangeRequest) GetExchangeSubjectTokenType() oidc.TokenType { + return r.exchangeSubjectTokenType +} + +func (r *tokenExchangeRequest) GetExchangeSubjectTokenIDOrToken() string { + return r.exchangeSubjectTokenIDOrToken +} + +func (r *tokenExchangeRequest) GetExchangeSubjectTokenClaims() map[string]interface{} { + return r.exchangeSubjectTokenClaims +} + +func (r *tokenExchangeRequest) GetExchangeActor() string { + return r.exchangeActor +} + +func (r *tokenExchangeRequest) GetExchangeActorTokenType() oidc.TokenType { + return r.exchangeActorTokenType +} + +func (r *tokenExchangeRequest) GetExchangeActorTokenIDOrToken() string { + return r.exchangeActorTokenIDOrToken +} + +func (r *tokenExchangeRequest) GetExchangeActorTokenClaims() map[string]interface{} { + return r.exchangeActorTokenClaims +} + +func (r *tokenExchangeRequest) GetSubject() string { + return r.subject +} + +func (r *tokenExchangeRequest) SetCurrentScopes(scopes []string) { + r.scopes = scopes +} + +func (r *tokenExchangeRequest) SetRequestedTokenType(tt oidc.TokenType) { + r.requestedTokenType = tt +} + +func (r *tokenExchangeRequest) SetSubject(subject string) { + r.subject = subject +} + +// TokenExchange handles the OAuth 2.0 token exchange grant ("urn:ietf:params:oauth:grant-type:token-exchange") +func TokenExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) { + tokenExchangeReq, clientID, clientSecret, err := ParseTokenExchangeRequest(r, exchanger.Decoder()) + if err != nil { + RequestError(w, r, err) + } + + tokenExchangeRequest, client, err := ValidateTokenExchangeRequest(r.Context(), tokenExchangeReq, clientID, clientSecret, exchanger) + if err != nil { + RequestError(w, r, err) + return + } + resp, err := CreateTokenExchangeResponse(r.Context(), tokenExchangeRequest, client, exchanger) + if err != nil { + RequestError(w, r, err) + return + } + httphelper.MarshalJSON(w, resp) +} + +// ParseTokenExchangeRequest parses the http request into oidc.TokenExchangeRequest +func ParseTokenExchangeRequest(r *http.Request, decoder httphelper.Decoder) (_ *oidc.TokenExchangeRequest, clientID, clientSecret string, err error) { + err = r.ParseForm() + if err != nil { + return nil, "", "", oidc.ErrInvalidRequest().WithDescription("error parsing form").WithParent(err) + } + + request := new(oidc.TokenExchangeRequest) + err = decoder.Decode(request, r.Form) + if err != nil { + return nil, "", "", oidc.ErrInvalidRequest().WithDescription("error decoding form").WithParent(err) + } + + var ok bool + if clientID, clientSecret, ok = r.BasicAuth(); ok { + clientID, err = url.QueryUnescape(clientID) + if err != nil { + return nil, "", "", oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err) + } + + clientSecret, err = url.QueryUnescape(clientSecret) + if err != nil { + return nil, "", "", oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err) + } + } + + return request, clientID, clientSecret, nil +} + +// ValidateTokenExchangeRequest validates the token exchange request parameters including authorization check of the client, +// subject_token and actor_token +func ValidateTokenExchangeRequest( + ctx context.Context, + oidcTokenExchangeRequest *oidc.TokenExchangeRequest, + clientID, clientSecret string, + exchanger Exchanger, +) (TokenExchangeRequest, Client, error) { + if oidcTokenExchangeRequest.SubjectToken == "" { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token missing") + } + + if oidcTokenExchangeRequest.SubjectTokenType == "" { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type missing") + } + + storage := exchanger.Storage() + teStorage, ok := storage.(TokenExchangeStorage) + if !ok { + return nil, nil, oidc.ErrUnsupportedGrantType().WithDescription("token_exchange grant not supported") + } + + client, err := AuthorizeTokenExchangeClient(ctx, clientID, clientSecret, exchanger) + if err != nil { + return nil, nil, err + } + + if oidcTokenExchangeRequest.RequestedTokenType != "" && !oidcTokenExchangeRequest.RequestedTokenType.IsSupported() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("requested_token_type is not supported") + } + + if !oidcTokenExchangeRequest.SubjectTokenType.IsSupported() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type is not supported") + } + + if oidcTokenExchangeRequest.ActorTokenType != "" && !oidcTokenExchangeRequest.ActorTokenType.IsSupported() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("actor_token_type is not supported") + } + + exchangeSubjectTokenIDOrToken, exchangeSubject, exchangeSubjectTokenClaims, ok := GetTokenIDAndSubjectFromToken(ctx, exchanger, + oidcTokenExchangeRequest.SubjectToken, oidcTokenExchangeRequest.SubjectTokenType, false) + if !ok { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token is invalid") + } + + var ( + exchangeActorTokenIDOrToken, exchangeActor string + exchangeActorTokenClaims map[string]interface{} + ) + if oidcTokenExchangeRequest.ActorToken != "" { + exchangeActorTokenIDOrToken, exchangeActor, exchangeActorTokenClaims, ok = GetTokenIDAndSubjectFromToken(ctx, exchanger, + oidcTokenExchangeRequest.ActorToken, oidcTokenExchangeRequest.ActorTokenType, true) + if !ok { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("actor_token is invalid") + } + } + + req := &tokenExchangeRequest{ + exchangeSubjectTokenIDOrToken: exchangeSubjectTokenIDOrToken, + exchangeSubjectTokenType: oidcTokenExchangeRequest.SubjectTokenType, + exchangeSubject: exchangeSubject, + exchangeSubjectTokenClaims: exchangeSubjectTokenClaims, + + exchangeActorTokenIDOrToken: exchangeActorTokenIDOrToken, + exchangeActorTokenType: oidcTokenExchangeRequest.ActorTokenType, + exchangeActor: exchangeActor, + exchangeActorTokenClaims: exchangeActorTokenClaims, + + subject: exchangeSubject, + resource: oidcTokenExchangeRequest.Resource, + audience: oidcTokenExchangeRequest.Audience, + scopes: oidcTokenExchangeRequest.Scopes, + requestedTokenType: oidcTokenExchangeRequest.RequestedTokenType, + clientID: client.GetID(), + authTime: time.Now(), + } + + err = teStorage.ValidateTokenExchangeRequest(ctx, req) + if err != nil { + return nil, nil, err + } + + err = teStorage.CreateTokenExchangeRequest(ctx, req) + if err != nil { + return nil, nil, err + } + + return req, client, nil +} + +func GetTokenIDAndSubjectFromToken( + ctx context.Context, + exchanger Exchanger, + token string, + tokenType oidc.TokenType, + isActor bool, +) (tokenIDOrToken, subject string, claims map[string]interface{}, ok bool) { + switch tokenType { + case oidc.AccessTokenType: + var accessTokenClaims oidc.AccessTokenClaims + tokenIDOrToken, subject, accessTokenClaims, ok = getTokenIDAndClaims(ctx, exchanger, token) + claims = accessTokenClaims.GetClaims() + case oidc.RefreshTokenType: + refreshTokenRequest, err := exchanger.Storage().TokenRequestByRefreshToken(ctx, token) + if err != nil { + break + } + + tokenIDOrToken, subject, ok = token, refreshTokenRequest.GetSubject(), true + case oidc.IDTokenType: + idTokenClaims, err := VerifyIDTokenHint(ctx, token, exchanger.IDTokenHintVerifier(ctx)) + if err != nil { + break + } + + tokenIDOrToken, subject, claims, ok = token, idTokenClaims.GetSubject(), idTokenClaims.GetClaims(), true + } + + if !ok { + if verifier, ok := exchanger.Storage().(TokenExchangeTokensVerifierStorage); ok { + var err error + if isActor { + tokenIDOrToken, subject, claims, err = verifier.VerifyExchangeActorToken(ctx, token, tokenType) + } else { + tokenIDOrToken, subject, claims, err = verifier.VerifyExchangeSubjectToken(ctx, token, tokenType) + } + if err != nil { + return "", "", nil, false + } + + return tokenIDOrToken, subject, claims, true + } + + return "", "", nil, false + } + + return tokenIDOrToken, subject, claims, true +} + +// AuthorizeTokenExchangeClient authorizes a client by validating the client_id and client_secret +func AuthorizeTokenExchangeClient(ctx context.Context, clientID, clientSecret string, exchanger Exchanger) (client Client, err error) { + if err := AuthorizeClientIDSecret(ctx, clientID, clientSecret, exchanger.Storage()); err != nil { + return nil, err + } + + client, err = exchanger.Storage().GetClientByClientID(ctx, clientID) + if err != nil { + return nil, oidc.ErrInvalidClient().WithParent(err) + } + + return client, nil +} + +func CreateTokenExchangeResponse( + ctx context.Context, + tokenExchangeRequest TokenExchangeRequest, + client Client, + creator TokenCreator, +) (_ *oidc.TokenExchangeResponse, err error) { + + var ( + token, refreshToken, tokenType string + validity time.Duration + ) + + switch tokenExchangeRequest.GetRequestedTokenType() { + case oidc.AccessTokenType, oidc.RefreshTokenType: + token, refreshToken, validity, err = CreateAccessToken(ctx, tokenExchangeRequest, client.AccessTokenType(), creator, client, "") + if err != nil { + return nil, err + } + + tokenType = oidc.BearerToken + case oidc.IDTokenType: + token, err = CreateIDToken(ctx, IssuerFromContext(ctx), tokenExchangeRequest, client.IDTokenLifetime(), "", "", creator.Storage(), client) + if err != nil { + return nil, err + } + + // not applicable (see https://datatracker.ietf.org/doc/html/rfc8693#section-2-2-1-2-6) + tokenType = "N_A" + default: + // oidc.JWTTokenType and other custom token types are not supported for issuing. + // In the future it can be considered to have custom tokens generation logic injected via op configuration + // or via expanding Storage interface + oidc.ErrInvalidRequest().WithDescription("requested_token_type is invalid") + } + + exp := uint64(validity.Seconds()) + return &oidc.TokenExchangeResponse{ + AccessToken: token, + IssuedTokenType: tokenExchangeRequest.GetRequestedTokenType(), + TokenType: tokenType, + ExpiresIn: exp, + RefreshToken: refreshToken, + Scopes: tokenExchangeRequest.GetScopes(), + }, nil +} + +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, ":") + if len(splitToken) != 2 { + return "", "", nil, false + } + + return splitToken[0], splitToken[1], nil, true + } + accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) + if err != nil { + return "", "", nil, false + } + + return accessTokenClaims.GetTokenID(), accessTokenClaims.GetSubject(), accessTokenClaims, true } diff --git a/pkg/op/token_request.go b/pkg/op/token_request.go index 190e812..3d65ea0 100644 --- a/pkg/op/token_request.go +++ b/pkg/op/token_request.go @@ -19,6 +19,8 @@ type Exchanger interface { GrantTypeTokenExchangeSupported() bool GrantTypeJWTAuthorizationSupported() bool GrantTypeClientCredentialsSupported() bool + AccessTokenVerifier(context.Context) AccessTokenVerifier + IDTokenHintVerifier(context.Context) IDTokenHintVerifier } func tokenHandler(exchanger Exchanger) func(w http.ResponseWriter, r *http.Request) { From f6d107340ebd86f593f96597fbe0695fda70133c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 24 Feb 2023 10:46:14 +0100 Subject: [PATCH 05/21] make `next` branch the new pre-release branch (#284) --- .releaserc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.releaserc.js b/.releaserc.js index 7b9f1ce..e8eea8e 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -1,7 +1,7 @@ module.exports = { branches: [ {name: "main"}, - {name: "dynamic-issuer", prerelease: true}, + {name: "next", prerelease: true}, ], plugins: [ "@semantic-release/commit-analyzer", From 03f71a67c21ff83a3afc0c884d634f465e522f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 24 Feb 2023 10:30:46 +0100 Subject: [PATCH 06/21] readme: update example commands --- README.md | 2 +- example/server/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26a08ec..31287e9 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Check the `/example` folder where example code for different scenarios is locate # oidc discovery http://localhost:9998/.well-known/openid-configuration go run github.com/zitadel/oidc/v2/example/server # start oidc web client (in a new terminal) -CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998 SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/example/client/app +CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998/ SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/v2/example/client/app ``` - open http://localhost:9999/login in your browser diff --git a/example/server/main.go b/example/server/main.go index 327e294..6b40305 100644 --- a/example/server/main.go +++ b/example/server/main.go @@ -15,7 +15,7 @@ func main() { //we will run on :9998 port := "9998" - //which gives us the issuer: //http://localhost:9998/ + //which gives us the issuer: http://localhost:9998/ issuer := fmt.Sprintf("http://localhost:%s/", port) // the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations From 2342f208ef94fe835db2b657307cb5c217ee688c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 1 Mar 2023 09:59:17 +0200 Subject: [PATCH 07/21] implement RFC 8628: Device authorization grant --- example/client/device/device.go | 61 +++ example/server/exampleop/device.go | 191 ++++++++ example/server/exampleop/login.go | 44 +- example/server/exampleop/op.go | 14 +- example/server/exampleop/templates.go | 26 + .../exampleop/templates/confirm_device.html | 25 + .../exampleop/templates/device_login.html | 29 ++ example/server/exampleop/templates/login.html | 29 ++ .../server/exampleop/templates/usercode.html | 21 + example/server/storage/storage.go | 97 ++++ pkg/client/client.go | 93 ++++ pkg/client/rp/device.go | 62 +++ pkg/client/rp/relying_party.go | 30 +- pkg/oidc/device_authorization.go | 29 ++ pkg/oidc/discovery.go | 2 + pkg/oidc/error.go | 34 ++ pkg/oidc/token_request.go | 5 +- pkg/op/client.go | 97 ++++ pkg/op/client_test.go | 253 ++++++++++ pkg/op/config.go | 3 + pkg/op/device.go | 265 ++++++++++ pkg/op/device_test.go | 453 ++++++++++++++++++ pkg/op/discovery.go | 4 + pkg/op/discovery_test.go | 2 + pkg/op/mock/configuration.mock.go | 56 +++ pkg/op/op.go | 49 +- pkg/op/storage.go | 47 ++ pkg/op/token_intospection.go | 38 +- pkg/op/token_request.go | 6 + 29 files changed, 1968 insertions(+), 97 deletions(-) create mode 100644 example/client/device/device.go create mode 100644 example/server/exampleop/device.go create mode 100644 example/server/exampleop/templates.go create mode 100644 example/server/exampleop/templates/confirm_device.html create mode 100644 example/server/exampleop/templates/device_login.html create mode 100644 example/server/exampleop/templates/login.html create mode 100644 example/server/exampleop/templates/usercode.html create mode 100644 pkg/client/rp/device.go create mode 100644 pkg/oidc/device_authorization.go create mode 100644 pkg/op/client_test.go create mode 100644 pkg/op/device.go create mode 100644 pkg/op/device_test.go diff --git a/example/client/device/device.go b/example/client/device/device.go new file mode 100644 index 0000000..284ba37 --- /dev/null +++ b/example/client/device/device.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/sirupsen/logrus" + + "github.com/zitadel/oidc/v2/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v2/pkg/http" +) + +var ( + key = []byte("test1234test1234") +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT) + defer stop() + + clientID := os.Getenv("CLIENT_ID") + clientSecret := os.Getenv("CLIENT_SECRET") + keyPath := os.Getenv("KEY_PATH") + issuer := os.Getenv("ISSUER") + scopes := strings.Split(os.Getenv("SCOPES"), " ") + + cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) + + var options []rp.Option + if clientSecret == "" { + options = append(options, rp.WithPKCE(cookieHandler)) + } + if keyPath != "" { + options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath))) + } + + provider, err := rp.NewRelyingPartyOIDC(issuer, clientID, clientSecret, "", scopes, options...) + if err != nil { + logrus.Fatalf("error creating provider %s", err.Error()) + } + + logrus.Info("starting device authorization flow") + resp, err := rp.DeviceAuthorization(scopes, provider) + if err != nil { + logrus.Fatal(err) + } + logrus.Info("resp", resp) + fmt.Printf("\nPlease browse to %s and enter code %s\n", resp.VerificationURI, resp.UserCode) + + logrus.Info("start polling") + token, err := rp.DeviceAccessToken(ctx, resp.DeviceCode, time.Duration(resp.Interval)*time.Second, provider) + if err != nil { + logrus.Fatal(err) + } + logrus.Infof("successfully obtained token: %v", token) +} diff --git a/example/server/exampleop/device.go b/example/server/exampleop/device.go new file mode 100644 index 0000000..ae2e8f2 --- /dev/null +++ b/example/server/exampleop/device.go @@ -0,0 +1,191 @@ +package exampleop + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/gorilla/mux" + "github.com/gorilla/securecookie" + "github.com/sirupsen/logrus" + "github.com/zitadel/oidc/v2/pkg/op" +) + +type deviceAuthenticate interface { + CheckUsernamePasswordSimple(username, password string) error + op.DeviceAuthorizationStorage +} + +type deviceLogin struct { + storage deviceAuthenticate + cookie *securecookie.SecureCookie +} + +func registerDeviceAuth(storage deviceAuthenticate, router *mux.Router) { + l := &deviceLogin{ + storage: storage, + cookie: securecookie.New(securecookie.GenerateRandomKey(32), nil), + } + + router.HandleFunc("", l.userCodeHandler) + router.Path("/login").Methods(http.MethodPost).HandlerFunc(l.loginHandler) + router.HandleFunc("/confirm", l.confirmHandler) +} + +func renderUserCode(w io.Writer, err error) { + data := struct { + Error string + }{ + Error: errMsg(err), + } + + if err := templates.ExecuteTemplate(w, "usercode", data); err != nil { + logrus.Error(err) + } +} + +func renderDeviceLogin(w http.ResponseWriter, userCode string, err error) { + data := &struct { + UserCode string + Error string + }{ + UserCode: userCode, + Error: errMsg(err), + } + if err = templates.ExecuteTemplate(w, "device_login", data); err != nil { + logrus.Error(err) + } +} + +func renderConfirmPage(w http.ResponseWriter, username, clientID string, scopes []string) { + data := &struct { + Username string + ClientID string + Scopes []string + }{ + Username: username, + ClientID: clientID, + Scopes: scopes, + } + if err := templates.ExecuteTemplate(w, "confirm_device", data); err != nil { + logrus.Error(err) + } +} + +func (d *deviceLogin) userCodeHandler(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + renderUserCode(w, err) + return + } + userCode := r.Form.Get("user_code") + if userCode == "" { + if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" { + err = errors.New(prompt) + } + renderUserCode(w, err) + return + } + + renderDeviceLogin(w, userCode, nil) +} + +func redirectBack(w http.ResponseWriter, r *http.Request, prompt string) { + values := make(url.Values) + values.Set("prompt", url.QueryEscape(prompt)) + + url := url.URL{ + Path: "/device", + RawQuery: values.Encode(), + } + http.Redirect(w, r, url.String(), http.StatusSeeOther) +} + +const userCodeCookieName = "user_code" + +type userCodeCookie struct { + UserCode string + UserName string +} + +func (d *deviceLogin) loginHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + redirectBack(w, r, err.Error()) + return + } + + userCode := r.PostForm.Get("user_code") + if userCode == "" { + redirectBack(w, r, "missing user_code in request") + return + } + username := r.PostForm.Get("username") + if username == "" { + redirectBack(w, r, "missing username in request") + return + } + password := r.PostForm.Get("password") + if password == "" { + redirectBack(w, r, "missing password in request") + return + } + + if err := d.storage.CheckUsernamePasswordSimple(username, password); err != nil { + redirectBack(w, r, err.Error()) + return + } + state, err := d.storage.GetDeviceAuthorizationByUserCode(r.Context(), userCode) + if err != nil { + redirectBack(w, r, err.Error()) + return + } + + encoded, err := d.cookie.Encode(userCodeCookieName, userCodeCookie{userCode, username}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + cookie := &http.Cookie{ + Name: userCodeCookieName, + Value: encoded, + Path: "/", + } + http.SetCookie(w, cookie) + renderConfirmPage(w, username, state.ClientID, state.Scopes) +} + +func (d *deviceLogin) confirmHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(userCodeCookieName) + if err != nil { + redirectBack(w, r, err.Error()) + return + } + data := new(userCodeCookie) + if err = d.cookie.Decode(userCodeCookieName, cookie.Value, &data); err != nil { + redirectBack(w, r, err.Error()) + return + } + if err = r.ParseForm(); err != nil { + redirectBack(w, r, err.Error()) + return + } + + action := r.Form.Get("action") + switch action { + case "allowed": + err = d.storage.CompleteDeviceAuthorization(r.Context(), data.UserCode, data.UserName) + case "denied": + err = d.storage.DenyDeviceAuthorization(r.Context(), data.UserCode) + default: + err = errors.New("action must be one of \"allow\" or \"deny\"") + } + if err != nil { + redirectBack(w, r, err.Error()) + return + } + + fmt.Fprintf(w, "Device authorization %s. You can now return to the device", action) +} diff --git a/example/server/exampleop/login.go b/example/server/exampleop/login.go index 5da86d1..c014c9a 100644 --- a/example/server/exampleop/login.go +++ b/example/server/exampleop/login.go @@ -3,45 +3,11 @@ package exampleop import ( "context" "fmt" - "html/template" "net/http" "github.com/gorilla/mux" ) -const ( - queryAuthRequestID = "authRequestID" -) - -var loginTmpl, _ = template.New("login").Parse(` - - - - - Login - - -
- - - -
- - -
- -
- - -
- -

{{.Error}}

- - -
- - `) - type login struct { authenticate authenticate router *mux.Router @@ -74,23 +40,19 @@ func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) { return } // the oidc package will pass the id of the auth request as query parameter - // we will use this id through the login process and therefore pass it to the login page + // we will use this id through the login process and therefore pass it to the login page renderLogin(w, r.FormValue(queryAuthRequestID), nil) } func renderLogin(w http.ResponseWriter, id string, err error) { - var errMsg string - if err != nil { - errMsg = err.Error() - } data := &struct { ID string Error string }{ ID: id, - Error: errMsg, + Error: errMsg(err), } - err = loginTmpl.Execute(w, data) + err = templates.ExecuteTemplate(w, "login", data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/example/server/exampleop/op.go b/example/server/exampleop/op.go index d3a450c..b46be7f 100644 --- a/example/server/exampleop/op.go +++ b/example/server/exampleop/op.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "log" "net/http" + "time" "github.com/gorilla/mux" "golang.org/x/text/language" @@ -27,7 +28,8 @@ func init() { type Storage interface { op.Storage - CheckUsernamePassword(username, password, id string) error + authenticate + deviceAuthenticate } // SetupServer creates an OIDC server with Issuer=http://localhost: @@ -62,6 +64,9 @@ func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Route // so we will direct all calls to /login to the login UI router.PathPrefix("/login/").Handler(http.StripPrefix("/login", l.router)) + router.PathPrefix("/device").Subrouter() + registerDeviceAuth(storage, router.PathPrefix("/device").Subrouter()) + // we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration) // is served on the correct path // @@ -99,6 +104,13 @@ func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte) // this example has only static texts (in English), so we'll set the here accordingly SupportedUILocales: []language.Tag{language.English}, + + DeviceAuthorization: op.DeviceAuthorizationConfig{ + Lifetime: 5 * time.Minute, + PollInterval: 5 * time.Second, + UserFormURL: issuer + "device", + UserCode: op.UserCodeBase20, + }, } handler, err := op.NewOpenIDProvider(ctx, issuer, config, storage, //we must explicitly allow the use of the http issuer diff --git a/example/server/exampleop/templates.go b/example/server/exampleop/templates.go new file mode 100644 index 0000000..5b5c966 --- /dev/null +++ b/example/server/exampleop/templates.go @@ -0,0 +1,26 @@ +package exampleop + +import ( + "embed" + "html/template" + + "github.com/sirupsen/logrus" +) + +var ( + //go:embed templates + templateFS embed.FS + templates = template.Must(template.ParseFS(templateFS, "templates/*.html")) +) + +const ( + queryAuthRequestID = "authRequestID" +) + +func errMsg(err error) string { + if err == nil { + return "" + } + logrus.Error(err) + return err.Error() +} diff --git a/example/server/exampleop/templates/confirm_device.html b/example/server/exampleop/templates/confirm_device.html new file mode 100644 index 0000000..a6bcdad --- /dev/null +++ b/example/server/exampleop/templates/confirm_device.html @@ -0,0 +1,25 @@ +{{ define "confirm_device" -}} + + + + + Confirm device authorization + + + +

Welcome back {{.Username}}!

+

+ You are about to grant device {{.ClientID}} access to the following scopes: {{.Scopes}}. +

+ + + + +{{- end }} diff --git a/example/server/exampleop/templates/device_login.html b/example/server/exampleop/templates/device_login.html new file mode 100644 index 0000000..cc5b00b --- /dev/null +++ b/example/server/exampleop/templates/device_login.html @@ -0,0 +1,29 @@ +{{ define "device_login" -}} + + + + + Login + + +
+ + + +
+ + +
+ +
+ + +
+ +

{{.Error}}

+ + +
+ + +{{- end }} diff --git a/example/server/exampleop/templates/login.html b/example/server/exampleop/templates/login.html new file mode 100644 index 0000000..b048211 --- /dev/null +++ b/example/server/exampleop/templates/login.html @@ -0,0 +1,29 @@ +{{ define "login" -}} + + + + + Login + + +
+ + + +
+ + +
+ +
+ + +
+ +

{{.Error}}

+ + +
+ +` +{{- end }} \ No newline at end of file diff --git a/example/server/exampleop/templates/usercode.html b/example/server/exampleop/templates/usercode.html new file mode 100644 index 0000000..fb8fa7f --- /dev/null +++ b/example/server/exampleop/templates/usercode.html @@ -0,0 +1,21 @@ +{{ define "usercode" -}} + + + + + Device authorization + + +
+

Device authorization

+
+ + +
+

{{.Error}}

+ + +
+ + +{{- end }} diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 662132c..b49ce1b 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -44,6 +44,8 @@ type Storage struct { services map[string]Service refreshTokens map[string]*RefreshToken signingKey signingKey + deviceCodes map[string]deviceAuthorizationEntry + userCodes map[string]string } type signingKey struct { @@ -105,6 +107,8 @@ func NewStorage(userStore UserStore) *Storage { algorithm: jose.RS256, key: key, }, + deviceCodes: make(map[string]deviceAuthorizationEntry), + userCodes: make(map[string]string), } } @@ -135,6 +139,17 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error { return fmt.Errorf("username or password wrong") } +func (s *Storage) CheckUsernamePasswordSimple(username, password string) error { + s.lock.Lock() + defer s.lock.Unlock() + + user := s.userStore.GetUserByUsername(username) + if user != nil && user.Password == password { + return nil + } + return fmt.Errorf("username or password wrong") +} + // CreateAuthRequest implements the op.Storage interface // it will be called after parsing and validation of the authentication request func (s *Storage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, userID string) (op.AuthRequest, error) { @@ -735,3 +750,85 @@ func appendClaim(claims map[string]interface{}, claim string, value interface{}) claims[claim] = value return claims } + +type deviceAuthorizationEntry struct { + deviceCode string + userCode string + state *op.DeviceAuthorizationState +} + +func (s *Storage) StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) error { + s.lock.Lock() + defer s.lock.Unlock() + + if _, ok := s.clients[clientID]; !ok { + return errors.New("client not found") + } + + if _, ok := s.userCodes[userCode]; ok { + return op.ErrDuplicateUserCode + } + + s.deviceCodes[deviceCode] = deviceAuthorizationEntry{ + deviceCode: deviceCode, + userCode: userCode, + state: &op.DeviceAuthorizationState{ + ClientID: clientID, + Scopes: scopes, + Expires: expires, + }, + } + + s.userCodes[userCode] = deviceCode + return nil +} + +func (s *Storage) GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (*op.DeviceAuthorizationState, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + s.lock.Lock() + defer s.lock.Unlock() + + entry, ok := s.deviceCodes[deviceCode] + if !ok || entry.state.ClientID != clientID { + return nil, errors.New("device code not found for client") // is there a standard not found error in the framework? + } + + return entry.state, nil +} + +func (s *Storage) GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error) { + s.lock.Lock() + defer s.lock.Unlock() + + entry, ok := s.deviceCodes[s.userCodes[userCode]] + if !ok { + return nil, errors.New("user code not found") + } + + return entry.state, nil +} + +func (s *Storage) CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) error { + s.lock.Lock() + defer s.lock.Unlock() + + entry, ok := s.deviceCodes[s.userCodes[userCode]] + if !ok { + return errors.New("user code not found") + } + + entry.state.Subject = subject + entry.state.Done = true + return nil +} + +func (s *Storage) DenyDeviceAuthorization(ctx context.Context, userCode string) error { + s.lock.Lock() + defer s.lock.Unlock() + + s.deviceCodes[s.userCodes[userCode]].state.Denied = true + return nil +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 077baf2..b9ae008 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1,6 +1,8 @@ package client import ( + "context" + "encoding/json" "errors" "fmt" "io" @@ -186,3 +188,94 @@ func SignedJWTProfileAssertion(clientID string, audience []string, expiration ti IssuedAt: oidc.Time(iat), }, signer) } + +type DeviceAuthorizationCaller interface { + GetDeviceAuthorizationEndpoint() string + HttpClient() *http.Client +} + +func CallDeviceAuthorizationEndpoint(request *oidc.ClientCredentialsRequest, caller DeviceAuthorizationCaller) (*oidc.DeviceAuthorizationResponse, error) { + req, err := httphelper.FormRequest(caller.GetDeviceAuthorizationEndpoint(), request, Encoder, nil) + if err != nil { + return nil, err + } + if request.ClientSecret != "" { + req.SetBasicAuth(request.ClientID, request.ClientSecret) + } + + resp := new(oidc.DeviceAuthorizationResponse) + if err := httphelper.HttpRequest(caller.HttpClient(), req, &resp); err != nil { + return nil, err + } + return resp, nil +} + +type DeviceAccessTokenRequest struct { + *oidc.ClientCredentialsRequest + oidc.DeviceAccessTokenRequest +} + +func CallDeviceAccessTokenEndpoint(ctx context.Context, request *DeviceAccessTokenRequest, caller TokenEndpointCaller) (*oidc.AccessTokenResponse, error) { + req, err := httphelper.FormRequest(caller.TokenEndpoint(), request, Encoder, nil) + if err != nil { + return nil, err + } + if request.ClientSecret != "" { + req.SetBasicAuth(request.ClientID, request.ClientSecret) + } + + httpResp, err := caller.HttpClient().Do(req) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + resp := new(struct { + *oidc.AccessTokenResponse + *oidc.Error + }) + if err = json.NewDecoder(httpResp.Body).Decode(resp); err != nil { + return nil, err + } + + if httpResp.StatusCode == http.StatusOK { + return resp.AccessTokenResponse, nil + } + + return nil, resp.Error +} + +func PollDeviceAccessTokenEndpoint(ctx context.Context, interval time.Duration, request *DeviceAccessTokenRequest, caller TokenEndpointCaller) (*oidc.AccessTokenResponse, error) { + for { + timer := time.After(interval) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer: + } + + ctx, cancel := context.WithTimeout(ctx, interval) + defer cancel() + + resp, err := CallDeviceAccessTokenEndpoint(ctx, request, caller) + if err == nil { + return resp, nil + } + if errors.Is(err, context.DeadlineExceeded) { + interval += 5 * time.Second + } + var target *oidc.Error + if !errors.As(err, &target) { + return nil, err + } + switch target.ErrorType { + case oidc.AuthorizationPending: + continue + case oidc.SlowDown: + interval += 5 * time.Second + continue + default: + return nil, err + } + } +} diff --git a/pkg/client/rp/device.go b/pkg/client/rp/device.go new file mode 100644 index 0000000..73b67ca --- /dev/null +++ b/pkg/client/rp/device.go @@ -0,0 +1,62 @@ +package rp + +import ( + "context" + "fmt" + "time" + + "github.com/zitadel/oidc/v2/pkg/client" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +func newDeviceClientCredentialsRequest(scopes []string, rp RelyingParty) (*oidc.ClientCredentialsRequest, error) { + confg := rp.OAuthConfig() + req := &oidc.ClientCredentialsRequest{ + GrantType: oidc.GrantTypeDeviceCode, + Scope: scopes, + ClientID: confg.ClientID, + ClientSecret: confg.ClientSecret, + } + + if signer := rp.Signer(); signer != nil { + assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer()}, time.Hour, signer) + if err != nil { + return nil, fmt.Errorf("failed to build assertion: %w", err) + } + req.ClientAssertion = assertion + req.ClientAssertionType = oidc.ClientAssertionTypeJWTAssertion + } + + return req, nil +} + +// DeviceAuthorization starts a new Device Authorization flow as defined +// in RFC 8628, section 3.1 and 3.2: +// https://www.rfc-editor.org/rfc/rfc8628#section-3.1 +func DeviceAuthorization(scopes []string, rp RelyingParty) (*oidc.DeviceAuthorizationResponse, error) { + req, err := newDeviceClientCredentialsRequest(scopes, rp) + if err != nil { + return nil, err + } + + return client.CallDeviceAuthorizationEndpoint(req, rp) +} + +// DeviceAccessToken attempts to obtain tokens from a Device Authorization, +// by means of polling as defined in RFC, section 3.3 and 3.4: +// https://www.rfc-editor.org/rfc/rfc8628#section-3.4 +func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Duration, rp RelyingParty) (resp *oidc.AccessTokenResponse, err error) { + req := &client.DeviceAccessTokenRequest{ + DeviceAccessTokenRequest: oidc.DeviceAccessTokenRequest{ + GrantType: oidc.GrantTypeDeviceCode, + DeviceCode: deviceCode, + }, + } + + req.ClientCredentialsRequest, err = newDeviceClientCredentialsRequest(nil, rp) + if err != nil { + return nil, err + } + + return client.PollDeviceAccessTokenEndpoint(ctx, interval, req, tokenEndpointCaller{rp}) +} diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go index d2e3cf7..96fe219 100644 --- a/pkg/client/rp/relying_party.go +++ b/pkg/client/rp/relying_party.go @@ -59,6 +59,10 @@ type RelyingParty interface { // UserinfoEndpoint returns the userinfo UserinfoEndpoint() string + // GetDeviceAuthorizationEndpoint returns the enpoint which can + // be used to start a DeviceAuthorization flow. + GetDeviceAuthorizationEndpoint() string + // IDTokenVerifier returns the verifier interface used for oidc id_token verification IDTokenVerifier() IDTokenVerifier // ErrorHandler returns the handler used for callback errors @@ -121,6 +125,10 @@ func (rp *relyingParty) UserinfoEndpoint() string { return rp.endpoints.UserinfoURL } +func (rp *relyingParty) GetDeviceAuthorizationEndpoint() string { + return rp.endpoints.DeviceAuthorizationURL +} + func (rp *relyingParty) GetEndSessionEndpoint() string { return rp.endpoints.EndSessionURL } @@ -495,11 +503,12 @@ type OptionFunc func(RelyingParty) type Endpoints struct { oauth2.Endpoint - IntrospectURL string - UserinfoURL string - JKWsURL string - EndSessionURL string - RevokeURL string + IntrospectURL string + UserinfoURL string + JKWsURL string + EndSessionURL string + RevokeURL string + DeviceAuthorizationURL string } func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints { @@ -509,11 +518,12 @@ func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints { AuthStyle: oauth2.AuthStyleAutoDetect, TokenURL: discoveryConfig.TokenEndpoint, }, - IntrospectURL: discoveryConfig.IntrospectionEndpoint, - UserinfoURL: discoveryConfig.UserinfoEndpoint, - JKWsURL: discoveryConfig.JwksURI, - EndSessionURL: discoveryConfig.EndSessionEndpoint, - RevokeURL: discoveryConfig.RevocationEndpoint, + IntrospectURL: discoveryConfig.IntrospectionEndpoint, + UserinfoURL: discoveryConfig.UserinfoEndpoint, + JKWsURL: discoveryConfig.JwksURI, + EndSessionURL: discoveryConfig.EndSessionEndpoint, + RevokeURL: discoveryConfig.RevocationEndpoint, + DeviceAuthorizationURL: discoveryConfig.DeviceAuthorizationEndpoint, } } diff --git a/pkg/oidc/device_authorization.go b/pkg/oidc/device_authorization.go new file mode 100644 index 0000000..68b8efa --- /dev/null +++ b/pkg/oidc/device_authorization.go @@ -0,0 +1,29 @@ +package oidc + +// DeviceAuthorizationRequest implements +// https://www.rfc-editor.org/rfc/rfc8628#section-3.1, +// 3.1 Device Authorization Request. +type DeviceAuthorizationRequest struct { + Scopes SpaceDelimitedArray `schema:"scope"` + ClientID string `schema:"client_id"` +} + +// DeviceAuthorizationResponse implements +// https://www.rfc-editor.org/rfc/rfc8628#section-3.2 +// 3.2. Device Authorization Response. +type DeviceAuthorizationResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval,omitempty"` +} + +// DeviceAccessTokenRequest implements +// https://www.rfc-editor.org/rfc/rfc8628#section-3.4, +// Device Access Token Request. +type DeviceAccessTokenRequest struct { + GrantType GrantType `json:"grant_type" schema:"grant_type"` + DeviceCode string `json:"device_code" schema:"device_code"` +} diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go index fbc417b..3574101 100644 --- a/pkg/oidc/discovery.go +++ b/pkg/oidc/discovery.go @@ -30,6 +30,8 @@ type DiscoveryConfiguration struct { // EndSessionEndpoint is a URL where the RP can perform a redirect to request that the End-User be logged out at the OP. EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"` + // CheckSessionIframe is a URL where the OP provides an iframe that support cross-origin communications for session state information with the RP Client. CheckSessionIframe string `json:"check_session_iframe,omitempty"` diff --git a/pkg/oidc/error.go b/pkg/oidc/error.go index 5797a59..79acecd 100644 --- a/pkg/oidc/error.go +++ b/pkg/oidc/error.go @@ -18,6 +18,14 @@ const ( InteractionRequired errorType = "interaction_required" LoginRequired errorType = "login_required" RequestNotSupported errorType = "request_not_supported" + + // Additional error codes as defined in + // https://www.rfc-editor.org/rfc/rfc8628#section-3.5 + // Device Access Token Response + AuthorizationPending errorType = "authorization_pending" + SlowDown errorType = "slow_down" + AccessDenied errorType = "access_denied" + ExpiredToken errorType = "expired_token" ) var ( @@ -77,6 +85,32 @@ var ( ErrorType: RequestNotSupported, } } + + // Device Access Token errors: + ErrAuthorizationPending = func() *Error { + return &Error{ + ErrorType: AuthorizationPending, + Description: "The client SHOULD repeat the access token request to the token endpoint, after interval from device authorization response.", + } + } + ErrSlowDown = func() *Error { + return &Error{ + ErrorType: SlowDown, + Description: "Polling should continue, but the interval MUST be increased by 5 seconds for this and all subsequent requests.", + } + } + ErrAccessDenied = func() *Error { + return &Error{ + ErrorType: AccessDenied, + Description: "The authorization request was denied.", + } + } + ErrExpiredDeviceCode = func() *Error { + return &Error{ + ErrorType: ExpiredToken, + Description: "The \"device_code\" has expired.", + } + } ) type Error struct { diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index 6d8f186..78bd658 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -27,6 +27,9 @@ const ( // GrantTypeImplicit defines the grant type `implicit` used for implicit flows that skip the generation and exchange of an Authorization Code GrantTypeImplicit GrantType = "implicit" + // GrantTypeDeviceCode + GrantTypeDeviceCode GrantType = "urn:ietf:params:oauth:grant-type:device_code" + // ClientAssertionTypeJWTAssertion defines the client_assertion_type `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` // used for the OAuth JWT Profile Client Authentication ClientAssertionTypeJWTAssertion = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" @@ -35,7 +38,7 @@ const ( var AllGrantTypes = []GrantType{ GrantTypeCode, GrantTypeRefreshToken, GrantTypeClientCredentials, GrantTypeBearer, GrantTypeTokenExchange, GrantTypeImplicit, - ClientAssertionTypeJWTAssertion, + GrantTypeDeviceCode, ClientAssertionTypeJWTAssertion, } type GrantType string diff --git a/pkg/op/client.go b/pkg/op/client.go index e8a3347..1f5e1c9 100644 --- a/pkg/op/client.go +++ b/pkg/op/client.go @@ -1,8 +1,13 @@ package op import ( + "context" + "errors" + "net/http" + "net/url" "time" + httphelper "github.com/zitadel/oidc/v2/pkg/http" "github.com/zitadel/oidc/v2/pkg/oidc" ) @@ -57,3 +62,95 @@ func ContainsResponseType(types []oidc.ResponseType, responseType oidc.ResponseT func IsConfidentialType(c Client) bool { return c.ApplicationType() == ApplicationTypeWeb } + +var ( + ErrInvalidAuthHeader = errors.New("invalid basic auth header") + ErrNoClientCredentials = errors.New("no client credentials provided") + ErrMissingClientID = errors.New("client_id missing from request") +) + +type ClientJWTProfile interface { + JWTProfileVerifier(context.Context) JWTProfileVerifier +} + +func ClientJWTAuth(ctx context.Context, ca oidc.ClientAssertionParams, verifier ClientJWTProfile) (clientID string, err error) { + if ca.ClientAssertion == "" { + return "", oidc.ErrInvalidClient().WithParent(ErrNoClientCredentials) + } + + profile, err := VerifyJWTAssertion(ctx, ca.ClientAssertion, verifier.JWTProfileVerifier(ctx)) + if err != nil { + return "", oidc.ErrUnauthorizedClient().WithParent(err).WithDescription("JWT assertion failed") + } + return profile.Issuer, nil +} + +func ClientBasicAuth(r *http.Request, storage Storage) (clientID string, err error) { + clientID, clientSecret, ok := r.BasicAuth() + if !ok { + return "", oidc.ErrInvalidClient().WithParent(ErrNoClientCredentials) + } + clientID, err = url.QueryUnescape(clientID) + if err != nil { + return "", oidc.ErrInvalidClient().WithParent(ErrInvalidAuthHeader) + } + clientSecret, err = url.QueryUnescape(clientSecret) + if err != nil { + return "", oidc.ErrInvalidClient().WithParent(ErrInvalidAuthHeader) + } + if err := storage.AuthorizeClientIDSecret(r.Context(), clientID, clientSecret); err != nil { + return "", oidc.ErrUnauthorizedClient().WithParent(err) + } + return clientID, nil +} + +type ClientProvider interface { + Decoder() httphelper.Decoder + Storage() Storage +} + +type clientData struct { + ClientID string `schema:"client_id"` + oidc.ClientAssertionParams +} + +// ClientIDFromRequest parses the request form and tries to obtain the client ID +// and reports if it is authenticated, using a JWT or static client secrets over +// http basic auth. +// +// If the Provider implements IntrospectorJWTProfile and "client_assertion" is +// present in the form data, JWT assertion will be verified and the +// client ID is taken from there. +// If any of them is absent, basic auth is attempted. +// In absence of basic auth data, the unauthenticated client id from the form +// data is returned. +// +// If no client id can be obtained by any method, oidc.ErrInvalidClient +// is returned with ErrMissingClientID wrapped in it. +func ClientIDFromRequest(r *http.Request, p ClientProvider) (clientID string, authenticated bool, err error) { + err = r.ParseForm() + if err != nil { + return "", false, oidc.ErrInvalidRequest().WithDescription("cannot parse form").WithParent(err) + } + + data := new(clientData) + if err = p.Decoder().Decode(data, r.PostForm); err != nil { + return "", false, err + } + + JWTProfile, ok := p.(ClientJWTProfile) + if ok { + clientID, err = ClientJWTAuth(r.Context(), data.ClientAssertionParams, JWTProfile) + } + if !ok || errors.Is(err, ErrNoClientCredentials) { + clientID, err = ClientBasicAuth(r, p.Storage()) + } + if err == nil { + return clientID, true, nil + } + + if data.ClientID == "" { + return "", false, oidc.ErrInvalidClient().WithParent(ErrMissingClientID) + } + return data.ClientID, false, nil +} diff --git a/pkg/op/client_test.go b/pkg/op/client_test.go new file mode 100644 index 0000000..1af4157 --- /dev/null +++ b/pkg/op/client_test.go @@ -0,0 +1,253 @@ +package op_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/gorilla/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" + "github.com/zitadel/oidc/v2/pkg/op/mock" +) + +type testClientJWTProfile struct{} + +func (testClientJWTProfile) JWTProfileVerifier(context.Context) op.JWTProfileVerifier { return nil } + +func TestClientJWTAuth(t *testing.T) { + type args struct { + ctx context.Context + ca oidc.ClientAssertionParams + verifier op.ClientJWTProfile + } + tests := []struct { + name string + args args + wantClientID string + wantErr error + }{ + { + name: "empty assertion", + args: args{ + context.Background(), + oidc.ClientAssertionParams{}, + testClientJWTProfile{}, + }, + wantErr: op.ErrNoClientCredentials, + }, + { + name: "verification error", + args: args{ + context.Background(), + oidc.ClientAssertionParams{ + ClientAssertion: "foo", + }, + testClientJWTProfile{}, + }, + wantErr: oidc.ErrParse, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotClientID, err := op.ClientJWTAuth(tt.args.ctx, tt.args.ca, tt.args.verifier) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantClientID, gotClientID) + }) + } +} + +func TestClientBasicAuth(t *testing.T) { + errWrong := errors.New("wrong secret") + + type args struct { + username string + password string + } + tests := []struct { + name string + args *args + storage op.Storage + wantClientID string + wantErr error + }{ + { + name: "no args", + wantErr: op.ErrNoClientCredentials, + }, + { + name: "username unescape err", + args: &args{ + username: "%", + password: "bar", + }, + wantErr: op.ErrInvalidAuthHeader, + }, + { + name: "password unescape err", + args: &args{ + username: "foo", + password: "%", + }, + wantErr: op.ErrInvalidAuthHeader, + }, + { + name: "auth error", + args: &args{ + username: "foo", + password: "wrong", + }, + storage: func() op.Storage { + s := mock.NewMockStorage(gomock.NewController(t)) + s.EXPECT().AuthorizeClientIDSecret(context.Background(), "foo", "wrong").Return(errWrong) + return s + }(), + wantErr: errWrong, + }, + { + name: "auth error", + args: &args{ + username: "foo", + password: "bar", + }, + storage: func() op.Storage { + s := mock.NewMockStorage(gomock.NewController(t)) + s.EXPECT().AuthorizeClientIDSecret(context.Background(), "foo", "bar").Return(nil) + return s + }(), + wantClientID: "foo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/foo", nil) + if tt.args != nil { + r.SetBasicAuth(tt.args.username, tt.args.password) + } + + gotClientID, err := op.ClientBasicAuth(r, tt.storage) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantClientID, gotClientID) + }) + } +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, io.ErrNoProgress +} + +type testClientProvider struct { + storage op.Storage +} + +func (testClientProvider) Decoder() httphelper.Decoder { + return schema.NewDecoder() +} + +func (p testClientProvider) Storage() op.Storage { + return p.storage +} + +func TestClientIDFromRequest(t *testing.T) { + type args struct { + body io.Reader + p op.ClientProvider + } + type basicAuth struct { + username string + password string + } + tests := []struct { + name string + args args + basicAuth *basicAuth + wantClientID string + wantAuthenticated bool + wantErr bool + }{ + { + name: "parse error", + args: args{ + body: errReader{}, + }, + wantErr: true, + }, + { + name: "unauthenticated", + args: args{ + body: strings.NewReader( + url.Values{ + "client_id": []string{"foo"}, + }.Encode(), + ), + p: testClientProvider{ + storage: mock.NewStorage(t), + }, + }, + wantClientID: "foo", + wantAuthenticated: false, + }, + { + name: "authenticated", + args: args{ + body: strings.NewReader( + url.Values{}.Encode(), + ), + p: testClientProvider{ + storage: func() op.Storage { + s := mock.NewMockStorage(gomock.NewController(t)) + s.EXPECT().AuthorizeClientIDSecret(context.Background(), "foo", "bar").Return(nil) + return s + }(), + }, + }, + basicAuth: &basicAuth{ + username: "foo", + password: "bar", + }, + wantClientID: "foo", + wantAuthenticated: true, + }, + { + name: "missing client id", + args: args{ + body: strings.NewReader( + url.Values{}.Encode(), + ), + p: testClientProvider{ + storage: mock.NewStorage(t), + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/foo", tt.args.body) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if tt.basicAuth != nil { + r.SetBasicAuth(tt.basicAuth.username, tt.basicAuth.password) + } + + gotClientID, gotAuthenticated, err := op.ClientIDFromRequest(r, tt.args.p) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantClientID, gotClientID) + assert.Equal(t, tt.wantAuthenticated, gotAuthenticated) + }) + } +} diff --git a/pkg/op/config.go b/pkg/op/config.go index c40fa2d..c40ed39 100644 --- a/pkg/op/config.go +++ b/pkg/op/config.go @@ -27,6 +27,7 @@ type Configuration interface { RevocationEndpoint() Endpoint EndSessionEndpoint() Endpoint KeysEndpoint() Endpoint + DeviceAuthorizationEndpoint() Endpoint AuthMethodPostSupported() bool CodeMethodS256Supported() bool @@ -36,6 +37,7 @@ type Configuration interface { GrantTypeTokenExchangeSupported() bool GrantTypeJWTAuthorizationSupported() bool GrantTypeClientCredentialsSupported() bool + GrantTypeDeviceCodeSupported() bool IntrospectionAuthMethodPrivateKeyJWTSupported() bool IntrospectionEndpointSigningAlgorithmsSupported() []string RevocationAuthMethodPrivateKeyJWTSupported() bool @@ -44,6 +46,7 @@ type Configuration interface { RequestObjectSigningAlgorithmsSupported() []string SupportedUILocales() []language.Tag + DeviceAuthorization() DeviceAuthorizationConfig } type IssuerFromRequest func(r *http.Request) string diff --git a/pkg/op/device.go b/pkg/op/device.go new file mode 100644 index 0000000..04c06f2 --- /dev/null +++ b/pkg/op/device.go @@ -0,0 +1,265 @@ +package op + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "math/big" + "net/http" + "strings" + "time" + + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +type DeviceAuthorizationConfig struct { + Lifetime time.Duration + PollInterval time.Duration + UserFormURL string // the URL where the user must go to authorize the device + UserCode UserCodeConfig +} + +type UserCodeConfig struct { + CharSet string + CharAmount int + DashInterval int +} + +const ( + CharSetBase20 = "BCDFGHJKLMNPQRSTVWXZ" + CharSetDigits = "0123456789" +) + +var ( + UserCodeBase20 = UserCodeConfig{ + CharSet: CharSetBase20, + CharAmount: 8, + DashInterval: 4, + } + UserCodeDigits = UserCodeConfig{ + CharSet: CharSetDigits, + CharAmount: 9, + DashInterval: 3, + } +) + +func DeviceAuthorizationHandler(o OpenIDProvider) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if err := DeviceAuthorization(w, r, o); err != nil { + RequestError(w, r, err) + } + } +} + +func DeviceAuthorization(w http.ResponseWriter, r *http.Request, o OpenIDProvider) error { + storage, err := assertDeviceStorage(o.Storage()) + if err != nil { + return err + } + + req, err := ParseDeviceCodeRequest(r, o) + if err != nil { + return err + } + + config := o.DeviceAuthorization() + + deviceCode, err := NewDeviceCode(RecommendedDeviceCodeBytes) + if err != nil { + return err + } + userCode, err := NewUserCode([]rune(config.UserCode.CharSet), config.UserCode.CharAmount, config.UserCode.DashInterval) + if err != nil { + return err + } + + expires := time.Now().Add(config.Lifetime) + err = storage.StoreDeviceAuthorization(r.Context(), req.ClientID, deviceCode, userCode, expires, req.Scopes) + if err != nil { + return err + } + + response := &oidc.DeviceAuthorizationResponse{ + DeviceCode: deviceCode, + UserCode: userCode, + VerificationURI: config.UserFormURL, + ExpiresIn: int(config.Lifetime / time.Second), + Interval: int(config.PollInterval / time.Second), + } + + response.VerificationURIComplete = fmt.Sprintf("%s?user_code=%s", config.UserFormURL, userCode) + + httphelper.MarshalJSON(w, response) + return nil +} + +func ParseDeviceCodeRequest(r *http.Request, o OpenIDProvider) (*oidc.DeviceAuthorizationRequest, error) { + clientID, _, err := ClientIDFromRequest(r, o) + if err != nil { + return nil, err + } + + req := new(oidc.DeviceAuthorizationRequest) + if err := o.Decoder().Decode(req, r.Form); err != nil { + return nil, oidc.ErrInvalidRequest().WithDescription("cannot parse device authentication request").WithParent(err) + } + req.ClientID = clientID + + return req, nil +} + +// 16 bytes gives 128 bit of entropy. +// results in a 22 character base64 encoded string. +const RecommendedDeviceCodeBytes = 16 + +func NewDeviceCode(nBytes int) (string, error) { + bytes := make([]byte, nBytes) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("%w getting entropy for device code", err) + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func NewUserCode(charSet []rune, charAmount, dashInterval int) (string, error) { + var buf strings.Builder + if dashInterval > 0 { + buf.Grow(charAmount + charAmount/dashInterval - 1) + } else { + buf.Grow(charAmount) + } + + max := big.NewInt(int64(len(charSet))) + + for i := 0; i < charAmount; i++ { + if dashInterval != 0 && i != 0 && i%dashInterval == 0 { + buf.WriteByte('-') + } + + bi, err := rand.Int(rand.Reader, max) + if err != nil { + return "", fmt.Errorf("%w getting entropy for user code", err) + } + + buf.WriteRune(charSet[int(bi.Int64())]) + } + + return buf.String(), nil +} + +type deviceAccessTokenRequest struct { + subject string + audience []string + scopes []string +} + +func (r *deviceAccessTokenRequest) GetSubject() string { + return r.subject +} + +func (r *deviceAccessTokenRequest) GetAudience() []string { + return r.audience +} + +func (r *deviceAccessTokenRequest) GetScopes() []string { + return r.scopes +} + +func DeviceAccessToken(w http.ResponseWriter, r *http.Request, exchanger Exchanger) { + if err := deviceAccessToken(w, r, exchanger); err != nil { + RequestError(w, r, err) + } +} + +func deviceAccessToken(w http.ResponseWriter, r *http.Request, exchanger Exchanger) error { + // use a limited context timeout shorter as the default + // poll interval of 5 seconds. + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) + defer cancel() + r = r.WithContext(ctx) + + clientID, clientAuthenticated, err := ClientIDFromRequest(r, exchanger) + if err != nil { + return err + } + + req, err := ParseDeviceAccessTokenRequest(r, exchanger) + if err != nil { + return err + } + state, err := CheckDeviceAuthorizationState(ctx, clientID, req.DeviceCode, exchanger) + if err != nil { + return err + } + + client, err := exchanger.Storage().GetClientByClientID(ctx, clientID) + if err != nil { + return err + } + if clientAuthenticated != IsConfidentialType(client) { + return oidc.ErrInvalidClient().WithParent(ErrNoClientCredentials). + WithDescription("confidential client requires authentication") + } + + tokenRequest := &deviceAccessTokenRequest{ + subject: state.Subject, + audience: []string{clientID}, + scopes: state.Scopes, + } + resp, err := CreateDeviceTokenResponse(r.Context(), tokenRequest, exchanger, client) + if err != nil { + return err + } + + httphelper.MarshalJSON(w, resp) + return nil +} + +func ParseDeviceAccessTokenRequest(r *http.Request, exchanger Exchanger) (*oidc.DeviceAccessTokenRequest, error) { + req := new(oidc.DeviceAccessTokenRequest) + if err := exchanger.Decoder().Decode(req, r.PostForm); err != nil { + return nil, err + } + return req, nil +} + +func CheckDeviceAuthorizationState(ctx context.Context, clientID, deviceCode string, exchanger Exchanger) (*DeviceAuthorizationState, error) { + storage, err := assertDeviceStorage(exchanger.Storage()) + if err != nil { + return nil, err + } + + state, err := storage.GetDeviceAuthorizatonState(ctx, clientID, deviceCode) + if errors.Is(err, context.DeadlineExceeded) { + return nil, oidc.ErrSlowDown().WithParent(err) + } + if err != nil { + return nil, oidc.ErrAccessDenied().WithParent(err) + } + if state.Denied { + return state, oidc.ErrAccessDenied() + } + if state.Done { + return state, nil + } + if time.Now().After(state.Expires) { + return state, oidc.ErrExpiredDeviceCode() + } + return state, oidc.ErrAuthorizationPending() +} + +func CreateDeviceTokenResponse(ctx context.Context, tokenRequest TokenRequest, creator TokenCreator, client AccessTokenClient) (*oidc.AccessTokenResponse, error) { + accessToken, refreshToken, validity, err := CreateAccessToken(ctx, tokenRequest, AccessTokenTypeBearer, creator, client, "") + if err != nil { + return nil, err + } + + return &oidc.AccessTokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: oidc.BearerToken, + ExpiresIn: uint64(validity.Seconds()), + }, nil +} diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go new file mode 100644 index 0000000..ca68759 --- /dev/null +++ b/pkg/op/device_test.go @@ -0,0 +1,453 @@ +package op_test + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "io" + mr "math/rand" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/example/server/storage" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" + "golang.org/x/text/language" +) + +var testProvider op.OpenIDProvider + +const ( + testIssuer = "https://localhost:9998/" + pathLoggedOut = "/logged-out" +) + +func init() { + config := &op.Config{ + CryptoKey: sha256.Sum256([]byte("test")), + DefaultLogoutRedirectURI: pathLoggedOut, + CodeMethodS256: true, + AuthMethodPost: true, + AuthMethodPrivateKeyJWT: true, + GrantTypeRefreshToken: true, + RequestObjectSupported: true, + SupportedUILocales: []language.Tag{language.English}, + DeviceAuthorization: op.DeviceAuthorizationConfig{ + Lifetime: 5 * time.Minute, + PollInterval: 5 * time.Second, + UserFormURL: testIssuer + "device", + UserCode: op.UserCodeBase20, + }, + } + + storage.RegisterClients( + storage.NativeClient("native"), + storage.WebClient("web", "secret"), + storage.WebClient("api", "secret"), + ) + + var err error + testProvider, err = op.NewOpenIDProvider(context.TODO(), testIssuer, config, + storage.NewStorage(storage.NewUserStore(testIssuer)), op.WithAllowInsecure(), + ) + if err != nil { + panic(err) + } +} + +func Test_deviceAuthorizationHandler(t *testing.T) { + req := &oidc.DeviceAuthorizationRequest{ + Scopes: []string{"foo", "bar"}, + ClientID: "web", + } + values := make(url.Values) + testProvider.Encoder().Encode(req, values) + body := strings.NewReader(values.Encode()) + + r := httptest.NewRequest(http.MethodPost, "/", body) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + w := httptest.NewRecorder() + + runWithRandReader(mr.New(mr.NewSource(1)), func() { + op.DeviceAuthorizationHandler(testProvider)(w, r) + }) + + result := w.Result() + + assert.Less(t, result.StatusCode, 300) + + got, _ := io.ReadAll(result.Body) + assert.JSONEq(t, `{"device_code":"Uv38ByGCZU8WP18PmmIdcg", "expires_in":300, "interval":5, "user_code":"JKRV-FRGK", "verification_uri":"https://localhost:9998/device", "verification_uri_complete":"https://localhost:9998/device?user_code=JKRV-FRGK"}`, string(got)) +} + +func TestParseDeviceCodeRequest(t *testing.T) { + tests := []struct { + name string + req *oidc.DeviceAuthorizationRequest + wantErr bool + }{ + { + name: "empty request", + wantErr: true, + }, + /* decoding a SpaceDelimitedArray is broken + https://github.com/zitadel/oidc/issues/295 + { + name: "success", + req: &oidc.DeviceAuthorizationRequest{ + Scopes: oidc.SpaceDelimitedArray{"foo", "bar"}, + ClientID: "web", + }, + }, + */ + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body io.Reader + if tt.req != nil { + values := make(url.Values) + testProvider.Encoder().Encode(tt.req, values) + body = strings.NewReader(values.Encode()) + } + + r := httptest.NewRequest(http.MethodPost, "/", body) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + got, err := op.ParseDeviceCodeRequest(r, testProvider) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.req, got) + }) + } +} + +func runWithRandReader(r io.Reader, f func()) { + originalReader := rand.Reader + rand.Reader = r + defer func() { + rand.Reader = originalReader + }() + + f() +} + +func TestNewDeviceCode(t *testing.T) { + t.Run("reader error", func(t *testing.T) { + runWithRandReader(errReader{}, func() { + _, err := op.NewDeviceCode(16) + require.Error(t, err) + }) + }) + + t.Run("different lengths, rand reader", func(t *testing.T) { + for i := 1; i <= 32; i++ { + got, err := op.NewDeviceCode(i) + require.NoError(t, err) + assert.Len(t, got, base64.RawURLEncoding.EncodedLen(i)) + } + }) + +} + +func TestNewUserCode(t *testing.T) { + type args struct { + charset []rune + charAmount int + dashInterval int + } + tests := []struct { + name string + args args + reader io.Reader + want string + wantErr bool + }{ + { + name: "reader error", + args: args{ + charset: []rune(op.CharSetBase20), + charAmount: 8, + dashInterval: 4, + }, + reader: errReader{}, + wantErr: true, + }, + { + name: "base20", + args: args{ + charset: []rune(op.CharSetBase20), + charAmount: 8, + dashInterval: 4, + }, + reader: mr.New(mr.NewSource(1)), + want: "XKCD-HTTD", + }, + { + name: "digits", + args: args{ + charset: []rune(op.CharSetDigits), + charAmount: 9, + dashInterval: 3, + }, + reader: mr.New(mr.NewSource(1)), + want: "271-256-225", + }, + { + name: "no dashes", + args: args{ + charset: []rune(op.CharSetDigits), + charAmount: 9, + }, + reader: mr.New(mr.NewSource(1)), + want: "271256225", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runWithRandReader(tt.reader, func() { + got, err := op.NewUserCode(tt.args.charset, tt.args.charAmount, tt.args.dashInterval) + if tt.wantErr { + require.ErrorIs(t, err, io.ErrNoProgress) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + + }) + } + + t.Run("crypto/rand", func(t *testing.T) { + const testN = 100000 + + for _, c := range []op.UserCodeConfig{op.UserCodeBase20, op.UserCodeDigits} { + t.Run(c.CharSet, func(t *testing.T) { + results := make(map[string]int) + + for i := 0; i < testN; i++ { + code, err := op.NewUserCode([]rune(c.CharSet), c.CharAmount, c.DashInterval) + require.NoError(t, err) + results[code]++ + } + + t.Log(results) + + var duplicates int + for code, count := range results { + assert.Less(t, count, 3, code) + if count == 2 { + duplicates++ + } + } + + }) + } + }) +} + +func BenchmarkNewUserCode(b *testing.B) { + type args struct { + charset []rune + charAmount int + dashInterval int + } + tests := []struct { + name string + args args + reader io.Reader + }{ + { + name: "math rand, base20", + args: args{ + charset: []rune(op.CharSetBase20), + charAmount: 8, + dashInterval: 4, + }, + reader: mr.New(mr.NewSource(1)), + }, + { + name: "math rand, digits", + args: args{ + charset: []rune(op.CharSetDigits), + charAmount: 9, + dashInterval: 3, + }, + reader: mr.New(mr.NewSource(1)), + }, + { + name: "crypto rand, base20", + args: args{ + charset: []rune(op.CharSetBase20), + charAmount: 8, + dashInterval: 4, + }, + reader: rand.Reader, + }, + { + name: "crypto rand, digits", + args: args{ + charset: []rune(op.CharSetDigits), + charAmount: 9, + dashInterval: 3, + }, + reader: rand.Reader, + }, + } + for _, tt := range tests { + runWithRandReader(tt.reader, func() { + b.Run(tt.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, err := op.NewUserCode(tt.args.charset, tt.args.charAmount, tt.args.dashInterval) + require.NoError(b, err) + } + }) + + }) + } +} + +func TestDeviceAccessToken(t *testing.T) { + storage := testProvider.Storage().(op.DeviceAuthorizationStorage) + storage.StoreDeviceAuthorization(context.Background(), "native", "qwerty", "yuiop", time.Now().Add(time.Minute), []string{"foo"}) + storage.CompleteDeviceAuthorization(context.Background(), "yuiop", "tim") + + values := make(url.Values) + values.Set("client_id", "native") + values.Set("grant_type", string(oidc.GrantTypeDeviceCode)) + values.Set("device_code", "qwerty") + + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + op.DeviceAccessToken(w, r, testProvider) + + result := w.Result() + got, _ := io.ReadAll(result.Body) + t.Log(string(got)) + assert.Less(t, result.StatusCode, 300) + assert.NotEmpty(t, string(got)) +} + +func TestCheckDeviceAuthorizationState(t *testing.T) { + now := time.Now() + + storage := testProvider.Storage().(op.DeviceAuthorizationStorage) + storage.StoreDeviceAuthorization(context.Background(), "native", "pending", "pending", now.Add(time.Minute), []string{"foo"}) + storage.StoreDeviceAuthorization(context.Background(), "native", "denied", "denied", now.Add(time.Minute), []string{"foo"}) + storage.StoreDeviceAuthorization(context.Background(), "native", "completed", "completed", now.Add(time.Minute), []string{"foo"}) + storage.StoreDeviceAuthorization(context.Background(), "native", "expired", "expired", now.Add(-time.Minute), []string{"foo"}) + + storage.DenyDeviceAuthorization(context.Background(), "denied") + storage.CompleteDeviceAuthorization(context.Background(), "completed", "tim") + + exceededCtx, cancel := context.WithTimeout(context.Background(), -time.Second) + defer cancel() + + type args struct { + ctx context.Context + clientID string + deviceCode string + } + tests := []struct { + name string + args args + want *op.DeviceAuthorizationState + wantErr error + }{ + { + name: "pending", + args: args{ + ctx: context.Background(), + clientID: "native", + deviceCode: "pending", + }, + want: &op.DeviceAuthorizationState{ + ClientID: "native", + Scopes: []string{"foo"}, + Expires: now.Add(time.Minute), + }, + wantErr: oidc.ErrAuthorizationPending(), + }, + { + name: "slow down", + args: args{ + ctx: exceededCtx, + clientID: "native", + deviceCode: "ok", + }, + wantErr: oidc.ErrSlowDown(), + }, + { + name: "wrong client", + args: args{ + ctx: context.Background(), + clientID: "foo", + deviceCode: "ok", + }, + wantErr: oidc.ErrAccessDenied(), + }, + { + name: "denied", + args: args{ + ctx: context.Background(), + clientID: "native", + deviceCode: "denied", + }, + want: &op.DeviceAuthorizationState{ + ClientID: "native", + Scopes: []string{"foo"}, + Expires: now.Add(time.Minute), + Denied: true, + }, + wantErr: oidc.ErrAccessDenied(), + }, + { + name: "completed", + args: args{ + ctx: context.Background(), + clientID: "native", + deviceCode: "completed", + }, + want: &op.DeviceAuthorizationState{ + ClientID: "native", + Scopes: []string{"foo"}, + Expires: now.Add(time.Minute), + Subject: "tim", + Done: true, + }, + }, + { + name: "expired", + args: args{ + ctx: context.Background(), + clientID: "native", + deviceCode: "expired", + }, + want: &op.DeviceAuthorizationState{ + ClientID: "native", + Scopes: []string{"foo"}, + Expires: now.Add(-time.Minute), + }, + wantErr: oidc.ErrExpiredDeviceCode(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.CheckDeviceAuthorizationState(tt.args.ctx, tt.args.clientID, tt.args.deviceCode, testProvider) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go index 9a25afc..26f89eb 100644 --- a/pkg/op/discovery.go +++ b/pkg/op/discovery.go @@ -44,6 +44,7 @@ func CreateDiscoveryConfig(r *http.Request, config Configuration, storage Discov RevocationEndpoint: config.RevocationEndpoint().Absolute(issuer), EndSessionEndpoint: config.EndSessionEndpoint().Absolute(issuer), JwksURI: config.KeysEndpoint().Absolute(issuer), + DeviceAuthorizationEndpoint: config.DeviceAuthorizationEndpoint().Absolute(issuer), ScopesSupported: Scopes(config), ResponseTypesSupported: ResponseTypes(config), GrantTypesSupported: GrantTypes(config), @@ -92,6 +93,9 @@ func GrantTypes(c Configuration) []oidc.GrantType { if c.GrantTypeJWTAuthorizationSupported() { grantTypes = append(grantTypes, oidc.GrantTypeBearer) } + if c.GrantTypeDeviceCodeSupported() { + grantTypes = append(grantTypes, oidc.GrantTypeDeviceCode) + } return grantTypes } diff --git a/pkg/op/discovery_test.go b/pkg/op/discovery_test.go index e1b07dd..2d0b8af 100644 --- a/pkg/op/discovery_test.go +++ b/pkg/op/discovery_test.go @@ -131,6 +131,7 @@ func Test_GrantTypes(t *testing.T) { c.EXPECT().GrantTypeTokenExchangeSupported().Return(false) c.EXPECT().GrantTypeJWTAuthorizationSupported().Return(false) c.EXPECT().GrantTypeClientCredentialsSupported().Return(false) + c.EXPECT().GrantTypeDeviceCodeSupported().Return(false) return c }(), }, @@ -148,6 +149,7 @@ func Test_GrantTypes(t *testing.T) { c.EXPECT().GrantTypeTokenExchangeSupported().Return(true) c.EXPECT().GrantTypeJWTAuthorizationSupported().Return(true) c.EXPECT().GrantTypeClientCredentialsSupported().Return(true) + c.EXPECT().GrantTypeDeviceCodeSupported().Return(false) return c }(), }, diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go index fc3158a..44b5ceb 100644 --- a/pkg/op/mock/configuration.mock.go +++ b/pkg/op/mock/configuration.mock.go @@ -92,6 +92,34 @@ func (mr *MockConfigurationMockRecorder) CodeMethodS256Supported() *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CodeMethodS256Supported", reflect.TypeOf((*MockConfiguration)(nil).CodeMethodS256Supported)) } +// DeviceAuthorization mocks base method. +func (m *MockConfiguration) DeviceAuthorization() op.DeviceAuthorizationConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeviceAuthorization") + ret0, _ := ret[0].(op.DeviceAuthorizationConfig) + return ret0 +} + +// DeviceAuthorization indicates an expected call of DeviceAuthorization. +func (mr *MockConfigurationMockRecorder) DeviceAuthorization() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeviceAuthorization", reflect.TypeOf((*MockConfiguration)(nil).DeviceAuthorization)) +} + +// DeviceAuthorizationEndpoint mocks base method. +func (m *MockConfiguration) DeviceAuthorizationEndpoint() op.Endpoint { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeviceAuthorizationEndpoint") + ret0, _ := ret[0].(op.Endpoint) + return ret0 +} + +// DeviceAuthorizationEndpoint indicates an expected call of DeviceAuthorizationEndpoint. +func (mr *MockConfigurationMockRecorder) DeviceAuthorizationEndpoint() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeviceAuthorizationEndpoint", reflect.TypeOf((*MockConfiguration)(nil).DeviceAuthorizationEndpoint)) +} + // EndSessionEndpoint mocks base method. func (m *MockConfiguration) EndSessionEndpoint() op.Endpoint { m.ctrl.T.Helper() @@ -120,6 +148,20 @@ func (mr *MockConfigurationMockRecorder) GrantTypeClientCredentialsSupported() * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GrantTypeClientCredentialsSupported", reflect.TypeOf((*MockConfiguration)(nil).GrantTypeClientCredentialsSupported)) } +// GrantTypeDeviceCodeSupported mocks base method. +func (m *MockConfiguration) GrantTypeDeviceCodeSupported() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GrantTypeDeviceCodeSupported") + ret0, _ := ret[0].(bool) + return ret0 +} + +// GrantTypeDeviceCodeSupported indicates an expected call of GrantTypeDeviceCodeSupported. +func (mr *MockConfigurationMockRecorder) GrantTypeDeviceCodeSupported() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GrantTypeDeviceCodeSupported", reflect.TypeOf((*MockConfiguration)(nil).GrantTypeDeviceCodeSupported)) +} + // GrantTypeJWTAuthorizationSupported mocks base method. func (m *MockConfiguration) GrantTypeJWTAuthorizationSupported() bool { m.ctrl.T.Helper() @@ -358,6 +400,20 @@ func (mr *MockConfigurationMockRecorder) TokenEndpointSigningAlgorithmsSupported return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TokenEndpointSigningAlgorithmsSupported", reflect.TypeOf((*MockConfiguration)(nil).TokenEndpointSigningAlgorithmsSupported)) } +// UserCodeFormEndpoint mocks base method. +func (m *MockConfiguration) UserCodeFormEndpoint() op.Endpoint { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserCodeFormEndpoint") + ret0, _ := ret[0].(op.Endpoint) + return ret0 +} + +// UserCodeFormEndpoint indicates an expected call of UserCodeFormEndpoint. +func (mr *MockConfigurationMockRecorder) UserCodeFormEndpoint() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserCodeFormEndpoint", reflect.TypeOf((*MockConfiguration)(nil).UserCodeFormEndpoint)) +} + // UserinfoEndpoint mocks base method. func (m *MockConfiguration) UserinfoEndpoint() op.Endpoint { m.ctrl.T.Helper() diff --git a/pkg/op/op.go b/pkg/op/op.go index 699fb45..2859722 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -27,17 +27,19 @@ const ( defaultRevocationEndpoint = "revoke" defaultEndSessionEndpoint = "end_session" defaultKeysEndpoint = "keys" + defaultDeviceAuthzEndpoint = "/device_authorization" ) var ( DefaultEndpoints = &endpoints{ - Authorization: NewEndpoint(defaultAuthorizationEndpoint), - Token: NewEndpoint(defaultTokenEndpoint), - Introspection: NewEndpoint(defaultIntrospectEndpoint), - Userinfo: NewEndpoint(defaultUserinfoEndpoint), - Revocation: NewEndpoint(defaultRevocationEndpoint), - EndSession: NewEndpoint(defaultEndSessionEndpoint), - JwksURI: NewEndpoint(defaultKeysEndpoint), + Authorization: NewEndpoint(defaultAuthorizationEndpoint), + Token: NewEndpoint(defaultTokenEndpoint), + Introspection: NewEndpoint(defaultIntrospectEndpoint), + Userinfo: NewEndpoint(defaultUserinfoEndpoint), + Revocation: NewEndpoint(defaultRevocationEndpoint), + EndSession: NewEndpoint(defaultEndSessionEndpoint), + JwksURI: NewEndpoint(defaultKeysEndpoint), + DeviceAuthorization: NewEndpoint(defaultDeviceAuthzEndpoint), } defaultCORSOptions = cors.Options{ @@ -95,6 +97,7 @@ func CreateRouter(o OpenIDProvider, interceptors ...HttpInterceptor) *mux.Router router.HandleFunc(o.RevocationEndpoint().Relative(), revocationHandler(o)) router.HandleFunc(o.EndSessionEndpoint().Relative(), endSessionHandler(o)) router.HandleFunc(o.KeysEndpoint().Relative(), keysHandler(o.Storage())) + router.HandleFunc(o.DeviceAuthorizationEndpoint().Relative(), DeviceAuthorizationHandler(o)) return router } @@ -118,17 +121,19 @@ type Config struct { GrantTypeRefreshToken bool RequestObjectSupported bool SupportedUILocales []language.Tag + DeviceAuthorization DeviceAuthorizationConfig } type endpoints struct { - Authorization Endpoint - Token Endpoint - Introspection Endpoint - Userinfo Endpoint - Revocation Endpoint - EndSession Endpoint - CheckSessionIframe Endpoint - JwksURI Endpoint + Authorization Endpoint + Token Endpoint + Introspection Endpoint + Userinfo Endpoint + Revocation Endpoint + EndSession Endpoint + CheckSessionIframe Endpoint + JwksURI Endpoint + DeviceAuthorization Endpoint } // NewOpenIDProvider creates a provider. The provider provides (with HttpHandler()) @@ -145,6 +150,7 @@ type endpoints struct { // /revoke // /end_session // /keys +// /device_authorization // // This does not include login. Login is handled with a redirect that includes the // request ID. The redirect for logins is specified per-client by Client.LoginURL(). @@ -242,6 +248,10 @@ func (o *Provider) EndSessionEndpoint() Endpoint { return o.endpoints.EndSession } +func (o *Provider) DeviceAuthorizationEndpoint() Endpoint { + return o.endpoints.DeviceAuthorization +} + func (o *Provider) KeysEndpoint() Endpoint { return o.endpoints.JwksURI } @@ -275,6 +285,11 @@ func (o *Provider) GrantTypeJWTAuthorizationSupported() bool { return true } +func (o *Provider) GrantTypeDeviceCodeSupported() bool { + _, ok := o.storage.(DeviceAuthorizationStorage) + return ok +} + func (o *Provider) IntrospectionAuthMethodPrivateKeyJWTSupported() bool { return true } @@ -308,6 +323,10 @@ func (o *Provider) SupportedUILocales() []language.Tag { return o.config.SupportedUILocales } +func (o *Provider) DeviceAuthorization() DeviceAuthorizationConfig { + return o.config.DeviceAuthorization +} + func (o *Provider) Storage() Storage { return o.storage } diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 1e19c76..ebab1c3 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -151,3 +151,50 @@ type EndSessionRequest struct { ClientID string RedirectURI string } + +var ErrDuplicateUserCode = errors.New("user code already exists") + +type DeviceAuthorizationState struct { + ClientID string + Scopes []string + Expires time.Time + Done bool + Subject string + Denied bool +} + +type DeviceAuthorizationStorage interface { + // StoreDeviceAuthorizationRequest stores a new device authorization request in the database. + // User code will be used by the user to complete the login flow and must be unique. + // ErrDuplicateUserCode signals the caller should try again with a new code. + // + // Note that user codes are low entropy keys and when many exist in the + // database, the change for collisions increases. Therefore implementers + // of this interface must make sure that user codes of expired authentication flows are purged, + // after some time. + StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) error + + // GetDeviceAuthorizatonState returns the current state of the device authorization flow in the database. + // The method is polled untill the the authorization is eighter Completed, Expired or Denied. + GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (*DeviceAuthorizationState, error) + + // GetDeviceAuthorizationByUserCode resturn the current state of the device authorization flow, + // identified by the user code. + GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*DeviceAuthorizationState, error) + + // CompleteDeviceAuthorization marks a device authorization entry as Completed, + // identified by userCode. The Subject is added to the state, so that + // GetDeviceAuthorizatonState can use it to create a new Access Token. + CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) error + + // DenyDeviceAuthorization marks a device authorization entry as Denied. + DenyDeviceAuthorization(ctx context.Context, userCode string) error +} + +func assertDeviceStorage(s Storage) (DeviceAuthorizationStorage, error) { + storage, ok := s.(DeviceAuthorizationStorage) + if !ok { + return nil, oidc.ErrUnsupportedGrantType().WithDescription("device_code grant not supported") + } + return storage, nil +} diff --git a/pkg/op/token_intospection.go b/pkg/op/token_intospection.go index dfc8954..e7ca7c4 100644 --- a/pkg/op/token_intospection.go +++ b/pkg/op/token_intospection.go @@ -4,7 +4,6 @@ import ( "context" "errors" "net/http" - "net/url" httphelper "github.com/zitadel/oidc/v2/pkg/http" "github.com/zitadel/oidc/v2/pkg/oidc" @@ -50,38 +49,19 @@ func Introspect(w http.ResponseWriter, r *http.Request, introspector Introspecto } func ParseTokenIntrospectionRequest(r *http.Request, introspector Introspector) (token, clientID string, err error) { - err = r.ParseForm() + clientID, authenticated, err := ClientIDFromRequest(r, introspector) if err != nil { - return "", "", errors.New("unable to parse request") + return "", "", err } - req := new(struct { - oidc.IntrospectionRequest - oidc.ClientAssertionParams - }) + if !authenticated { + return "", "", oidc.ErrInvalidClient().WithParent(ErrNoClientCredentials) + } + + req := new(oidc.IntrospectionRequest) err = introspector.Decoder().Decode(req, r.Form) if err != nil { return "", "", errors.New("unable to parse request") } - if introspectorJWTProfile, ok := introspector.(IntrospectorJWTProfile); ok && req.ClientAssertion != "" { - profile, err := VerifyJWTAssertion(r.Context(), req.ClientAssertion, introspectorJWTProfile.JWTProfileVerifier(r.Context())) - if err == nil { - return req.Token, profile.Issuer, nil - } - } - clientID, clientSecret, ok := r.BasicAuth() - if ok { - clientID, err = url.QueryUnescape(clientID) - if err != nil { - return "", "", errors.New("invalid basic auth header") - } - clientSecret, err = url.QueryUnescape(clientSecret) - if err != nil { - return "", "", errors.New("invalid basic auth header") - } - if err := introspector.Storage().AuthorizeClientIDSecret(r.Context(), clientID, clientSecret); err != nil { - return "", "", err - } - return req.Token, clientID, nil - } - return "", "", errors.New("invalid authorization") + + return req.Token, clientID, nil } diff --git a/pkg/op/token_request.go b/pkg/op/token_request.go index 3d65ea0..b9e9805 100644 --- a/pkg/op/token_request.go +++ b/pkg/op/token_request.go @@ -19,6 +19,7 @@ type Exchanger interface { GrantTypeTokenExchangeSupported() bool GrantTypeJWTAuthorizationSupported() bool GrantTypeClientCredentialsSupported() bool + GrantTypeDeviceCodeSupported() bool AccessTokenVerifier(context.Context) AccessTokenVerifier IDTokenHintVerifier(context.Context) IDTokenHintVerifier } @@ -56,6 +57,11 @@ func Exchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) { ClientCredentialsExchange(w, r, exchanger) return } + case string(oidc.GrantTypeDeviceCode): + if exchanger.GrantTypeDeviceCodeSupported() { + DeviceAccessToken(w, r, exchanger) + return + } case "": RequestError(w, r, oidc.ErrInvalidRequest().WithDescription("grant_type missing")) return From f3eae0f32925b0db827e9473a5a6d9ef6caee80f Mon Sep 17 00:00:00 2001 From: David Sharnoff Date: Tue, 28 Feb 2023 12:44:33 -0800 Subject: [PATCH 08/21] breaking change: add rp/RelyingParty.GetRevokeEndpoint --- NEXT_RELEASE.md | 1 - pkg/client/rp/relying_party.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/NEXT_RELEASE.md b/NEXT_RELEASE.md index 91f7f5d..4bde900 100644 --- a/NEXT_RELEASE.md +++ b/NEXT_RELEASE.md @@ -1,7 +1,6 @@ # Backwards-incompatible changes to be made in the next major release -- Add `rp/RelyingParty.GetRevokeEndpoint` - Rename `op/OpStorage.GetKeyByIDAndUserID` to `op/OpStorage.GetKeyByIDAndClientID` - Add `CanRefreshTokenInfo` (`GetRefreshTokenInfo()`) to `op.Storage` diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go index 96fe219..5bd3558 100644 --- a/pkg/client/rp/relying_party.go +++ b/pkg/client/rp/relying_party.go @@ -54,7 +54,7 @@ type RelyingParty interface { GetEndSessionEndpoint() string // GetRevokeEndpoint returns the endpoint to revoke a specific token - // "GetRevokeEndpoint() string" will be added in a future release + GetRevokeEndpoint() string // UserinfoEndpoint returns the userinfo UserinfoEndpoint() string From f447b9b6d461168d2643c4b9f12bd8cf99f1fbe1 Mon Sep 17 00:00:00 2001 From: David Sharnoff Date: Tue, 28 Feb 2023 15:49:24 -0800 Subject: [PATCH 09/21] breaking change: Add GetRefreshTokenInfo() to op.Storage --- NEXT_RELEASE.md | 1 - example/server/storage/storage.go | 12 +++++++++++- example/server/storage/storage_dynamic.go | 10 ++++++++++ go.mod | 1 + go.sum | 12 ++++++++++++ pkg/op/mock/storage.mock.go | 16 ++++++++++++++++ pkg/op/storage.go | 17 +++++++---------- pkg/op/token_revocation.go | 4 ++-- 8 files changed, 59 insertions(+), 14 deletions(-) diff --git a/NEXT_RELEASE.md b/NEXT_RELEASE.md index 4bde900..e113dce 100644 --- a/NEXT_RELEASE.md +++ b/NEXT_RELEASE.md @@ -2,5 +2,4 @@ # Backwards-incompatible changes to be made in the next major release - Rename `op/OpStorage.GetKeyByIDAndUserID` to `op/OpStorage.GetKeyByIDAndClientID` -- Add `CanRefreshTokenInfo` (`GetRefreshTokenInfo()`) to `op.Storage` diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index b49ce1b..08efeb3 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -327,6 +327,16 @@ func (s *Storage) TerminateSession(ctx context.Context, userID string, clientID return nil } +// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id. +// If given something that is not a refresh token, it must return error. +func (s *Storage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) { + refreshToken, ok := s.refreshTokens[token] + if !ok { + return "", "", op.ErrInvalidRefreshToken + } + return refreshToken.UserID, refreshToken.ID, nil +} + // RevokeToken implements the op.Storage interface // it will be called after parsing and validation of the token revocation request func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID string, clientID string) *oidc.Error { @@ -384,7 +394,7 @@ func (s *Storage) KeySet(ctx context.Context) ([]op.Key, error) { // so it will directly use its public key // // when using key rotation you typically would store the public keys alongside the private keys in your database - //and give both of them an expiration date, with the public key having a longer lifetime + // and give both of them an expiration date, with the public key having a longer lifetime return []op.Key{&publicKey{s.signingKey}}, nil } diff --git a/example/server/storage/storage_dynamic.go b/example/server/storage/storage_dynamic.go index ec6a92e..b8051fa 100644 --- a/example/server/storage/storage_dynamic.go +++ b/example/server/storage/storage_dynamic.go @@ -126,6 +126,16 @@ func (s *multiStorage) TerminateSession(ctx context.Context, userID string, clie return storage.TerminateSession(ctx, userID, clientID) } +// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id. +// If given something that is not a refresh token, it must return error. +func (s *multiStorage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) { + storage, err := s.storageFromContext(ctx) + if err != nil { + return "", "", err + } + return storage.GetRefreshTokenInfo(ctx, clientID, token) +} + // RevokeToken implements the op.Storage interface // it will be called after parsing and validation of the token revocation request func (s *multiStorage) RevokeToken(ctx context.Context, token string, userID string, clientID string) *oidc.Error { diff --git a/go.mod b/go.mod index 2691e57..d3e1234 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/zitadel/oidc/v2 go 1.16 require ( + github.com/dmarkham/enumer v1.5.7 // indirect 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 diff --git a/go.sum b/go.sum index c73eb9d..8ce0b62 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dmarkham/enumer v1.5.7 h1:xYJA/lGoniiuhZLASBUbpPjScUslfyDHUAMczeflCeg= +github.com/dmarkham/enumer v1.5.7/go.mod h1:eAawajOQnFBxf0NndBKgbqJImkHytg3eFEngUovqgo8= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -125,6 +127,8 @@ 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/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/pascaldekloe/name v1.0.0 h1:n7LKFgHixETzxpRv2R77YgPUFo85QHGZKrdaYm7eY5U= +github.com/pascaldekloe/name v1.0.0/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -146,6 +150,7 @@ 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.1/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= @@ -190,6 +195,7 @@ 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 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= 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= @@ -219,6 +225,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R 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-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -265,8 +272,10 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-20210423082822-04245dca01da/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-20211019181941-9d821ace8654/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= @@ -278,6 +287,7 @@ 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.6/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= @@ -325,6 +335,8 @@ 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.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= +golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= 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= diff --git a/pkg/op/mock/storage.mock.go b/pkg/op/mock/storage.mock.go index 58cc2a0..c01137d 100644 --- a/pkg/op/mock/storage.mock.go +++ b/pkg/op/mock/storage.mock.go @@ -189,6 +189,22 @@ func (mr *MockStorageMockRecorder) GetPrivateClaimsFromScopes(arg0, arg1, arg2, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrivateClaimsFromScopes", reflect.TypeOf((*MockStorage)(nil).GetPrivateClaimsFromScopes), arg0, arg1, arg2, arg3) } +// GetRefreshTokenInfo mocks base method. +func (m *MockStorage) GetRefreshTokenInfo(arg0 context.Context, arg1, arg2 string) (string, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRefreshTokenInfo", arg0, arg1, arg2) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetRefreshTokenInfo indicates an expected call of GetRefreshTokenInfo. +func (mr *MockStorageMockRecorder) GetRefreshTokenInfo(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRefreshTokenInfo", reflect.TypeOf((*MockStorage)(nil).GetRefreshTokenInfo), arg0, arg1, arg2) +} + // Health mocks base method. func (m *MockStorage) Health(arg0 context.Context) error { m.ctrl.T.Helper() diff --git a/pkg/op/storage.go b/pkg/op/storage.go index ebab1c3..c87fac3 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -48,9 +48,15 @@ type AuthStorage interface { // RevokeToken should revoke a token. In the situation that the original request was to // revoke an access token, then tokenOrTokenID will be a tokenID and userID will be set // but if the original request was for a refresh token, then userID will be empty and - // tokenOrTokenID will be the refresh token, not its ID. + // tokenOrTokenID will be the refresh token, not its ID. RevokeToken depends upon GetRefreshTokenInfo + // to get information from refresh tokens that are not either ":" strings + // nor JWTs. RevokeToken(ctx context.Context, tokenOrTokenID string, userID string, clientID string) *oidc.Error + // GetRefreshTokenInfo must return ErrInvalidRefreshToken when presented + // with a token that is not a refresh token. + GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) + SigningKey(context.Context) (SigningKey, error) SignatureAlgorithms(context.Context) ([]jose.SignatureAlgorithm, error) KeySet(context.Context) ([]Key, error) @@ -100,15 +106,6 @@ type TokenExchangeTokensVerifierStorage interface { VerifyExchangeActorToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, actor string, tokenClaims map[string]interface{}, err error) } -// CanRefreshTokenInfo is an optional additional interface that Storage can support. -// Supporting CanRefreshTokenInfo is required to be able to (revoke) a refresh token that -// is neither an encrypted string of : nor a JWT. -type CanRefreshTokenInfo interface { - // GetRefreshTokenInfo must return ErrInvalidRefreshToken when presented - // with a token that is not a refresh token. - GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) -} - var ErrInvalidRefreshToken = errors.New("invalid_refresh_token") type OPStorage interface { diff --git a/pkg/op/token_revocation.go b/pkg/op/token_revocation.go index 7dbd4a7..33978f5 100644 --- a/pkg/op/token_revocation.go +++ b/pkg/op/token_revocation.go @@ -39,8 +39,8 @@ func Revoke(w http.ResponseWriter, r *http.Request, revoker Revoker) { } var subject string doDecrypt := true - if canRefreshInfo, ok := revoker.Storage().(CanRefreshTokenInfo); ok && tokenTypeHint != "access_token" { - userID, tokenID, err := canRefreshInfo.GetRefreshTokenInfo(r.Context(), clientID, token) + if tokenTypeHint != "access_token" { + userID, tokenID, err := revoker.Storage().GetRefreshTokenInfo(r.Context(), clientID, token) if err != nil { // An invalid refresh token means that we'll try other things (leaving doDecrypt==true) if !errors.Is(err, ErrInvalidRefreshToken) { From 0c74bd51db3b7db7a065862ac6d4a5324a4a70fc Mon Sep 17 00:00:00 2001 From: David Sharnoff Date: Tue, 28 Feb 2023 16:15:25 -0800 Subject: [PATCH 10/21] breaking change: rename GetKeyByIDAndUserID -> GetKeyByIDAndClientID --- NEXT_RELEASE.md | 1 - example/server/storage/storage.go | 4 ++-- example/server/storage/storage_dynamic.go | 6 +++--- pkg/op/mock/storage.mock.go | 12 ++++++------ pkg/op/storage.go | 5 +---- pkg/op/verifier_jwt_profile.go | 4 ++-- 6 files changed, 14 insertions(+), 18 deletions(-) diff --git a/NEXT_RELEASE.md b/NEXT_RELEASE.md index e113dce..f515c40 100644 --- a/NEXT_RELEASE.md +++ b/NEXT_RELEASE.md @@ -1,5 +1,4 @@ # Backwards-incompatible changes to be made in the next major release -- Rename `op/OpStorage.GetKeyByIDAndUserID` to `op/OpStorage.GetKeyByIDAndClientID` diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 08efeb3..2794783 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -510,9 +510,9 @@ func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, client return claims, nil } -// GetKeyByIDAndUserID implements the op.Storage interface +// GetKeyByIDAndClientID implements the op.Storage interface // it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication) -func (s *Storage) GetKeyByIDAndUserID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) { +func (s *Storage) GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) { s.lock.Lock() defer s.lock.Unlock() service, ok := s.services[clientID] diff --git a/example/server/storage/storage_dynamic.go b/example/server/storage/storage_dynamic.go index b8051fa..d424a89 100644 --- a/example/server/storage/storage_dynamic.go +++ b/example/server/storage/storage_dynamic.go @@ -236,14 +236,14 @@ func (s *multiStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, c return storage.GetPrivateClaimsFromScopes(ctx, userID, clientID, scopes) } -// GetKeyByIDAndUserID implements the op.Storage interface +// GetKeyByIDAndClientID implements the op.Storage interface // it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication) -func (s *multiStorage) GetKeyByIDAndUserID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) { +func (s *multiStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) { storage, err := s.storageFromContext(ctx) if err != nil { return nil, err } - return storage.GetKeyByIDAndUserID(ctx, keyID, userID) + return storage.GetKeyByIDAndClientID(ctx, keyID, userID) } // ValidateJWTProfileScopes implements the op.Storage interface diff --git a/pkg/op/mock/storage.mock.go b/pkg/op/mock/storage.mock.go index c01137d..fc0c358 100644 --- a/pkg/op/mock/storage.mock.go +++ b/pkg/op/mock/storage.mock.go @@ -159,19 +159,19 @@ func (mr *MockStorageMockRecorder) GetClientByClientID(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClientByClientID", reflect.TypeOf((*MockStorage)(nil).GetClientByClientID), arg0, arg1) } -// GetKeyByIDAndUserID mocks base method. -func (m *MockStorage) GetKeyByIDAndUserID(arg0 context.Context, arg1, arg2 string) (*jose.JSONWebKey, error) { +// GetKeyByIDAndClientID mocks base method. +func (m *MockStorage) GetKeyByIDAndClientID(arg0 context.Context, arg1, arg2 string) (*jose.JSONWebKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetKeyByIDAndUserID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetKeyByIDAndClientID", arg0, arg1, arg2) ret0, _ := ret[0].(*jose.JSONWebKey) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetKeyByIDAndUserID indicates an expected call of GetKeyByIDAndUserID. -func (mr *MockStorageMockRecorder) GetKeyByIDAndUserID(arg0, arg1, arg2 interface{}) *gomock.Call { +// GetKeyByIDAndClientID indicates an expected call of GetKeyByIDAndClientID. +func (mr *MockStorageMockRecorder) GetKeyByIDAndClientID(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeyByIDAndUserID", reflect.TypeOf((*MockStorage)(nil).GetKeyByIDAndUserID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKeyByIDAndClientID", reflect.TypeOf((*MockStorage)(nil).GetKeyByIDAndClientID), arg0, arg1, arg2) } // GetPrivateClaimsFromScopes mocks base method. diff --git a/pkg/op/storage.go b/pkg/op/storage.go index c87fac3..8ba1946 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -115,10 +115,7 @@ type OPStorage interface { SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, 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) - - // GetKeyByIDAndUserID is mis-named. It does not pass userID. Instead - // it passes the clientID. - GetKeyByIDAndUserID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, 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/verifier_jwt_profile.go b/pkg/op/verifier_jwt_profile.go index 9befb64..4d83c59 100644 --- a/pkg/op/verifier_jwt_profile.go +++ b/pkg/op/verifier_jwt_profile.go @@ -104,7 +104,7 @@ func VerifyJWTAssertion(ctx context.Context, assertion string, v JWTProfileVerif } type jwtProfileKeyStorage interface { - GetKeyByIDAndUserID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) + GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) } func SubjectIsIssuer(request *oidc.JWTTokenRequest) error { @@ -122,7 +122,7 @@ type jwtProfileKeySet struct { // VerifySignature implements oidc.KeySet by getting the public key from Storage implementation func (k *jwtProfileKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) { keyID, _ := oidc.GetKeyIDAndAlg(jws) - key, err := k.storage.GetKeyByIDAndUserID(ctx, keyID, k.clientID) + key, err := k.storage.GetKeyByIDAndClientID(ctx, keyID, k.clientID) if err != nil { return nil, fmt.Errorf("error fetching keys: %w", err) } From ad76a7cb072bc3dd4e2370f3ce28ba36df298b55 Mon Sep 17 00:00:00 2001 From: David Sharnoff Date: Tue, 28 Feb 2023 16:15:44 -0800 Subject: [PATCH 11/21] remove empty NEXT_RELEASE.md --- NEXT_RELEASE.md | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 NEXT_RELEASE.md diff --git a/NEXT_RELEASE.md b/NEXT_RELEASE.md deleted file mode 100644 index f515c40..0000000 --- a/NEXT_RELEASE.md +++ /dev/null @@ -1,4 +0,0 @@ - -# Backwards-incompatible changes to be made in the next major release - - From 2d4ce6fde3353fcbd0ebd73f4bc4c9786859cea5 Mon Sep 17 00:00:00 2001 From: David Sharnoff Date: Wed, 1 Mar 2023 12:39:12 -0800 Subject: [PATCH 12/21] go mod tidy --- go.mod | 1 - go.sum | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/go.mod b/go.mod index d3e1234..2691e57 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/zitadel/oidc/v2 go 1.16 require ( - github.com/dmarkham/enumer v1.5.7 // indirect 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 diff --git a/go.sum b/go.sum index 8ce0b62..c73eb9d 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,6 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dmarkham/enumer v1.5.7 h1:xYJA/lGoniiuhZLASBUbpPjScUslfyDHUAMczeflCeg= -github.com/dmarkham/enumer v1.5.7/go.mod h1:eAawajOQnFBxf0NndBKgbqJImkHytg3eFEngUovqgo8= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -127,8 +125,6 @@ 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/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/pascaldekloe/name v1.0.0 h1:n7LKFgHixETzxpRv2R77YgPUFo85QHGZKrdaYm7eY5U= -github.com/pascaldekloe/name v1.0.0/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -150,7 +146,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.1/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= @@ -195,7 +190,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 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= 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= @@ -225,7 +219,6 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R 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-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -272,10 +265,8 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-20210423082822-04245dca01da/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-20211019181941-9d821ace8654/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= @@ -287,7 +278,6 @@ 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.6/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= @@ -335,8 +325,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.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= -golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= 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= From 1eb4ee1c8e5dae9c9f3d332d617d22d42b5db68b Mon Sep 17 00:00:00 2001 From: David Sharnoff Date: Wed, 1 Mar 2023 12:43:09 -0800 Subject: [PATCH 13/21] auto install things for "go generate" and then clean up afterwards --- pkg/op/client.go | 1 + pkg/op/mock/configuration.mock.go | 14 -------------- pkg/op/mock/generate.go | 1 + 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/pkg/op/client.go b/pkg/op/client.go index 1f5e1c9..f1d106b 100644 --- a/pkg/op/client.go +++ b/pkg/op/client.go @@ -13,6 +13,7 @@ import ( //go:generate go get github.com/dmarkham/enumer //go:generate go run github.com/dmarkham/enumer -linecomment -sql -json -text -yaml -gqlgen -type=ApplicationType,AccessTokenType +//go:generate go mod tidy const ( ApplicationTypeWeb ApplicationType = iota // web diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go index 44b5ceb..fe7d4da 100644 --- a/pkg/op/mock/configuration.mock.go +++ b/pkg/op/mock/configuration.mock.go @@ -400,20 +400,6 @@ func (mr *MockConfigurationMockRecorder) TokenEndpointSigningAlgorithmsSupported return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TokenEndpointSigningAlgorithmsSupported", reflect.TypeOf((*MockConfiguration)(nil).TokenEndpointSigningAlgorithmsSupported)) } -// UserCodeFormEndpoint mocks base method. -func (m *MockConfiguration) UserCodeFormEndpoint() op.Endpoint { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserCodeFormEndpoint") - ret0, _ := ret[0].(op.Endpoint) - return ret0 -} - -// UserCodeFormEndpoint indicates an expected call of UserCodeFormEndpoint. -func (mr *MockConfigurationMockRecorder) UserCodeFormEndpoint() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserCodeFormEndpoint", reflect.TypeOf((*MockConfiguration)(nil).UserCodeFormEndpoint)) -} - // UserinfoEndpoint mocks base method. func (m *MockConfiguration) UserinfoEndpoint() op.Endpoint { m.ctrl.T.Helper() diff --git a/pkg/op/mock/generate.go b/pkg/op/mock/generate.go index 0066571..ca288d2 100644 --- a/pkg/op/mock/generate.go +++ b/pkg/op/mock/generate.go @@ -1,5 +1,6 @@ package mock +//go:generate go install github.com/golang/mock/mockgen@v1.6.0 //go:generate mockgen -package mock -destination ./storage.mock.go github.com/zitadel/oidc/v2/pkg/op Storage //go:generate mockgen -package mock -destination ./authorizer.mock.go github.com/zitadel/oidc/v2/pkg/op Authorizer //go:generate mockgen -package mock -destination ./client.mock.go github.com/zitadel/oidc/v2/pkg/op Client From fc1a80d2749145d01b704965ce709eb723aa0b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 2 Mar 2023 15:24:13 +0200 Subject: [PATCH 14/21] chore: enable github actions for next branch (#298) --- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/release.yml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 85ea2ca..d2bae79 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,10 +2,10 @@ name: "Code scanning - action" on: push: - branches: [main, ] + branches: [main,next] pull_request: # The branches below must be a subset of the branches above - branches: [main] + branches: [main,next] schedule: - cron: '0 11 * * 0' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d97d41a..9509826 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - next tags-ignore: - '**' pull_request: @@ -31,7 +32,7 @@ jobs: release: runs-on: ubuntu-20.04 needs: [test] - if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }} + if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: From 4dca29f1f9635a3879f400c8b5d18db44ee12565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 2 Mar 2023 15:24:44 +0200 Subject: [PATCH 15/21] fix: use the same schema encoder everywhere (#299) properly register SpaceDelimitedArray for all instances of schema.Encoder inside the oidc framework. Closes #295 --- pkg/client/client.go | 10 +--------- pkg/oidc/types.go | 12 ++++++++++++ pkg/oidc/types_test.go | 19 +++++++++++++++++++ pkg/op/device_test.go | 3 --- pkg/op/op.go | 2 +- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/pkg/client/client.go b/pkg/client/client.go index b9ae008..ebe1442 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -8,11 +8,9 @@ import ( "io" "net/http" "net/url" - "reflect" "strings" "time" - "github.com/gorilla/schema" "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" @@ -21,13 +19,7 @@ import ( "github.com/zitadel/oidc/v2/pkg/oidc" ) -var Encoder = func() httphelper.Encoder { - e := schema.NewEncoder() - e.RegisterEncoder(oidc.SpaceDelimitedArray{}, func(value reflect.Value) string { - return value.Interface().(oidc.SpaceDelimitedArray).Encode() - }) - return e -}() +var Encoder = httphelper.Encoder(oidc.NewEncoder()) // Discover calls the discovery endpoint of the provided issuer and returns its configuration // It accepts an optional argument "wellknownUrl" which can be used to overide the dicovery endpoint url diff --git a/pkg/oidc/types.go b/pkg/oidc/types.go index 1260798..21b6fba 100644 --- a/pkg/oidc/types.go +++ b/pkg/oidc/types.go @@ -4,9 +4,11 @@ import ( "database/sql/driver" "encoding/json" "fmt" + "reflect" "strings" "time" + "github.com/gorilla/schema" "golang.org/x/text/language" "gopkg.in/square/go-jose.v2" ) @@ -125,6 +127,16 @@ func (s SpaceDelimitedArray) Value() (driver.Value, error) { return strings.Join(s, " "), nil } +// NewEncoder returns a schema Encoder with +// a registered encoder for SpaceDelimitedArray. +func NewEncoder() *schema.Encoder { + e := schema.NewEncoder() + e.RegisterEncoder(SpaceDelimitedArray{}, func(value reflect.Value) string { + return value.Interface().(SpaceDelimitedArray).Encode() + }) + return e +} + type Time time.Time func (t *Time) UnmarshalJSON(data []byte) error { diff --git a/pkg/oidc/types_test.go b/pkg/oidc/types_test.go index 6c62c40..74323da 100644 --- a/pkg/oidc/types_test.go +++ b/pkg/oidc/types_test.go @@ -3,10 +3,12 @@ package oidc import ( "bytes" "encoding/json" + "net/url" "strconv" "strings" "testing" + "github.com/gorilla/schema" "github.com/stretchr/testify/assert" "golang.org/x/text/language" ) @@ -335,3 +337,20 @@ func TestSpaceDelimitatedArray_ValuerNil(t *testing.T) { assert.Equal(t, SpaceDelimitedArray(nil), reversed, "scan nil") } } + +func TestNewEncoder(t *testing.T) { + type request struct { + Scopes SpaceDelimitedArray `schema:"scope"` + } + a := request{ + Scopes: SpaceDelimitedArray{"foo", "bar"}, + } + + values := make(url.Values) + NewEncoder().Encode(a, values) + assert.Equal(t, url.Values{"scope": []string{"foo bar"}}, values) + + var b request + schema.NewDecoder().Decode(&b, values) + assert.Equal(t, a, b) +} diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go index ca68759..b3ac89d 100644 --- a/pkg/op/device_test.go +++ b/pkg/op/device_test.go @@ -98,8 +98,6 @@ func TestParseDeviceCodeRequest(t *testing.T) { name: "empty request", wantErr: true, }, - /* decoding a SpaceDelimitedArray is broken - https://github.com/zitadel/oidc/issues/295 { name: "success", req: &oidc.DeviceAuthorizationRequest{ @@ -107,7 +105,6 @@ func TestParseDeviceCodeRequest(t *testing.T) { ClientID: "web", }, }, - */ } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/op/op.go b/pkg/op/op.go index 2859722..bb45425 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -189,7 +189,7 @@ func newProvider(ctx context.Context, config *Config, storage Storage, issuer fu o.decoder = schema.NewDecoder() o.decoder.IgnoreUnknownKeys(true) - o.encoder = schema.NewEncoder() + o.encoder = oidc.NewEncoder() o.crypto = NewAESCrypto(config.CryptoKey) From 4bd2b742f909beb57e226ec7108b61338ecbe2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 8 Mar 2023 11:43:47 +0200 Subject: [PATCH 16/21] chore: remove unused context in NewOpenIDProvider BREAKING CHANGE: - op.NewOpenIDProvider - op.NewDynamicOpenIDProvider The call chain of above functions did not use the context anywhere. This change removes the context from those fucntion arguments. --- example/server/dynamic/op.go | 2 +- example/server/exampleop/op.go | 9 ++++----- example/server/main.go | 6 +----- pkg/client/integration_test.go | 7 ++----- pkg/op/device_test.go | 2 +- pkg/op/op.go | 10 +++++----- 6 files changed, 14 insertions(+), 22 deletions(-) diff --git a/example/server/dynamic/op.go b/example/server/dynamic/op.go index 02c12b2..783c75c 100644 --- a/example/server/dynamic/op.go +++ b/example/server/dynamic/op.go @@ -125,7 +125,7 @@ func newDynamicOP(ctx context.Context, storage op.Storage, key [32]byte) (*op.Pr //this example has only static texts (in English), so we'll set the here accordingly SupportedUILocales: []language.Tag{language.English}, } - handler, err := op.NewDynamicOpenIDProvider(ctx, "/", config, storage, + handler, err := op.NewDynamicOpenIDProvider("/", config, storage, //we must explicitly allow the use of the http issuer op.WithAllowInsecure(), //as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth diff --git a/example/server/exampleop/op.go b/example/server/exampleop/op.go index b46be7f..5604483 100644 --- a/example/server/exampleop/op.go +++ b/example/server/exampleop/op.go @@ -1,7 +1,6 @@ package exampleop import ( - "context" "crypto/sha256" "log" "net/http" @@ -35,7 +34,7 @@ type Storage interface { // SetupServer creates an OIDC server with Issuer=http://localhost: // // Use one of the pre-made clients in storage/clients.go or register a new one. -func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Router { +func SetupServer(issuer string, storage Storage) *mux.Router { // the OpenID Provider requires a 32-byte key for (token) encryption // be sure to create a proper crypto random key and manage it securely! key := sha256.Sum256([]byte("test")) @@ -51,7 +50,7 @@ func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Route }) // creation of the OpenIDProvider with the just created in-memory Storage - provider, err := newOP(ctx, storage, issuer, key) + provider, err := newOP(storage, issuer, key) if err != nil { log.Fatal(err) } @@ -80,7 +79,7 @@ func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Route // newOP will create an OpenID Provider for localhost on a specified port with a given encryption key // and a predefined default logout uri // it will enable all options (see descriptions) -func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte) (op.OpenIDProvider, error) { +func newOP(storage op.Storage, issuer string, key [32]byte) (op.OpenIDProvider, error) { config := &op.Config{ CryptoKey: key, @@ -112,7 +111,7 @@ func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte) UserCode: op.UserCodeBase20, }, } - handler, err := op.NewOpenIDProvider(ctx, issuer, config, storage, + handler, err := op.NewOpenIDProvider(issuer, config, storage, //we must explicitly allow the use of the http issuer op.WithAllowInsecure(), // as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth diff --git a/example/server/main.go b/example/server/main.go index 6b40305..a2836ea 100644 --- a/example/server/main.go +++ b/example/server/main.go @@ -1,7 +1,6 @@ package main import ( - "context" "fmt" "log" "net/http" @@ -11,8 +10,6 @@ import ( ) func main() { - ctx := context.Background() - //we will run on :9998 port := "9998" //which gives us the issuer: http://localhost:9998/ @@ -23,7 +20,7 @@ func main() { // in this example it will be handled in-memory storage := storage.NewStorage(storage.NewUserStore(issuer)) - router := exampleop.SetupServer(ctx, issuer, storage) + router := exampleop.SetupServer(issuer, storage) server := &http.Server{ Addr: ":" + port, @@ -35,5 +32,4 @@ func main() { if err != nil { log.Fatal(err) } - <-ctx.Done() } diff --git a/pkg/client/integration_test.go b/pkg/client/integration_test.go index e89004a..f112b30 100644 --- a/pkg/client/integration_test.go +++ b/pkg/client/integration_test.go @@ -2,7 +2,6 @@ package client_test import ( "bytes" - "context" "io" "io/ioutil" "math/rand" @@ -30,14 +29,13 @@ import ( func TestRelyingPartySession(t *testing.T) { t.Log("------- start example OP ------") - ctx := context.Background() targetURL := "http://local-site" exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) var dh deferredHandler opServer := httptest.NewServer(&dh) defer opServer.Close() t.Logf("auth server at %s", opServer.URL) - dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) + dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage) seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) @@ -79,14 +77,13 @@ func TestRelyingPartySession(t *testing.T) { func TestResourceServerTokenExchange(t *testing.T) { t.Log("------- start example OP ------") - ctx := context.Background() targetURL := "http://local-site" exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) var dh deferredHandler opServer := httptest.NewServer(&dh) defer opServer.Close() t.Logf("auth server at %s", opServer.URL) - dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) + dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage) seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go index b3ac89d..de16a59 100644 --- a/pkg/op/device_test.go +++ b/pkg/op/device_test.go @@ -54,7 +54,7 @@ func init() { ) var err error - testProvider, err = op.NewOpenIDProvider(context.TODO(), testIssuer, config, + testProvider, err = op.NewOpenIDProvider(testIssuer, config, storage.NewStorage(storage.NewUserStore(testIssuer)), op.WithAllowInsecure(), ) if err != nil { diff --git a/pkg/op/op.go b/pkg/op/op.go index bb45425..ecb753e 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -157,15 +157,15 @@ type endpoints struct { // Successful logins should mark the request as authorized and redirect back to to // op.AuthCallbackURL(provider) which is probably /callback. On the redirect back // to the AuthCallbackURL, the request id should be passed as the "id" parameter. -func NewOpenIDProvider(ctx context.Context, issuer string, config *Config, storage Storage, opOpts ...Option) (*Provider, error) { - return newProvider(ctx, config, storage, StaticIssuer(issuer), opOpts...) +func NewOpenIDProvider(issuer string, config *Config, storage Storage, opOpts ...Option) (*Provider, error) { + return newProvider(config, storage, StaticIssuer(issuer), opOpts...) } -func NewDynamicOpenIDProvider(ctx context.Context, path string, config *Config, storage Storage, opOpts ...Option) (*Provider, error) { - return newProvider(ctx, config, storage, IssuerFromHost(path), opOpts...) +func NewDynamicOpenIDProvider(path string, config *Config, storage Storage, opOpts ...Option) (*Provider, error) { + return newProvider(config, storage, IssuerFromHost(path), opOpts...) } -func newProvider(ctx context.Context, config *Config, storage Storage, issuer func(bool) (IssuerFromRequest, error), opOpts ...Option) (_ *Provider, err error) { +func newProvider(config *Config, storage Storage, issuer func(bool) (IssuerFromRequest, error), opOpts ...Option) (_ *Provider, err error) { o := &Provider{ config: config, storage: storage, 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 17/21] 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) + }) + } +} From 711a194b5036ce618bd6ab8ca29b5f9653c10cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Mon, 6 Mar 2023 19:21:24 +0200 Subject: [PATCH 18/21] fix: allow RFC3339 encoded time strings Fixes #292 --- pkg/oidc/types.go | 24 +++++++++++++++++++ pkg/oidc/types_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/pkg/oidc/types.go b/pkg/oidc/types.go index 415ab04..cb513a0 100644 --- a/pkg/oidc/types.go +++ b/pkg/oidc/types.go @@ -184,6 +184,30 @@ func NowTime() Time { return FromTime(time.Now()) } +func (ts *Time) UnmarshalJSON(data []byte) error { + var v any + if err := json.Unmarshal(data, &v); err != nil { + return fmt.Errorf("oidc.Time: %w", err) + } + switch x := v.(type) { + case float64: + *ts = Time(x) + case string: + // Compatibility with Auth0: + // https://github.com/zitadel/oidc/issues/292 + tt, err := time.Parse(time.RFC3339, x) + if err != nil { + return fmt.Errorf("oidc.Time: %w", err) + } + *ts = FromTime(tt) + case nil: + *ts = 0 + default: + return fmt.Errorf("oidc.Time: unable to parse type %T with value %v", x, x) + } + return nil +} + type RequestObject struct { Issuer string `json:"iss"` Audience Audience `json:"aud"` diff --git a/pkg/oidc/types_test.go b/pkg/oidc/types_test.go index c8e2801..2721e0b 100644 --- a/pkg/oidc/types_test.go +++ b/pkg/oidc/types_test.go @@ -466,3 +466,57 @@ func TestNewEncoder(t *testing.T) { schema.NewDecoder().Decode(&b, values) assert.Equal(t, a, b) } + +func TestTime_UnmarshalJSON(t *testing.T) { + type dst struct { + UpdatedAt Time `json:"updated_at"` + } + tests := []struct { + name string + json string + want dst + wantErr bool + }{ + { + name: "RFC3339", // https://github.com/zitadel/oidc/issues/292 + json: `{"updated_at": "2021-05-11T21:13:25.566Z"}`, + want: dst{UpdatedAt: 1620767605}, + }, + { + name: "int", + json: `{"updated_at":1620767605}`, + want: dst{UpdatedAt: 1620767605}, + }, + { + name: "time parse error", + json: `{"updated_at":"foo"}`, + wantErr: true, + }, + { + name: "null", + json: `{"updated_at":null}`, + }, + { + name: "invalid type", + json: `{"updated_at":["foo","bar"]}`, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got dst + err := json.Unmarshal([]byte(tt.json), &got) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } + t.Run("syntax error", func(t *testing.T) { + var ts Time + err := ts.UnmarshalJSON([]byte{'~'}) + assert.Error(t, err) + }) +} From 26d8e326361d1cdf60eaabe955d04e573578695a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 15 Mar 2023 15:32:14 +0200 Subject: [PATCH 19/21] chore: test all routes Co-authored-by: David Sharnoff --- example/server/storage/oidc.go | 8 +- example/server/storage/storage.go | 58 ++++- go.mod | 2 +- go.sum | 4 +- pkg/op/context.go | 22 +- pkg/op/device_test.go | 43 ---- pkg/op/op_test.go | 392 ++++++++++++++++++++++++++++++ 7 files changed, 467 insertions(+), 62 deletions(-) create mode 100644 pkg/op/op_test.go diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go index 83db739..f5412cf 100644 --- a/example/server/storage/oidc.go +++ b/example/server/storage/oidc.go @@ -37,8 +37,8 @@ type AuthRequest struct { Nonce string CodeChallenge *OIDCCodeChallenge - passwordChecked bool - authTime time.Time + done bool + authTime time.Time } func (a *AuthRequest) GetID() string { @@ -51,7 +51,7 @@ func (a *AuthRequest) GetACR() string { func (a *AuthRequest) GetAMR() []string { // this example only uses password for authentication - if a.passwordChecked { + if a.done { return []string{"pwd"} } return nil @@ -102,7 +102,7 @@ func (a *AuthRequest) GetSubject() string { } func (a *AuthRequest) Done() bool { - return a.passwordChecked // this example only uses password for authentication + return a.done } func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string { diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index ff7889e..7e1afbd 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -28,8 +28,8 @@ var serviceKey1 = &rsa.PublicKey{ E: 65537, } -// var _ op.Storage = &storage{} -// var _ op.ClientCredentialsStorage = &storage{} +var _ op.Storage = &Storage{} +var _ op.ClientCredentialsStorage = &Storage{} // storage implements the op.Storage interface // typically you would implement this as a layer on top of your database @@ -46,6 +46,7 @@ type Storage struct { signingKey signingKey deviceCodes map[string]deviceAuthorizationEntry userCodes map[string]string + serviceUsers map[string]*Client } type signingKey struct { @@ -109,6 +110,16 @@ func NewStorage(userStore UserStore) *Storage { }, deviceCodes: make(map[string]deviceAuthorizationEntry), userCodes: make(map[string]string), + serviceUsers: map[string]*Client{ + "sid1": { + id: "sid1", + secret: "verysecret", + grantTypes: []oidc.GrantType{ + oidc.GrantTypeClientCredentials, + }, + accessTokenType: op.AccessTokenTypeBearer, + }, + }, } } @@ -133,7 +144,7 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error { // you will have to change some state on the request to guide the user through possible multiple steps of the login process // in this example we'll simply check the username / password and set a boolean to true // therefore we will also just check this boolean if the request / login has been finished - request.passwordChecked = true + request.done = true return nil } return fmt.Errorf("username or password wrong") @@ -847,3 +858,44 @@ func (s *Storage) DenyDeviceAuthorization(ctx context.Context, userCode string) s.deviceCodes[s.userCodes[userCode]].state.Denied = true return nil } + +// AuthRequestDone is used by testing and is not required to implement op.Storage +func (s *Storage) AuthRequestDone(id string) error { + s.lock.Lock() + defer s.lock.Unlock() + + if req, ok := s.authRequests[id]; ok { + req.done = true + return nil + } + + return errors.New("request not found") +} + +func (s *Storage) ClientCredentials(ctx context.Context, clientID, clientSecret string) (op.Client, error) { + s.lock.Lock() + defer s.lock.Unlock() + + client, ok := s.serviceUsers[clientID] + if !ok { + return nil, errors.New("wrong service user or password") + } + if client.secret != clientSecret { + return nil, errors.New("wrong service user or password") + } + + return client, nil +} + +func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (op.TokenRequest, error) { + client, ok := s.serviceUsers[clientID] + if !ok { + return nil, errors.New("wrong service user or password") + } + + return &oidc.JWTTokenRequest{ + Subject: client.id, + Audience: []string{clientID}, + Scopes: scopes, + }, nil +} diff --git a/go.mod b/go.mod index 9ed1e8e..50167f7 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/gorilla/schema v1.2.0 github.com/gorilla/securecookie v1.1.1 github.com/jeremija/gosubmit v0.2.7 - github.com/muhlemmer/gu v0.3.0 + github.com/muhlemmer/gu v0.3.1 github.com/rs/cors v1.8.3 github.com/sirupsen/logrus v1.9.0 github.com/stretchr/testify v1.8.1 diff --git a/go.sum b/go.sum index 1933228..a5cf579 100644 --- a/go.sum +++ b/go.sum @@ -123,8 +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/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/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= diff --git a/pkg/op/context.go b/pkg/op/context.go index 4406273..7cff5a7 100644 --- a/pkg/op/context.go +++ b/pkg/op/context.go @@ -7,16 +7,16 @@ import ( type key int -var ( - issuer key = 0 +const ( + issuerKey key = 0 ) type IssuerInterceptor struct { issuerFromRequest IssuerFromRequest } -//NewIssuerInterceptor will set the issuer into the context -//by the provided IssuerFromRequest (e.g. returned from StaticIssuer or IssuerFromHost) +// NewIssuerInterceptor will set the issuer into the context +// by the provided IssuerFromRequest (e.g. returned from StaticIssuer or IssuerFromHost) func NewIssuerInterceptor(issuerFromRequest IssuerFromRequest) *IssuerInterceptor { return &IssuerInterceptor{ issuerFromRequest: issuerFromRequest, @@ -35,15 +35,19 @@ func (i *IssuerInterceptor) HandlerFunc(next http.HandlerFunc) http.HandlerFunc } } -//IssuerFromContext reads the issuer from the context (set by an IssuerInterceptor) -//it will return an empty string if not found +// IssuerFromContext reads the issuer from the context (set by an IssuerInterceptor) +// it will return an empty string if not found func IssuerFromContext(ctx context.Context) string { - ctxIssuer, _ := ctx.Value(issuer).(string) + ctxIssuer, _ := ctx.Value(issuerKey).(string) return ctxIssuer } +// ContextWithIssuer returns a new context with issuer set to it. +func ContextWithIssuer(ctx context.Context, issuer string) context.Context { + return context.WithValue(ctx, issuerKey, issuer) +} + func (i *IssuerInterceptor) setIssuerCtx(w http.ResponseWriter, r *http.Request, next http.Handler) { - ctx := context.WithValue(r.Context(), issuer, i.issuerFromRequest(r)) - r = r.WithContext(ctx) + r = r.WithContext(ContextWithIssuer(r.Context(), i.issuerFromRequest(r))) next.ServeHTTP(w, r) } diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go index de16a59..69ba102 100644 --- a/pkg/op/device_test.go +++ b/pkg/op/device_test.go @@ -3,7 +3,6 @@ package op_test import ( "context" "crypto/rand" - "crypto/sha256" "encoding/base64" "io" mr "math/rand" @@ -16,52 +15,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/oidc/v2/example/server/storage" "github.com/zitadel/oidc/v2/pkg/oidc" "github.com/zitadel/oidc/v2/pkg/op" - "golang.org/x/text/language" ) -var testProvider op.OpenIDProvider - -const ( - testIssuer = "https://localhost:9998/" - pathLoggedOut = "/logged-out" -) - -func init() { - config := &op.Config{ - CryptoKey: sha256.Sum256([]byte("test")), - DefaultLogoutRedirectURI: pathLoggedOut, - CodeMethodS256: true, - AuthMethodPost: true, - AuthMethodPrivateKeyJWT: true, - GrantTypeRefreshToken: true, - RequestObjectSupported: true, - SupportedUILocales: []language.Tag{language.English}, - DeviceAuthorization: op.DeviceAuthorizationConfig{ - Lifetime: 5 * time.Minute, - PollInterval: 5 * time.Second, - UserFormURL: testIssuer + "device", - UserCode: op.UserCodeBase20, - }, - } - - storage.RegisterClients( - storage.NativeClient("native"), - storage.WebClient("web", "secret"), - storage.WebClient("api", "secret"), - ) - - var err error - testProvider, err = op.NewOpenIDProvider(testIssuer, config, - storage.NewStorage(storage.NewUserStore(testIssuer)), op.WithAllowInsecure(), - ) - if err != nil { - panic(err) - } -} - func Test_deviceAuthorizationHandler(t *testing.T) { req := &oidc.DeviceAuthorizationRequest{ Scopes: []string{"foo", "bar"}, diff --git a/pkg/op/op_test.go b/pkg/op/op_test.go new file mode 100644 index 0000000..ba3570b --- /dev/null +++ b/pkg/op/op_test.go @@ -0,0 +1,392 @@ +package op_test + +import ( + "context" + "crypto/sha256" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/example/server/storage" + "github.com/zitadel/oidc/v2/pkg/oidc" + "github.com/zitadel/oidc/v2/pkg/op" + "golang.org/x/text/language" +) + +var testProvider op.OpenIDProvider + +const ( + testIssuer = "https://localhost:9998/" + pathLoggedOut = "/logged-out" +) + +func init() { + config := &op.Config{ + CryptoKey: sha256.Sum256([]byte("test")), + DefaultLogoutRedirectURI: pathLoggedOut, + CodeMethodS256: true, + AuthMethodPost: true, + AuthMethodPrivateKeyJWT: true, + GrantTypeRefreshToken: true, + RequestObjectSupported: true, + SupportedUILocales: []language.Tag{language.English}, + DeviceAuthorization: op.DeviceAuthorizationConfig{ + Lifetime: 5 * time.Minute, + PollInterval: 5 * time.Second, + UserFormURL: testIssuer + "device", + UserCode: op.UserCodeBase20, + }, + } + + storage.RegisterClients( + storage.NativeClient("native"), + storage.WebClient("web", "secret", "https://example.com"), + storage.WebClient("api", "secret"), + ) + + var err error + testProvider, err = op.NewOpenIDProvider(testIssuer, config, + storage.NewStorage(storage.NewUserStore(testIssuer)), op.WithAllowInsecure(), + ) + if err != nil { + panic(err) + } +} + +type routesTestStorage interface { + op.Storage + AuthRequestDone(id string) error +} + +func mapAsValues(m map[string]string) string { + values := make(url.Values, len(m)) + for k, v := range m { + values.Set(k, v) + } + return values.Encode() +} + +func TestRoutes(t *testing.T) { + storage := testProvider.Storage().(routesTestStorage) + ctx := op.ContextWithIssuer(context.Background(), testIssuer) + + client, err := storage.GetClientByClientID(ctx, "web") + require.NoError(t, err) + + oidcAuthReq := &oidc.AuthRequest{ + ClientID: client.GetID(), + RedirectURI: "https://example.com", + MaxAge: gu.Ptr[uint](300), + Scopes: oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, oidc.ScopeEmail, oidc.ScopeProfile, oidc.ScopePhone}, + ResponseType: oidc.ResponseTypeCode, + } + + authReq, err := storage.CreateAuthRequest(ctx, oidcAuthReq, "id1") + require.NoError(t, err) + storage.AuthRequestDone(authReq.GetID()) + + accessToken, refreshToken, _, err := op.CreateAccessToken(ctx, authReq, op.AccessTokenTypeBearer, testProvider, client, "") + require.NoError(t, err) + accessTokenRevoke, _, _, err := op.CreateAccessToken(ctx, authReq, op.AccessTokenTypeBearer, testProvider, client, "") + require.NoError(t, err) + idToken, err := op.CreateIDToken(ctx, testIssuer, authReq, time.Hour, accessToken, "123", storage, client) + require.NoError(t, err) + jwtToken, _, _, err := op.CreateAccessToken(ctx, authReq, op.AccessTokenTypeJWT, testProvider, client, "") + require.NoError(t, err) + + oidcAuthReq.IDTokenHint = idToken + + serverURL, err := url.Parse(testIssuer) + require.NoError(t, err) + + type basicAuth struct { + username, password string + } + + tests := []struct { + name string + method string + path string + basicAuth *basicAuth + header map[string]string + values map[string]string + body map[string]string + wantCode int + headerContains map[string]string + json string // test for exact json output + contains []string // when the body output is not constant, we just check for snippets to be present in the response + }{ + { + name: "health", + method: http.MethodGet, + path: "/healthz", + wantCode: http.StatusOK, + json: `{"status":"ok"}`, + }, + { + name: "ready", + method: http.MethodGet, + path: "/ready", + wantCode: http.StatusOK, + json: `{"status":"ok"}`, + }, + { + name: "discovery", + method: http.MethodGet, + path: oidc.DiscoveryEndpoint, + wantCode: http.StatusOK, + json: `{"issuer":"https://localhost:9998/","authorization_endpoint":"https://localhost:9998/authorize","token_endpoint":"https://localhost:9998/oauth/token","introspection_endpoint":"https://localhost:9998/oauth/introspect","userinfo_endpoint":"https://localhost:9998/userinfo","revocation_endpoint":"https://localhost:9998/revoke","end_session_endpoint":"https://localhost:9998/end_session","device_authorization_endpoint":"https://localhost:9998/device_authorization","jwks_uri":"https://localhost:9998/keys","scopes_supported":["openid","profile","email","phone","address","offline_access"],"response_types_supported":["code","id_token","id_token token"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials","urn:ietf:params:oauth:grant-type:token-exchange","urn:ietf:params:oauth:grant-type:jwt-bearer","urn:ietf:params:oauth:grant-type:device_code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"request_object_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"revocation_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post","private_key_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["RS256"],"introspection_endpoint_auth_methods_supported":["client_secret_basic","private_key_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["RS256"],"claims_supported":["sub","aud","exp","iat","iss","auth_time","nonce","acr","amr","c_hash","at_hash","act","scopes","client_id","azp","preferred_username","name","family_name","given_name","locale","email","email_verified","phone_number","phone_number_verified"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en"],"request_parameter_supported":true,"request_uri_parameter_supported":false}`, + }, + { + name: "authorization", + method: http.MethodGet, + path: testProvider.AuthorizationEndpoint().Relative(), + values: map[string]string{ + "client_id": client.GetID(), + "redirect_uri": "https://example.com", + "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(), + "response_type": string(oidc.ResponseTypeCode), + }, + wantCode: http.StatusFound, + headerContains: map[string]string{"Location": "/login/username?authRequestID="}, + }, + { + name: "authorization callback", + method: http.MethodGet, + path: testProvider.AuthorizationEndpoint().Relative() + "/callback", + values: map[string]string{"id": authReq.GetID()}, + wantCode: http.StatusFound, + headerContains: map[string]string{"Location": "https://example.com?code="}, + contains: []string{ + `Found.", + }, + }, + { + // This call will fail. A successfull test is already + // part of client/integration_test.go + name: "code exchange", + method: http.MethodGet, + path: testProvider.TokenEndpoint().Relative(), + values: map[string]string{ + "grant_type": string(oidc.GrantTypeCode), + "code": "123", + }, + wantCode: http.StatusUnauthorized, + json: `{"error":"invalid_client"}`, + }, + { + name: "JWT authorization", + method: http.MethodGet, + path: testProvider.TokenEndpoint().Relative(), + values: map[string]string{ + "grant_type": string(oidc.GrantTypeBearer), + "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(), + "assertion": jwtToken, + }, + wantCode: http.StatusBadRequest, + json: "{\"error\":\"server_error\",\"error_description\":\"audience is not valid: Audience must contain client_id \\\"https://localhost:9998/\\\"\"}", + }, + { + name: "Token exchange", + method: http.MethodGet, + path: testProvider.TokenEndpoint().Relative(), + basicAuth: &basicAuth{"web", "secret"}, + values: map[string]string{ + "grant_type": string(oidc.GrantTypeTokenExchange), + "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(), + "subject_token": jwtToken, + "subject_token_type": string(oidc.AccessTokenType), + }, + wantCode: http.StatusOK, + contains: []string{ + `{"access_token":"`, + `","issued_token_type":"urn:ietf:params:oauth:token-type:refresh_token","token_type":"Bearer","expires_in":299,"scope":"openid offline_access","refresh_token":"`, + }, + }, + { + name: "Client credentials exchange", + method: http.MethodGet, + path: testProvider.TokenEndpoint().Relative(), + basicAuth: &basicAuth{"sid1", "verysecret"}, + values: map[string]string{ + "grant_type": string(oidc.GrantTypeClientCredentials), + "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(), + }, + wantCode: http.StatusOK, + contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299}`}, + }, + { + // This call will fail. A successfull test is already + // part of device_test.go + name: "device token", + method: http.MethodPost, + path: testProvider.TokenEndpoint().Relative(), + basicAuth: &basicAuth{"web", "secret"}, + header: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + body: map[string]string{ + "grant_type": string(oidc.GrantTypeDeviceCode), + "device_code": "123", + }, + wantCode: http.StatusBadRequest, + json: `{"error":"access_denied","error_description":"The authorization request was denied."}`, + }, + { + name: "missing grant type", + method: http.MethodGet, + path: testProvider.TokenEndpoint().Relative(), + wantCode: http.StatusBadRequest, + json: `{"error":"invalid_request","error_description":"grant_type missing"}`, + }, + { + name: "unsupported grant type", + method: http.MethodGet, + path: testProvider.TokenEndpoint().Relative(), + values: map[string]string{ + "grant_type": "foo", + }, + wantCode: http.StatusBadRequest, + json: `{"error":"unsupported_grant_type","error_description":"foo not supported"}`, + }, + { + name: "introspection", + method: http.MethodGet, + path: testProvider.IntrospectionEndpoint().Relative(), + basicAuth: &basicAuth{"web", "secret"}, + values: map[string]string{ + "token": accessToken, + }, + wantCode: http.StatusOK, + json: `{"active":true,"scope":"openid offline_access email profile phone","client_id":"web","sub":"id1","username":"test-user@localhost","name":"Test User","given_name":"Test","family_name":"User","locale":"de","preferred_username":"test-user@localhost","email":"test-user@zitadel.ch","email_verified":true}`, + }, + { + name: "user info", + method: http.MethodGet, + path: testProvider.UserinfoEndpoint().Relative(), + header: map[string]string{ + "authorization": "Bearer " + accessToken, + }, + wantCode: http.StatusOK, + json: `{"sub":"id1","name":"Test User","given_name":"Test","family_name":"User","locale":"de","preferred_username":"test-user@localhost","email":"test-user@zitadel.ch","email_verified":true}`, + }, + { + name: "refresh token", + method: http.MethodGet, + path: testProvider.TokenEndpoint().Relative(), + values: map[string]string{ + "grant_type": string(oidc.GrantTypeRefreshToken), + "refresh_token": refreshToken, + "client_id": client.GetID(), + "client_secret": "secret", + }, + wantCode: http.StatusOK, + contains: []string{ + `{"access_token":"`, + `","token_type":"Bearer","refresh_token":"`, + `","expires_in":299,"id_token":"`, + }, + }, + { + name: "revoke", + method: http.MethodGet, + path: testProvider.RevocationEndpoint().Relative(), + basicAuth: &basicAuth{"web", "secret"}, + values: map[string]string{ + "token": accessTokenRevoke, + }, + wantCode: http.StatusOK, + }, + { + name: "end session", + method: http.MethodGet, + path: testProvider.EndSessionEndpoint().Relative(), + values: map[string]string{ + "id_token_hint": idToken, + "client_id": "web", + }, + wantCode: http.StatusFound, + headerContains: map[string]string{"Location": "/logged-out"}, + contains: []string{`Found.`}, + }, + { + name: "keys", + method: http.MethodGet, + path: testProvider.KeysEndpoint().Relative(), + wantCode: http.StatusOK, + contains: []string{ + `{"keys":[{"use":"sig","kty":"RSA","kid":"`, + `","alg":"RS256","n":"`, `","e":"AQAB"}]}`, + }, + }, + { + name: "device authorization", + method: http.MethodGet, + path: testProvider.DeviceAuthorizationEndpoint().Relative(), + basicAuth: &basicAuth{"web", "secret"}, + values: map[string]string{ + "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.Encode(), + }, + wantCode: http.StatusOK, + contains: []string{ + `{"device_code":"`, `","user_code":"`, + `","verification_uri":"https://localhost:9998/device"`, + `"verification_uri_complete":"https://localhost:9998/device?user_code=`, + `","expires_in":300,"interval":5}`, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := gu.PtrCopy(serverURL) + u.Path = tt.path + if tt.values != nil { + u.RawQuery = mapAsValues(tt.values) + } + var body io.Reader + if tt.body != nil { + body = strings.NewReader(mapAsValues(tt.body)) + } + + req := httptest.NewRequest(tt.method, u.String(), body) + for k, v := range tt.header { + req.Header.Set(k, v) + } + if tt.basicAuth != nil { + req.SetBasicAuth(tt.basicAuth.username, tt.basicAuth.password) + } + + rec := httptest.NewRecorder() + testProvider.HttpHandler().ServeHTTP(rec, req) + + resp := rec.Result() + require.NoError(t, err) + assert.Equal(t, tt.wantCode, resp.StatusCode) + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + respBodyString := string(respBody) + t.Log(respBodyString) + t.Log(resp.Header) + + if tt.json != "" { + assert.JSONEq(t, tt.json, respBodyString) + } + for _, c := range tt.contains { + assert.Contains(t, respBodyString, c) + } + for k, v := range tt.headerContains { + assert.Contains(t, resp.Header.Get(k), v) + } + }) + } +} From 0f3d4f4828e028d6a4a915990af556bbccda51c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 15 Mar 2023 15:37:02 +0200 Subject: [PATCH 20/21] chore: update all modules (#321) --- go.mod | 25 ++-- go.sum | 375 +++++---------------------------------------------------- 2 files changed, 42 insertions(+), 358 deletions(-) diff --git a/go.mod b/go.mod index 50167f7..7594264 100644 --- a/go.mod +++ b/go.mod @@ -13,24 +13,23 @@ require ( github.com/muhlemmer/gu v0.3.1 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 + github.com/stretchr/testify v1.8.2 + golang.org/x/oauth2 v0.6.0 + golang.org/x/text v0.8.0 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/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-querystring v1.1.0 // 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 + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/sys v0.6.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.29.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a5cf579..61083fb 100644 --- a/go.sum +++ b/go.sum @@ -1,125 +1,34 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo= github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM= -github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 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= @@ -129,8 +38,6 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb 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= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/cors v1.8.3 h1:O+qNyWn7Z+F9M0ILBHgMVPuB1xTOucVd5gtaYyXBpRo= github.com/rs/cors v1.8.3/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= @@ -138,285 +45,63 @@ github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -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= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -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/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -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= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -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-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= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-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/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= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-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/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -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.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= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -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/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= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.29.0 h1:44S3JjaKmLEE4YIkjzexaP+NzZsudE3Zin5Njn/pYX0= +google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= From c6820ba88a24df28bd2c6e542183f845b22a900f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 15 Mar 2023 15:44:49 +0200 Subject: [PATCH 21/21] fix: unmarshalling of scopes in access token (#327) The Scopes field in accessTokenClaims should be a SpaceDelimitedArray, in order to allow for correct unmarshalling. Fixes #318 * adjust test data --- pkg/oidc/regression_data/oidc.AccessTokenClaims.json | 5 +---- pkg/oidc/token.go | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/oidc/regression_data/oidc.AccessTokenClaims.json b/pkg/oidc/regression_data/oidc.AccessTokenClaims.json index e4f7808..b63bf30 100644 --- a/pkg/oidc/regression_data/oidc.AccessTokenClaims.json +++ b/pkg/oidc/regression_data/oidc.AccessTokenClaims.json @@ -13,10 +13,7 @@ "some", "methods" ], - "scope": [ - "email", - "phone" - ], + "scope": "email phone", "client_id": "777", "exp": 12345, "iat": 12000, diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index 1ade913..b017023 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -97,8 +97,8 @@ func (c *TokenClaims) SetSignatureAlgorithm(algorithm jose.SignatureAlgorithm) { type AccessTokenClaims struct { TokenClaims - Scopes []string `json:"scope,omitempty"` - Claims map[string]any `json:"-"` + Scopes SpaceDelimitedArray `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 {