diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 92465f9..d024341 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -2,6 +2,7 @@ name: Bug Report description: "Create a bug report to help us improve ZITADEL. Click [here](https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#product-management) to see how we process your issue." title: "[Bug]: " labels: ["bug"] +type: Bug body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/docs.yaml b/.github/ISSUE_TEMPLATE/docs.yaml index 04c1c0c..d3f82b9 100644 --- a/.github/ISSUE_TEMPLATE/docs.yaml +++ b/.github/ISSUE_TEMPLATE/docs.yaml @@ -1,6 +1,7 @@ name: 📄 Documentation description: Create an issue for missing or wrong documentation. labels: ["docs"] +type: task body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/improvement.yaml b/.github/ISSUE_TEMPLATE/enhancement.yaml similarity index 92% rename from .github/ISSUE_TEMPLATE/improvement.yaml rename to .github/ISSUE_TEMPLATE/enhancement.yaml index 2e2ddf4..ef2103e 100644 --- a/.github/ISSUE_TEMPLATE/improvement.yaml +++ b/.github/ISSUE_TEMPLATE/enhancement.yaml @@ -1,11 +1,12 @@ name: đŸ› ī¸ Improvement description: "Create an new issue for an improvment in ZITADEL" -labels: ["improvement"] +labels: ["enhancement"] +type: enhancement body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this improvement request + Thanks for taking the time to fill out this proposal / feature reqeust - type: checkboxes id: preflight attributes: diff --git a/.github/ISSUE_TEMPLATE/proposal.yaml b/.github/ISSUE_TEMPLATE/proposal.yaml deleted file mode 100644 index af7acd5..0000000 --- a/.github/ISSUE_TEMPLATE/proposal.yaml +++ /dev/null @@ -1,44 +0,0 @@ -name: 💡 Proposal / Feature request -description: "Create an issue for a feature request/proposal." -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this proposal / feature reqeust - - type: checkboxes - id: preflight - attributes: - label: Preflight Checklist - options: - - label: - I could not find a solution in the existing issues, docs, nor discussions - required: true - - label: - I have joined the [ZITADEL chat](https://zitadel.com/chat) - - type: textarea - id: problem - attributes: - label: Describe your problem - description: Please describe your problem this proposal / feature is supposed to solve. - placeholder: Describe the problem you have. - validations: - required: true - - type: textarea - id: solution - attributes: - label: Describe your ideal solution - description: Which solution do you propose? - placeholder: As a [type of user], I want [some goal] so that [some reason]. - validations: - required: true - - type: input - id: version - attributes: - label: Version - description: Which version of the OIDC Library are you using. - - type: textarea - id: additional - attributes: - label: Additional Context - description: Please add any other infos that could be useful. diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 27fa244..8699d84 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. @@ -29,7 +29,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 # Override language selection by uncommenting this and choosing your languages with: languages: go @@ -37,7 +37,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -51,4 +51,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index d328058..480c339 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: add issue - uses: actions/add-to-project@v1 + uses: actions/add-to-project@v1.0.2 if: ${{ github.event_name == 'issues' }} with: # You can target a repository in a different organization @@ -28,7 +28,7 @@ jobs: username: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} - name: add pr - uses: actions/add-to-project@v1 + uses: actions/add-to-project@v1.0.2 if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} with: # You can target a repository in a different organization diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 202596f..f8b5159 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,34 +14,34 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - go: ['1.21', '1.22'] + go: ['1.24', '1.25'] name: Go ${{ matrix.go }} test steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - run: go test -race -v -coverprofile=profile.cov -coverpkg=./pkg/... ./pkg/... - - uses: codecov/codecov-action@v4.1.1 + - uses: codecov/codecov-action@v5.5.1 with: file: ./profile.cov name: codecov-go release: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: [test] if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Source checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Semantic Release - uses: cycjimmy/semantic-release-action@v4 + uses: cycjimmy/semantic-release-action@v6 with: dry_run: false semantic_version: 18.0.1 diff --git a/README.md b/README.md index f76c2b2..037a53d 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,17 @@ The RP is certified for the [basic](https://www.certification.openid.net/plan-de Whenever possible we tried to reuse / extend existing packages like `OAuth2 for Go`. +> [!NOTE] +> We currently have limited availability for feature reviews: +https://github.com/zitadel/oidc/discussions/785 + ## Basic Overview The most important packages of the library: +
 /pkg
-    /client            clients using the OP for retrieving, exchanging and verifying tokens       
+    /client            clients using the OP for retrieving, exchanging and verifying tokens
         /rp            definition and implementation of an OIDC Relying Party (client)
         /rs            definition and implementation of an OAuth Resource Server (API)
     /op                definition and implementation of an OIDC OpenID Provider (server)
@@ -37,7 +42,6 @@ The most important packages of the library:
     /server            examples of an OpenID Provider implementations (including dynamic) with some very basic login UI
 
- ### Semver This package uses [semver](https://semver.org/) for [releases](https://github.com/zitadel/oidc/releases). Major releases ship breaking changes. Starting with the `v2` to `v3` increment we provide an [upgrade guide](UPGRADING.md) to ease migration to a newer version. @@ -55,48 +59,93 @@ CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998/ SCOPES="openid ``` - open http://localhost:9999/login in your browser -- you will be redirected to op server and the login UI +- you will be redirected to op server and the login UI - 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/v4/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/v4/example/client/app ``` > Note: Usernames are suffixed with the hostname (`test-user@localhost` or `test-user@oidc.local`) + +### Build Tags + +The library uses build tags to enable or disable features. The following build tags are available: + +| Build Tag | Description | +|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `no_otel` | Disables the OTel instrumentation, which is enabled by default. This is useful if you do not want to use OTel or if you want to use a different instrumentation library. | + +### Server configuration + +Example server allows extra configuration using environment variables and could be used for end to +end testing of your services. + +| Name | Format | Description | +| ------------ | -------------------------------- | ------------------------------------- | +| PORT | Number between 1 and 65535 | OIDC listen port | +| REDIRECT_URI | Comma-separated URIs | List of allowed redirect URIs | +| USERS_FILE | Path to json in local filesystem | Users with their data and credentials | + +Here is json equivalent for one of the default users + +```json +{ + "id2": { + "ID": "id2", + "Username": "test-user2", + "Password": "verysecure", + "FirstName": "Test", + "LastName": "User2", + "Email": "test-user2@zitadel.ch", + "EmailVerified": true, + "Phone": "", + "PhoneVerified": false, + "PreferredLanguage": "DE", + "IsAdmin": false + } +} +``` + ## Features -| | Relying party | OpenID Provider | Specification | -| -------------------- | ------------- | --------------- | ----------------------------------------- | -| Code Flow | yes | yes | OpenID Connect Core 1.0, [Section 3.1][1] | -| Implicit Flow | no[^1] | yes | OpenID Connect Core 1.0, [Section 3.2][2] | -| Hybrid Flow | no | not yet | OpenID Connect Core 1.0, [Section 3.3][3] | -| Client Credentials | yes | yes | OpenID Connect Core 1.0, [Section 9][4] | -| Refresh Token | yes | yes | OpenID Connect Core 1.0, [Section 12][5] | -| Discovery | yes | yes | OpenID Connect [Discovery][6] 1.0 | -| JWT Profile | yes | yes | [RFC 7523][7] | -| PKCE | yes | yes | [RFC 7636][8] | -| Token Exchange | yes | yes | [RFC 8693][9] | -| Device Authorization | yes | yes | [RFC 8628][10] | -| mTLS | not yet | not yet | [RFC 8705][11] | +| | Relying party | OpenID Provider | Specification | +| -------------------- | ------------- | --------------- | -------------------------------------------- | +| Code Flow | yes | yes | OpenID Connect Core 1.0, [Section 3.1][1] | +| Implicit Flow | no[^1] | yes | OpenID Connect Core 1.0, [Section 3.2][2] | +| Hybrid Flow | no | not yet | OpenID Connect Core 1.0, [Section 3.3][3] | +| Client Credentials | yes | yes | OpenID Connect Core 1.0, [Section 9][4] | +| Refresh Token | yes | yes | OpenID Connect Core 1.0, [Section 12][5] | +| Discovery | yes | yes | OpenID Connect [Discovery][6] 1.0 | +| JWT Profile | yes | yes | [RFC 7523][7] | +| PKCE | yes | yes | [RFC 7636][8] | +| Token Exchange | yes | yes | [RFC 8693][9] | +| Device Authorization | yes | yes | [RFC 8628][10] | +| mTLS | not yet | not yet | [RFC 8705][11] | +| Back-Channel Logout | not yet | yes | OpenID Connect [Back-Channel Logout][12] 1.0 | -[1]: "3.1. Authentication using the Authorization Code Flow" -[2]: "3.2. Authentication using the Implicit Flow" -[3]: "3.3. Authentication using the Hybrid Flow" -[4]: "9. Client Authentication" -[5]: "12. Using Refresh Tokens" -[6]: "OpenID Connect Discovery 1.0 incorporating errata set 1" -[7]: "JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants" -[8]: "Proof Key for Code Exchange by OAuth Public Clients" -[9]: "OAuth 2.0 Token Exchange" -[10]: "OAuth 2.0 Device Authorization Grant" -[11]: "OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens" +[1]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth "3.1. Authentication using the Authorization Code Flow" +[2]: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth "3.2. Authentication using the Implicit Flow" +[3]: https://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth "3.3. Authentication using the Hybrid Flow" +[4]: https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication "9. Client Authentication" +[5]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens "12. Using Refresh Tokens" +[6]: https://openid.net/specs/openid-connect-discovery-1_0.html "OpenID Connect Discovery 1.0 incorporating errata set 1" +[7]: https://www.rfc-editor.org/rfc/rfc7523.html "JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants" +[8]: https://www.rfc-editor.org/rfc/rfc7636.html "Proof Key for Code Exchange by OAuth Public Clients" +[9]: https://www.rfc-editor.org/rfc/rfc8693.html "OAuth 2.0 Token Exchange" +[10]: https://www.rfc-editor.org/rfc/rfc8628.html "OAuth 2.0 Device Authorization Grant" +[11]: https://www.rfc-editor.org/rfc/rfc8705.html "OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens" +[12]: https://openid.net/specs/openid-connect-backchannel-1_0.html "OpenID Connect Back-Channel Logout 1.0 incorporating errata set 1" ## Contributors @@ -115,14 +164,14 @@ For your convenience you can find the relevant guides linked below. ## Supported Go Versions -For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:). +For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:). Versions that also build are marked with :warning:. | Version | Supported | | ------- | ------------------ | -| <1.21 | :x: | -| 1.21 | :white_check_mark: | -| 1.22 | :white_check_mark: | +| <1.24 | :x: | +| 1.24 | :white_check_mark: | +| 1.25 | :white_check_mark: | ## Why another library @@ -153,5 +202,4 @@ Unless required by applicable law or agreed to in writing, software distributed AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - [^1]: https://github.com/zitadel/oidc/issues/135#issuecomment-950563892 diff --git a/example/client/app/app.go b/example/client/app/app.go index 2e9b62b..2584f45 100644 --- a/example/client/app/app.go +++ b/example/client/app/app.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "os" + "strconv" "strings" "sync/atomic" "time" @@ -15,6 +16,7 @@ import ( "github.com/sirupsen/logrus" "github.com/zitadel/logging" + "github.com/zitadel/oidc/v4/pkg/client/rp" httphelper "github.com/zitadel/oidc/v4/pkg/http" "github.com/zitadel/oidc/v4/pkg/oidc" @@ -29,11 +31,20 @@ func main() { clientID := os.Getenv("CLIENT_ID") clientSecret := os.Getenv("CLIENT_SECRET") keyPath := os.Getenv("KEY_PATH") + keyID := os.Getenv("KEY_ID") issuer := os.Getenv("ISSUER") port := os.Getenv("PORT") scopes := strings.Split(os.Getenv("SCOPES"), " ") responseMode := os.Getenv("RESPONSE_MODE") + var pkce bool + if pkceEnv, ok := os.LookupEnv("PKCE"); ok { + var err error + pkce, err = strconv.ParseBool(pkceEnv) + if err != nil { + logrus.Fatalf("error parsing PKCE %s", err.Error()) + } + } redirectURI := fmt.Sprintf("http://localhost:%v%v", port, callbackPath) cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) @@ -56,12 +67,20 @@ func main() { rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)), rp.WithHTTPClient(client), rp.WithLogger(logger), + rp.WithSigningAlgsFromDiscovery(), } if clientSecret == "" { options = append(options, rp.WithPKCE(cookieHandler)) } if keyPath != "" { - options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath))) + signingKey, err := os.ReadFile(keyPath) + if err != nil { + logrus.Fatalf("error reading key file %s", err.Error()) + } + options = append(options, rp.WithJWTProfile(rp.SignerFromKeyAndKeyID(signingKey, keyID))) + } + if pkce { + options = append(options, rp.WithPKCE(cookieHandler)) } // One can add a logger to the context, @@ -108,6 +127,7 @@ func main() { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("content-type", "application/json") w.Write(data) } diff --git a/example/client/device/device.go b/example/client/device/device.go index 065b289..d680984 100644 --- a/example/client/device/device.go +++ b/example/client/device/device.go @@ -60,6 +60,7 @@ func main() { clientID := os.Getenv("CLIENT_ID") clientSecret := os.Getenv("CLIENT_SECRET") keyPath := os.Getenv("KEY_PATH") + keyID := os.Getenv("KEY_ID") issuer := os.Getenv("ISSUER") scopes := strings.Split(os.Getenv("SCOPES"), " ") @@ -70,7 +71,11 @@ func main() { options = append(options, rp.WithPKCE(cookieHandler)) } if keyPath != "" { - options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath))) + signingKey, err := os.ReadFile(keyPath) + if err != nil { + logrus.Fatalf("error reading key file %s", err.Error()) + } + options = append(options, rp.WithJWTProfile(rp.SignerFromKeyAndKeyID(signingKey, keyID))) } provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, "", scopes, options...) diff --git a/example/server/config/config.go b/example/server/config/config.go new file mode 100644 index 0000000..96837d4 --- /dev/null +++ b/example/server/config/config.go @@ -0,0 +1,40 @@ +package config + +import ( + "os" + "strings" +) + +const ( + // default port for the http server to run + DefaultIssuerPort = "9998" +) + +type Config struct { + Port string + RedirectURI []string + UsersFile string +} + +// FromEnvVars loads configuration parameters from environment variables. +// If there is no such variable defined, then use default values. +func FromEnvVars(defaults *Config) *Config { + if defaults == nil { + defaults = &Config{} + } + cfg := &Config{ + Port: defaults.Port, + RedirectURI: defaults.RedirectURI, + UsersFile: defaults.UsersFile, + } + if value, ok := os.LookupEnv("PORT"); ok { + cfg.Port = value + } + if value, ok := os.LookupEnv("USERS_FILE"); ok { + cfg.UsersFile = value + } + if value, ok := os.LookupEnv("REDIRECT_URI"); ok { + cfg.RedirectURI = strings.Split(value, ",") + } + return cfg +} diff --git a/example/server/config/config_test.go b/example/server/config/config_test.go new file mode 100644 index 0000000..3b73c0b --- /dev/null +++ b/example/server/config/config_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "fmt" + "os" + "testing" +) + +func TestFromEnvVars(t *testing.T) { + + for _, tc := range []struct { + name string + env map[string]string + defaults *Config + want *Config + }{ + { + name: "no vars, no default values", + env: map[string]string{}, + want: &Config{}, + }, + { + name: "no vars, only defaults", + env: map[string]string{}, + defaults: &Config{ + Port: "6666", + UsersFile: "/default/user/path", + RedirectURI: []string{"re", "direct", "uris"}, + }, + want: &Config{ + Port: "6666", + UsersFile: "/default/user/path", + RedirectURI: []string{"re", "direct", "uris"}, + }, + }, + { + name: "overriding default values", + env: map[string]string{ + "PORT": "1234", + "USERS_FILE": "/path/to/users", + "REDIRECT_URI": "http://redirect/redirect", + }, + defaults: &Config{ + Port: "6666", + UsersFile: "/default/user/path", + RedirectURI: []string{"re", "direct", "uris"}, + }, + want: &Config{ + Port: "1234", + UsersFile: "/path/to/users", + RedirectURI: []string{"http://redirect/redirect"}, + }, + }, + { + name: "multiple redirect uris", + env: map[string]string{ + "REDIRECT_URI": "http://host_1,http://host_2,http://host_3", + }, + want: &Config{ + RedirectURI: []string{ + "http://host_1", "http://host_2", "http://host_3", + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + os.Clearenv() + for k, v := range tc.env { + os.Setenv(k, v) + } + cfg := FromEnvVars(tc.defaults) + if fmt.Sprint(cfg) != fmt.Sprint(tc.want) { + t.Errorf("Expected FromEnvVars()=%q, but got %q", tc.want, cfg) + } + }) + } +} diff --git a/example/server/crypto.go b/example/server/crypto.go new file mode 100644 index 0000000..9f5b683 --- /dev/null +++ b/example/server/crypto.go @@ -0,0 +1,33 @@ +package main + +import ( + "log/slog" + + "github.com/zitadel/oidc/v4/pkg/crypto" + "github.com/zitadel/oidc/v4/pkg/op" +) + +var _ op.Crypto = &myCrypto{} + +// myCrypto demonstrates how to provide your custom implementation of op.Crypto. +type myCrypto struct { + key string + logger *slog.Logger +} + +func newMyCrypto(key [32]byte, l *slog.Logger) *myCrypto { + return &myCrypto{ + key: string(key[:32]), + logger: l, + } +} + +func (m *myCrypto) Decrypt(s string) (string, error) { + m.logger.Info("decrypting") + return crypto.DecryptAES(s, m.key) +} + +func (m *myCrypto) Encrypt(s string) (string, error) { + m.logger.Info("encrypting") + return crypto.EncryptAES(s, m.key) +} diff --git a/example/server/exampleop/op.go b/example/server/exampleop/op.go index 26405ca..e39b0bf 100644 --- a/example/server/exampleop/op.go +++ b/example/server/exampleop/op.go @@ -12,7 +12,6 @@ import ( "github.com/zitadel/logging" "golang.org/x/text/language" - "github.com/zitadel/oidc/v4/example/server/storage" "github.com/zitadel/oidc/v4/pkg/op" ) @@ -20,14 +19,6 @@ const ( pathLoggedOut = "/logged-out" ) -func init() { - storage.RegisterClients( - storage.NativeClient("native"), - storage.WebClient("web", "secret"), - storage.WebClient("api", "secret"), - ) -} - type Storage interface { op.Storage authenticate @@ -56,11 +47,17 @@ func SetupServer(issuer string, storage Storage, logger *slog.Logger, wrapServer // 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) { w.Write([]byte("signed out successfully")) - // no need to check/log error, this will be handeled by the middleware. + // no need to check/log error, this will be handled by the middleware. }) // creation of the OpenIDProvider with the just created in-memory Storage - provider, err := newOP(storage, issuer, key, logger, extraOptions...) + provider, err := newOP( + storage, + issuer, + key, + logger, + extraOptions..., + ) if err != nil { log.Fatal(err) } @@ -80,7 +77,7 @@ func SetupServer(issuer string, storage Storage, logger *slog.Logger, wrapServer handler := http.Handler(provider) if wrapServer { - handler = op.RegisterLegacyServer(op.NewLegacyServer(provider, *op.DefaultEndpoints)) + handler = op.RegisterLegacyServer(op.NewLegacyServer(provider, *op.DefaultEndpoints), op.AuthorizeCallbackHandler(provider)) } // we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration) @@ -93,10 +90,16 @@ func SetupServer(issuer string, storage Storage, logger *slog.Logger, wrapServer return router } -// newOP will create an OpenID Provider for localhost on a specified port with a given encryption key +// newOP will create an OpenID Provider for localhost on a specified port // and a predefined default logout uri // it will enable all options (see descriptions) -func newOP(storage op.Storage, issuer string, key [32]byte, logger *slog.Logger, extraOptions ...op.Option) (op.OpenIDProvider, error) { +func newOP( + storage op.Storage, + issuer string, + key [32]byte, // encryption key + logger *slog.Logger, + extraOptions ...op.Option, +) (op.OpenIDProvider, error) { config := &op.Config{ CryptoKey: key, diff --git a/example/server/exampleop/templates/login.html b/example/server/exampleop/templates/login.html index b048211..d7f8f9a 100644 --- a/example/server/exampleop/templates/login.html +++ b/example/server/exampleop/templates/login.html @@ -25,5 +25,5 @@ -` -{{- end }} \ No newline at end of file + +{{- end }} diff --git a/example/server/main.go b/example/server/main.go index c5d3610..2c5c2ca 100644 --- a/example/server/main.go +++ b/example/server/main.go @@ -6,36 +6,60 @@ import ( "net/http" "os" + "github.com/zitadel/oidc/v4/example/server/config" "github.com/zitadel/oidc/v4/example/server/exampleop" "github.com/zitadel/oidc/v4/example/server/storage" ) +func getUserStore(cfg *config.Config) (storage.UserStore, error) { + if cfg.UsersFile == "" { + return storage.NewUserStore(fmt.Sprintf("http://localhost:%s/", cfg.Port)), nil + } + return storage.StoreFromFile(cfg.UsersFile) +} + func main() { - //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(issuer)) - + cfg := config.FromEnvVars(&config.Config{Port: "9998"}) logger := slog.New( slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ AddSource: true, Level: slog.LevelDebug, }), ) - router := exampleop.SetupServer(issuer, storage, logger, false) + + //which gives us the issuer: http://localhost:9998/ + issuer := fmt.Sprintf("http://localhost:%s/", cfg.Port) + + storage.RegisterClients( + storage.NativeClient("native", cfg.RedirectURI...), + storage.WebClient("web", "secret", cfg.RedirectURI...), + storage.WebClient("api", "secret", cfg.RedirectURI...), + ) + + // 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 + store, err := getUserStore(cfg) + if err != nil { + logger.Error("cannot create UserStore", "error", err) + os.Exit(1) + } + + stor := storage.NewStorage(store) + router := exampleop.SetupServer( + issuer, + stor, + logger, + false, + //op.WithCrypto(newMyCrypto(sha256.Sum256([]byte("test")), logger)), + ) server := &http.Server{ - Addr: ":" + port, + Addr: ":" + cfg.Port, Handler: router, } - logger.Info("server listening, press ctrl+c to stop", "addr", fmt.Sprintf("http://localhost:%s/", port)) - err := server.ListenAndServe() - if err != http.ErrServerClosed { + logger.Info("server listening, press ctrl+c to stop", "addr", issuer) + if server.ListenAndServe() != http.ErrServerClosed { logger.Error("server terminated", "error", err) os.Exit(1) } diff --git a/example/server/service-key1.json b/example/server/service-key1.json deleted file mode 100644 index a0d20e8..0000000 --- a/example/server/service-key1.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"serviceaccount","keyId":"key1","key":"-----BEGIN RSA PRIVATE KEY-----\nMIICXgIBAAKBgQD21E+180rCAzp15zy2X/JOYYHtxYhF51pWCsITeChJd7sFWxp1\ntxSHTiomQYBiBWgcCavsdu/VLPQJhO3PTIyglxc1XRGsM48oDT5MkFsAVDvbjuWk\nF0lstQyw4pr8Wg0Ecf1aL6YlvVKB9h5rAgZ9T+elNJ7q5takMAvNhu7zMQIDAQAB\nAoGAeLRw2qjEaUZM43WWchVPmFcEw/MyZgTyX1tZd03uXacolUDtGp3ScyydXiHw\nF39PX063fabYOCaInNMdvJ9RsQz2OcZuS/K6NOmWhzBfLgs4Y1tU6ijoY/gBjHgu\nCV0KjvoWIfEtKl/On/wTrAnUStFzrc7U4dpKFP1fy2ZTTnECQQD8aP2QOxmKUyfg\nBAjfonpkrNeaTRNwTULTvEHFiLyaeFd1PAvsDiKZtpk6iHLb99mQZkVVtAK5qgQ4\n1OI72jkVAkEA+lcAamuZAM+gIiUhbHA7BfX9OVgyGDD2tx5g/kxhMUmK6hIiO6Ul\n0nw5KfrCEUU3AzrM7HejUg3q61SYcXTgrQJBALhrzbhwNf0HPP9Ec2dSw7KDRxSK\ndEV9bfJefn/hpEwI2X3i3aMfwNAmxlYqFCH8OY5z6vzvhX46ZtNPV+z7SPECQQDq\nApXi5P27YlpgULEzup2R7uZsymLZdjvJ5V3pmOBpwENYlublNnVqkrCk60CqADdy\nj26rxRIoS9ZDcWqm9AhpAkEAyrNXBMJh08ghBMb3NYPFfr/bftRJSrGjhBPuJ5qr\nXzWaXhYVMMh3OSAwzHBJbA1ffdQJuH2ebL99Ur5fpBcbVw==\n-----END RSA PRIVATE KEY-----\n","userId":"service"} diff --git a/example/server/service-key1.pem b/example/server/service-key1.pem new file mode 100644 index 0000000..4fe9ba4 --- /dev/null +++ b/example/server/service-key1.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQD21E+180rCAzp15zy2X/JOYYHtxYhF51pWCsITeChJd7sFWxp1 +txSHTiomQYBiBWgcCavsdu/VLPQJhO3PTIyglxc1XRGsM48oDT5MkFsAVDvbjuWk +F0lstQyw4pr8Wg0Ecf1aL6YlvVKB9h5rAgZ9T+elNJ7q5takMAvNhu7zMQIDAQAB +AoGAeLRw2qjEaUZM43WWchVPmFcEw/MyZgTyX1tZd03uXacolUDtGp3ScyydXiHw +F39PX063fabYOCaInNMdvJ9RsQz2OcZuS/K6NOmWhzBfLgs4Y1tU6ijoY/gBjHgu +CV0KjvoWIfEtKl/On/wTrAnUStFzrc7U4dpKFP1fy2ZTTnECQQD8aP2QOxmKUyfg +BAjfonpkrNeaTRNwTULTvEHFiLyaeFd1PAvsDiKZtpk6iHLb99mQZkVVtAK5qgQ4 +1OI72jkVAkEA+lcAamuZAM+gIiUhbHA7BfX9OVgyGDD2tx5g/kxhMUmK6hIiO6Ul +0nw5KfrCEUU3AzrM7HejUg3q61SYcXTgrQJBALhrzbhwNf0HPP9Ec2dSw7KDRxSK +dEV9bfJefn/hpEwI2X3i3aMfwNAmxlYqFCH8OY5z6vzvhX46ZtNPV+z7SPECQQDq +ApXi5P27YlpgULEzup2R7uZsymLZdjvJ5V3pmOBpwENYlublNnVqkrCk60CqADdy +j26rxRIoS9ZDcWqm9AhpAkEAyrNXBMJh08ghBMb3NYPFfr/bftRJSrGjhBPuJ5qr +XzWaXhYVMMh3OSAwzHBJbA1ffdQJuH2ebL99Ur5fpBcbVw== +-----END RSA PRIVATE KEY----- diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go index ce5d0b6..9fed2ef 100644 --- a/example/server/storage/oidc.go +++ b/example/server/storage/oidc.go @@ -18,7 +18,7 @@ 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 is an example scope prefix for passing user id to impersonate using token exchange CustomScopeImpersonatePrefix = "custom_scope:impersonate:" ) @@ -121,7 +121,7 @@ func (a *AuthRequest) Done() bool { } func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string { - prompts := make([]string, len(oidcPrompt)) + prompts := make([]string, 0, len(oidcPrompt)) for _, oidcPrompt := range oidcPrompt { switch oidcPrompt { case oidc.PromptNone, @@ -143,6 +143,14 @@ func MaxAgeToInternal(maxAge *uint) *time.Duration { } func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthRequest { + var codeChallenge *OIDCCodeChallenge + if authReq.CodeChallenge != "" { + codeChallenge = &OIDCCodeChallenge{ + Challenge: authReq.CodeChallenge, + Method: string(authReq.CodeChallengeMethod), + } + } + return &AuthRequest{ CreationDate: time.Now(), ApplicationID: authReq.ClientID, @@ -157,13 +165,19 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques ResponseType: authReq.ResponseType, ResponseMode: authReq.ResponseMode, Nonce: authReq.Nonce, - CodeChallenge: &OIDCCodeChallenge{ - Challenge: authReq.CodeChallenge, - Method: string(authReq.CodeChallengeMethod), - }, + CodeChallenge: codeChallenge, } } +type AuthRequestWithSessionState struct { + *AuthRequest + SessionState string +} + +func (a *AuthRequestWithSessionState) GetSessionState() string { + return a.SessionState +} + type OIDCCodeChallenge struct { Challenge string Method string diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 3b826ac..f296e29 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -11,7 +11,7 @@ import ( "sync" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/zitadel/oidc/v4/pkg/oidc" @@ -19,7 +19,7 @@ import ( ) // serviceKey1 is a public key which will be used for the JWT Profile Authorization Grant -// the corresponding private key is in the service-key1.json (for demonstration purposes) +// the corresponding private key is in the service-key1.pem (for demonstration purposes) var serviceKey1 = &rsa.PublicKey{ N: func() *big.Int { n, _ := new(big.Int).SetString("00f6d44fb5f34ac2033a75e73cb65ff24e6181edc58845e75a560ac21378284977bb055b1a75b714874e2a2641806205681c09abec76efd52cf40984edcf4c8ca09717355d11ac338f280d3e4c905b00543bdb8ee5a417496cb50cb0e29afc5a0d0471fd5a2fa625bd5281f61e6b02067d4fe7a5349eeae6d6a4300bcd86eef331", 16) @@ -33,7 +33,7 @@ var ( _ op.ClientCredentialsStorage = &Storage{} ) -// storage implements the op.Storage interface +// Storage implements the op.Storage interface // typically you would implement this as a layer on top of your database // for simplicity this example keeps everything in-memory type Storage struct { @@ -105,7 +105,7 @@ func NewStorageWithClients(userStore UserStore, clients map[string]*Client) *Sto services: map[string]Service{ userStore.ExampleClientID(): { keys: map[string]*rsa.PublicKey{ - "key1": serviceKey1, + ServiceUserKeyID: serviceKey1, }, }, }, @@ -151,6 +151,9 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error { // 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.done = true + + request.authTime = time.Now() + return nil } return fmt.Errorf("username or password wrong") @@ -295,15 +298,19 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T // if we get here, the currentRefreshToken was not empty, so the call is a refresh token request // we therefore will have to check the currentRefreshToken and renew the refresh token - refreshToken, refreshTokenID, err := s.renewRefreshToken(currentRefreshToken) + + newRefreshToken = uuid.NewString() + + accessToken, err := s.accessToken(applicationID, newRefreshToken, request.GetSubject(), request.GetAudience(), request.GetScopes()) if err != nil { return "", "", time.Time{}, err } - accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes()) - if err != nil { + + if err := s.renewRefreshToken(currentRefreshToken, newRefreshToken, accessToken.ID); err != nil { return "", "", time.Time{}, err } - return accessToken.ID, refreshToken, accessToken.Expiration, nil + + return accessToken.ID, newRefreshToken, accessToken.Expiration, nil } func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExchangeRequest) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { @@ -385,14 +392,9 @@ func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID if refreshToken.ApplicationID != clientID { return oidc.ErrInvalidClient().WithDescription("token was not issued for this client") } - // if it is a refresh token, you will have to remove the access token as well delete(s.refreshTokens, refreshToken.ID) - for _, accessToken := range s.tokens { - if accessToken.RefreshTokenID == refreshToken.ID { - delete(s.tokens, accessToken.ID) - return nil - } - } + // if it is a refresh token, you will have to remove the access token as well + delete(s.tokens, refreshToken.AccessToken) return nil } @@ -488,6 +490,9 @@ func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserI // return err // } //} + if token.Expiration.Before(time.Now()) { + return fmt.Errorf("token is expired") + } return s.setUserinfo(ctx, userinfo, token.Subject, token.ApplicationID, token.Scopes) } @@ -594,33 +599,41 @@ func (s *Storage) createRefreshToken(accessToken *Token, amr []string, authTime Audience: accessToken.Audience, Expiration: time.Now().Add(5 * time.Hour), Scopes: accessToken.Scopes, + AccessToken: accessToken.ID, } s.refreshTokens[token.ID] = token return token.Token, nil } // renewRefreshToken checks the provided refresh_token and creates a new one based on the current -func (s *Storage) renewRefreshToken(currentRefreshToken string) (string, string, error) { +// +// [Refresh Token Rotation] is implemented. +// +// [Refresh Token Rotation]: https://www.rfc-editor.org/rfc/rfc6819#section-5.2.2.3 +func (s *Storage) renewRefreshToken(currentRefreshToken, newRefreshToken, newAccessToken string) error { s.lock.Lock() defer s.lock.Unlock() refreshToken, ok := s.refreshTokens[currentRefreshToken] if !ok { - return "", "", fmt.Errorf("invalid refresh token") + return fmt.Errorf("invalid refresh token") } - // deletes the refresh token and all access tokens which were issued based on this refresh token + // deletes the refresh token delete(s.refreshTokens, currentRefreshToken) - for _, token := range s.tokens { - if token.RefreshTokenID == currentRefreshToken { - delete(s.tokens, token.ID) - break - } + + // delete the access token which was issued based on this refresh token + delete(s.tokens, refreshToken.AccessToken) + + if refreshToken.Expiration.Before(time.Now()) { + return fmt.Errorf("expired refresh token") } + // creates a new refresh token based on the current one - token := uuid.NewString() - refreshToken.Token = token - refreshToken.ID = token - s.refreshTokens[token] = refreshToken - return token, refreshToken.ID, nil + refreshToken.Token = newRefreshToken + refreshToken.ID = newRefreshToken + refreshToken.Expiration = time.Now().Add(5 * time.Hour) + refreshToken.AccessToken = newAccessToken + s.refreshTokens[newRefreshToken] = refreshToken + return nil } // accessToken will store an access_token in-memory based on the provided information diff --git a/example/server/storage/storage_dynamic.go b/example/server/storage/storage_dynamic.go index f15ab23..b8028f5 100644 --- a/example/server/storage/storage_dynamic.go +++ b/example/server/storage/storage_dynamic.go @@ -4,7 +4,7 @@ import ( "context" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v4/pkg/oidc" "github.com/zitadel/oidc/v4/pkg/op" @@ -249,12 +249,12 @@ func (s *multiStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, c // 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) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) { +func (s *multiStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) { storage, err := s.storageFromContext(ctx) if err != nil { return nil, err } - return storage.GetKeyByIDAndClientID(ctx, keyID, userID) + return storage.GetKeyByIDAndClientID(ctx, keyID, clientID) } // ValidateJWTProfileScopes implements the op.Storage interface diff --git a/example/server/storage/token.go b/example/server/storage/token.go index ad907e3..beab38c 100644 --- a/example/server/storage/token.go +++ b/example/server/storage/token.go @@ -22,4 +22,5 @@ type RefreshToken struct { ApplicationID string Expiration time.Time Scopes []string + AccessToken string // Token.ID } diff --git a/example/server/storage/user.go b/example/server/storage/user.go index 173daef..b2564a7 100644 --- a/example/server/storage/user.go +++ b/example/server/storage/user.go @@ -2,11 +2,20 @@ package storage import ( "crypto/rsa" + "encoding/json" + "os" "strings" "golang.org/x/text/language" ) +const ( + // ServiceUserID is the ID of the service user. + ServiceUserID = "service" + // ServiceUserKeyID is the key ID of the service user. + ServiceUserKeyID = "key1" +) + type User struct { ID string Username string @@ -35,6 +44,18 @@ type userStore struct { users map[string]*User } +func StoreFromFile(path string) (UserStore, error) { + users := map[string]*User{} + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &users); err != nil { + return nil, err + } + return userStore{users}, nil +} + func NewUserStore(issuer string) UserStore { hostname := strings.Split(strings.Split(issuer, "://")[1], ":")[0] return userStore{ @@ -71,7 +92,7 @@ func NewUserStore(issuer string) UserStore { // ExampleClientID is only used in the example server func (u userStore) ExampleClientID() string { - return "service" + return ServiceUserID } func (u userStore) GetUserByID(id string) *User { diff --git a/example/server/storage/user_test.go b/example/server/storage/user_test.go new file mode 100644 index 0000000..c2e2212 --- /dev/null +++ b/example/server/storage/user_test.go @@ -0,0 +1,70 @@ +package storage + +import ( + "os" + "path" + "reflect" + "testing" + + "golang.org/x/text/language" +) + +func TestStoreFromFile(t *testing.T) { + for _, tc := range []struct { + name string + pathToFile string + content string + want UserStore + wantErr bool + }{ + { + name: "normal user file", + pathToFile: "userfile.json", + content: `{ + "id1": { + "ID": "id1", + "EmailVerified": true, + "PreferredLanguage": "DE" + } + }`, + want: userStore{map[string]*User{ + "id1": { + ID: "id1", + EmailVerified: true, + PreferredLanguage: language.German, + }, + }}, + }, + { + name: "malformed file", + pathToFile: "whatever", + content: "not a json just a text", + wantErr: true, + }, + { + name: "not existing file", + pathToFile: "what/ever/file", + wantErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualPath := path.Join(t.TempDir(), tc.pathToFile) + + if tc.content != "" && tc.pathToFile != "" { + if err := os.WriteFile(actualPath, []byte(tc.content), 0666); err != nil { + t.Fatalf("cannot create file with test content: %q", tc.content) + } + } + result, err := StoreFromFile(actualPath) + if err != nil && !tc.wantErr { + t.Errorf("StoreFromFile(%q) returned unexpected error %q", tc.pathToFile, err) + } else if err == nil && tc.wantErr { + t.Errorf("StoreFromFile(%q) did not return an expected error", tc.pathToFile) + } + if !tc.wantErr && !reflect.DeepEqual(tc.want, result.(userStore)) { + t.Errorf("expected StoreFromFile(%q) = %v, but got %v", + tc.pathToFile, tc.want, result) + } + }) + } +} diff --git a/go.mod b/go.mod index 58c20dc..f1b3984 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,40 @@ module github.com/zitadel/oidc/v4 -go 1.21 +go 1.24.0 require ( - github.com/bmatcuk/doublestar/v4 v4.6.1 - github.com/go-chi/chi/v5 v5.0.12 - github.com/go-jose/go-jose/v3 v3.0.3 + github.com/bmatcuk/doublestar/v4 v4.9.1 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-jose/go-jose/v4 v4.0.5 github.com/golang/mock v1.6.0 github.com/google/go-github/v31 v31.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 - github.com/jeremija/gosubmit v0.2.7 + github.com/jeremija/gosubmit v0.2.8 github.com/muhlemmer/gu v0.3.1 github.com/muhlemmer/httpforwarded v0.1.0 - github.com/rs/cors v1.10.1 + github.com/rs/cors v1.11.1 github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.9.0 - github.com/zitadel/logging v0.6.0 - github.com/zitadel/schema v1.3.0 - go.opentelemetry.io/otel v1.24.0 - golang.org/x/oauth2 v0.18.0 - golang.org/x/text v0.14.0 + github.com/stretchr/testify v1.11.1 + github.com/zitadel/logging v0.6.2 + github.com/zitadel/oidc/v3 v3.45.0 + github.com/zitadel/schema v1.3.1 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 + golang.org/x/oauth2 v0.33.0 + golang.org/x/text v0.31.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sys v0.18.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.33.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 58029da..3f8966c 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,23 @@ -github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= -github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 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/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= -github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= -github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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.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.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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= @@ -35,114 +29,86 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= -github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= -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/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= 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/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= -github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= -github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= -github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= -github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= +github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= +github.com/zitadel/oidc/v3 v3.45.0 h1:SaVJ2kdcJi/zdEWWlAns+81VxmfdYX4E+2mWFVIH7Ec= +github.com/zitadel/oidc/v3 v3.45.0/go.mod h1:UeK0iVOoqfMuDVgSfv56BqTz8YQC2M+tGRIXZ7Ii3VY= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 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-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-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -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/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= -golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/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-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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -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.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= diff --git a/internal/otel/otel.go b/internal/otel/otel.go new file mode 100644 index 0000000..21f734a --- /dev/null +++ b/internal/otel/otel.go @@ -0,0 +1,12 @@ +//go:build !no_otel + +package otel + +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" +) + +func Tracer(name string) trace.Tracer { + return otel.Tracer(name) +} diff --git a/internal/otel/shim.go b/internal/otel/shim.go new file mode 100644 index 0000000..62d3b0c --- /dev/null +++ b/internal/otel/shim.go @@ -0,0 +1,22 @@ +//go:build no_otel + +package otel + +import ( + "context" +) + +type FakeTracer struct{} +type FakeSpan struct{} + +func Tracer(name string) FakeTracer { + return FakeTracer{} +} + +func (t FakeTracer) Start(ctx context.Context, _ string) (context.Context, FakeSpan) { + return ctx, FakeSpan{} +} + +func (s FakeSpan) End() { + +} diff --git a/internal/testutil/token.go b/internal/testutil/token.go index 9f059a2..5fe19f1 100644 --- a/internal/testutil/token.go +++ b/internal/testutil/token.go @@ -8,7 +8,7 @@ import ( "errors" "time" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" "github.com/muhlemmer/gu" "github.com/zitadel/oidc/v4/pkg/oidc" ) diff --git a/pkg/client/client.go b/pkg/client/client.go index 9c93068..35c118e 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -10,7 +10,7 @@ import ( "strings" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/logging" "github.com/zitadel/oidc/v4/pkg/crypto" httphelper "github.com/zitadel/oidc/v4/pkg/http" @@ -41,7 +41,7 @@ func Discover(ctx context.Context, issuer string, httpClient *http.Client, wellK discoveryConfig := new(oidc.DiscoveryConfiguration) err = httphelper.HttpRequest(httpClient, req, &discoveryConfig) if err != nil { - return nil, err + return nil, errors.Join(oidc.ErrDiscoveryFailed, err) } if logger, ok := logging.FromContext(ctx); ok { logger.Debug("discover", "config", discoveryConfig) @@ -97,7 +97,12 @@ func CallEndSessionEndpoint(ctx context.Context, request any, authFn any, caller ctx, span := Tracer.Start(ctx, "CallEndSessionEndpoint") defer span.End() - req, err := httphelper.FormRequest(ctx, caller.GetEndSessionEndpoint(), request, Encoder, authFn) + endpoint := caller.GetEndSessionEndpoint() + if endpoint == "" { + return nil, fmt.Errorf("end session %w", ErrEndpointNotSet) + } + + req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn) if err != nil { return nil, err } @@ -143,7 +148,12 @@ func CallRevokeEndpoint(ctx context.Context, request any, authFn any, caller Rev ctx, span := Tracer.Start(ctx, "CallRevokeEndpoint") defer span.End() - req, err := httphelper.FormRequest(ctx, caller.GetRevokeEndpoint(), request, Encoder, authFn) + endpoint := caller.GetRevokeEndpoint() + if endpoint == "" { + return fmt.Errorf("revoke %w", ErrEndpointNotSet) + } + + req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn) if err != nil { return err } @@ -186,12 +196,12 @@ func CallTokenExchangeEndpoint(ctx context.Context, request any, authFn any, cal } func NewSignerFromPrivateKeyByte(key []byte, keyID string) (jose.Signer, error) { - privateKey, err := crypto.BytesToPrivateKey(key) + privateKey, algorithm, err := crypto.BytesToPrivateKey(key) if err != nil { return nil, err } signingKey := jose.SigningKey{ - Algorithm: jose.RS256, + Algorithm: algorithm, Key: &jose.JSONWebKey{Key: privateKey, KeyID: keyID}, } return jose.NewSigner(signingKey, &jose.SignerOptions{}) @@ -218,7 +228,12 @@ func CallDeviceAuthorizationEndpoint(ctx context.Context, request *oidc.ClientCr ctx, span := Tracer.Start(ctx, "CallDeviceAuthorizationEndpoint") defer span.End() - req, err := httphelper.FormRequest(ctx, caller.GetDeviceAuthorizationEndpoint(), request, Encoder, authFn) + endpoint := caller.GetDeviceAuthorizationEndpoint() + if endpoint == "" { + return nil, fmt.Errorf("device authorization %w", ErrEndpointNotSet) + } + + req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn) if err != nil { return nil, err } diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index e06c825..ad276d1 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v4/pkg/oidc" ) func TestDiscover(t *testing.T) { @@ -22,7 +23,7 @@ func TestDiscover(t *testing.T) { name string args args wantFields *wantFields - wantErr bool + wantErr error }{ { name: "spotify", // https://github.com/zitadel/oidc/issues/406 @@ -32,17 +33,20 @@ func TestDiscover(t *testing.T) { wantFields: &wantFields{ UILocalesSupported: true, }, - wantErr: false, + wantErr: nil, + }, + { + name: "discovery failed", + args: args{ + issuer: "https://example.com", + }, + wantErr: oidc.ErrDiscoveryFailed, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Discover(context.Background(), tt.args.issuer, http.DefaultClient, tt.args.wellKnownUrl...) - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) + require.ErrorIs(t, err, tt.wantErr) if tt.wantFields == nil { return } diff --git a/pkg/client/errors.go b/pkg/client/errors.go new file mode 100644 index 0000000..47210e5 --- /dev/null +++ b/pkg/client/errors.go @@ -0,0 +1,5 @@ +package client + +import "errors" + +var ErrEndpointNotSet = errors.New("endpoint not set") diff --git a/pkg/client/integration_test.go b/pkg/client/integration_test.go index 7827fd4..f8745e6 100644 --- a/pkg/client/integration_test.go +++ b/pkg/client/integration_test.go @@ -3,6 +3,9 @@ package client_test import ( "bytes" "context" + "crypto/hmac" + cryptoRand "crypto/rand" + "crypto/sha512" "fmt" "io" "log/slog" @@ -18,6 +21,8 @@ import ( "testing" "time" + "github.com/google/uuid" + "github.com/gorilla/securecookie" "github.com/jeremija/gosubmit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,6 +47,15 @@ var Logger = slog.New( var CTX context.Context +type cookieSpec struct { + cookieHandler *httphelper.CookieHandler + extraCookies []*http.Cookie +} + +var defaultCookieSpec = cookieSpec{ + cookieHandler: httphelper.NewCookieHandler([]byte("test1234test1234"), []byte("test1234test1234"), httphelper.WithUnsecure()), +} + func TestMain(m *testing.M) { os.Exit(func() int { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT) @@ -53,14 +67,65 @@ func TestMain(m *testing.M) { } func TestRelyingPartySession(t *testing.T) { - for _, wrapServer := range []bool{false, true} { - t.Run(fmt.Sprint("wrapServer ", wrapServer), func(t *testing.T) { - testRelyingPartySession(t, wrapServer) - }) + secret := make([]byte, 64) + _, _ = cryptoRand.Read(secret) + hashFunc := sha512.New + requestAwareCookieHandler := httphelper.NewRequestAwareCookieHandler(func(r *http.Request) (*securecookie.SecureCookie, error) { + // Expect a login ID cookie to be set. + loginIDCookie, err := r.Cookie("login_id") + require.NoError(t, err) + + // Login ID cookie will contain a UUID. + loginID, err := uuid.Parse(loginIDCookie.Value) + require.NoError(t, err) + + // Use a HMAC hash of the secret and login ID (salt). + h := hmac.New(hashFunc, secret) + _, err = h.Write(loginID[:]) + require.NoError(t, err) + + // Write a separator for the hash key. + _, err = h.Write([]byte("INTEGRITY")) + require.NoError(t, err) + hash := h.Sum(nil) // 64 bytes + + // Write a separator for the block key. + _, err = h.Write([]byte("ENCRYPTION")) + require.NoError(t, err) + block := h.Sum(nil)[:32] // Sum is 64 bytes but we only want 32 for AES-256. + + return securecookie.New(hash, block), nil + }, httphelper.WithUnsecure()) + + loginID := uuid.New() + loginIDCookie := &http.Cookie{ + Name: "login_id", + Value: loginID.String(), + Secure: false, + SameSite: http.SameSiteLaxMode, + Path: "/", + } + + cookieCases := []cookieSpec{ + defaultCookieSpec, + { + cookieHandler: requestAwareCookieHandler, + extraCookies: []*http.Cookie{ + loginIDCookie, + }, + }, + } + + for _, cookieCase := range cookieCases { + for _, wrapServer := range []bool{false, true} { + t.Run(fmt.Sprint("wrapServer ", wrapServer, " requestAwareCookieHandler ", cookieCase.cookieHandler.IsRequestAware()), func(t *testing.T) { + testRelyingPartySession(t, wrapServer, cookieCase) + }) + } } } -func testRelyingPartySession(t *testing.T, wrapServer bool) { +func testRelyingPartySession(t *testing.T, wrapServer bool, cookieSpec cookieSpec) { t.Log("------- start example OP ------") targetURL := "http://local-site" exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) @@ -74,7 +139,7 @@ func testRelyingPartySession(t *testing.T, wrapServer bool) { clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) t.Log("------- run authorization code flow ------") - provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, "secret") + provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, "secret", cookieSpec) t.Log("------- refresh tokens ------") @@ -92,7 +157,7 @@ func testRelyingPartySession(t *testing.T, wrapServer bool) { t.Log("------ end session (logout) ------") - newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "") + newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "", "", nil) require.NoError(t, err, "logout") if newLoc != nil { t.Logf("redirect to %s", newLoc) @@ -111,6 +176,92 @@ func testRelyingPartySession(t *testing.T, wrapServer bool) { } } +func TestRelyingPartyWithSigningAlgsFromDiscovery(t *testing.T) { + targetURL := "http://local-site" + localURL, err := url.Parse(targetURL + "/login?requestID=1234") + require.NoError(t, err, "local url") + + t.Log("------- start example OP ------") + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) + clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) + clientSecret := "secret" + client := storage.WebClient(clientID, clientSecret, targetURL) + storage.RegisterClients(client) + exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) + var dh deferredHandler + opServer := httptest.NewServer(&dh) + defer opServer.Close() + dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, true) + + t.Log("------- create RP ------") + provider, err := rp.NewRelyingPartyOIDC( + CTX, + opServer.URL, + clientID, + clientSecret, + targetURL, + []string{"openid"}, + rp.WithSigningAlgsFromDiscovery(), + ) + require.NoError(t, err, "new rp") + + t.Log("------- run authorization code flow ------") + jar, err := cookiejar.New(nil) + require.NoError(t, err, "create cookie jar") + httpClient := &http.Client{ + Timeout: time.Second * 5, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + Jar: jar, + } + state := "state-" + strconv.FormatInt(seed.Int63(), 25) + capturedW := httptest.NewRecorder() + get := httptest.NewRequest("GET", localURL.String(), nil) + rp.AuthURLHandler(func() string { return state }, provider, + rp.WithPromptURLParam("Hello, World!", "Goodbye, World!"), + rp.WithURLParam("custom", "param"), + )(capturedW, get) + defer func() { + if t.Failed() { + t.Log("response body (redirect from RP to OP)", capturedW.Body.String()) + } + }() + resp := capturedW.Result() + startAuthURL, err := resp.Location() + require.NoError(t, err, "get redirect") + loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL) + form := getForm(t, "get login form", httpClient, loginPageURL) + defer func() { + if t.Failed() { + t.Logf("login form (unfilled): %s", string(form)) + } + }() + postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageURL, + gosubmit.Set("username", "test-user@local-site"), + gosubmit.Set("password", "verysecure"), + ) + codeBearingURL := getRedirect(t, "get redirect with code", httpClient, postLoginRedirectURL) + capturedW = httptest.NewRecorder() + get = httptest.NewRequest("GET", codeBearingURL.String(), nil) + var idToken string + redirect := func(w http.ResponseWriter, r *http.Request, newTokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { + idToken = newTokens.IDToken + http.Redirect(w, r, targetURL, http.StatusFound) + } + rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider)(capturedW, get) + defer func() { + if t.Failed() { + t.Log("token exchange response body", capturedW.Body.String()) + require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code") + } + }() + + t.Log("------- verify id token ------") + _, err = rp.VerifyIDToken[*oidc.IDTokenClaims](CTX, idToken, provider.IDTokenVerifier()) + require.NoError(t, err, "verify id token") +} + func TestResourceServerTokenExchange(t *testing.T) { for _, wrapServer := range []bool{false, true} { t.Run(fmt.Sprint("wrapServer ", wrapServer), func(t *testing.T) { @@ -134,7 +285,7 @@ func testResourceServerTokenExchange(t *testing.T, wrapServer bool) { clientSecret := "secret" t.Log("------- run authorization code flow ------") - provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, clientSecret) + provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, clientSecret, defaultCookieSpec) resourceServer, err := rs.NewResourceServerClientCredentials(CTX, opServer.URL, clientID, clientSecret) require.NoError(t, err, "new resource server") @@ -162,7 +313,7 @@ func testResourceServerTokenExchange(t *testing.T, wrapServer bool) { t.Log("------ end session (logout) ------") - newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "") + newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "", "", nil) require.NoError(t, err, "logout") if newLoc != nil { t.Logf("redirect to %s", newLoc) @@ -189,7 +340,7 @@ func testResourceServerTokenExchange(t *testing.T, wrapServer bool) { require.Nil(t, tokenExchangeResponse, "token exchange response") } -func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret string) (provider rp.RelyingParty, tokens *oidc.Tokens[*oidc.IDTokenClaims]) { +func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret string, cookieSpec cookieSpec) (provider rp.RelyingParty, tokens *oidc.Tokens[*oidc.IDTokenClaims]) { targetURL := "http://local-site" localURL, err := url.Parse(targetURL + "/login?requestID=1234") require.NoError(t, err, "local url") @@ -208,8 +359,6 @@ func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, } t.Log("------- create RP ------") - key := []byte("test1234test1234") - cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) provider, err = rp.NewRelyingPartyOIDC( CTX, opServer.URL, @@ -217,7 +366,7 @@ func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret, targetURL, []string{"openid", "email", "profile", "offline_access"}, - rp.WithPKCE(cookieHandler), + rp.WithPKCE(cookieSpec.cookieHandler), rp.WithAuthStyle(oauth2.AuthStyleInHeader), rp.WithVerifierOpts( rp.WithIssuedAtOffset(5*time.Second), @@ -231,6 +380,11 @@ func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, state := "state-" + strconv.FormatInt(seed.Int63(), 25) capturedW := httptest.NewRecorder() get := httptest.NewRequest("GET", localURL.String(), nil) + for _, cookie := range cookieSpec.extraCookies { + get.AddCookie(cookie) + http.SetCookie(capturedW, cookie) + } + rp.AuthURLHandler(func() string { return state }, provider, rp.WithPromptURLParam("Hello, World!", "Goodbye, World!"), rp.WithURLParam("custom", "param"), @@ -297,7 +451,7 @@ func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, t.Log("email", info.Email) email = info.Email - http.Redirect(w, r, targetURL, 302) + http.Redirect(w, r, targetURL, http.StatusFound) } rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider, rp.WithURLParam("custom", "param"))(capturedW, get) diff --git a/pkg/client/key.go b/pkg/client/key.go index 0c01dd2..2f43bdd 100644 --- a/pkg/client/key.go +++ b/pkg/client/key.go @@ -2,7 +2,7 @@ package client import ( "encoding/json" - "io/ioutil" + "os" ) const ( @@ -10,6 +10,8 @@ const ( applicationKey = "application" ) +// Deprecated: use [github.com/zitadel/zitadel-go/v3/pkg/client.KeyFile] instead. +// The type will be removed in the next major release. type KeyFile struct { Type string `json:"type"` // serviceaccount or application KeyID string `json:"keyId"` @@ -23,14 +25,18 @@ type KeyFile struct { ClientID string `json:"clientId"` } +// Deprecated: use [github.com/zitadel/zitadel-go/v3/pkg/client.ConfigFromKeyFile] instead. +// The type will be removed in the next major release. func ConfigFromKeyFile(path string) (*KeyFile, error) { - data, err := ioutil.ReadFile(path) + data, err := os.ReadFile(path) if err != nil { return nil, err } return ConfigFromKeyFileData(data) } +// Deprecated: use [github.com/zitadel/zitadel-go/v3/pkg/client.ConfigFromKeyFileData] instead. +// The type will be removed in the next major release. func ConfigFromKeyFileData(data []byte) (*KeyFile, error) { var f KeyFile if err := json.Unmarshal(data, &f); err != nil { diff --git a/pkg/client/profile/jwt_profile.go b/pkg/client/profile/jwt_profile.go index 93de660..f01d7f2 100644 --- a/pkg/client/profile/jwt_profile.go +++ b/pkg/client/profile/jwt_profile.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" "golang.org/x/oauth2" "github.com/zitadel/oidc/v4/pkg/client" @@ -34,6 +34,9 @@ type jwtProfileTokenSource struct { // therefore sending an `assertion` by singing a JWT with the provided private key from jsonFile. // // The passed context is only used for the call to the Discover endpoint. +// +// Deprecated: use [github.com/zitadel/zitadel-go/v3/pkg/client.ConfigFromKeyFileData] instead. +// The function will be removed in the next major release. func NewJWTProfileTokenSourceFromKeyFile(ctx context.Context, issuer, jsonFile string, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) { keyData, err := client.ConfigFromKeyFile(jsonFile) if err != nil { @@ -47,6 +50,9 @@ func NewJWTProfileTokenSourceFromKeyFile(ctx context.Context, issuer, jsonFile s // therefore sending an `assertion` by singing a JWT with the provided private key in jsonData. // // The passed context is only used for the call to the Discover endpoint. +// +// Deprecated: use [github.com/zitadel/zitadel-go/v3/pkg/client.ConfigFromKeyFileData] instead. +// The function will be removed in the next major release. func NewJWTProfileTokenSourceFromKeyFileData(ctx context.Context, issuer string, jsonData []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) { keyData, err := client.ConfigFromKeyFileData(jsonData) if err != nil { @@ -55,7 +61,7 @@ func NewJWTProfileTokenSourceFromKeyFileData(ctx context.Context, issuer string, return NewJWTProfileTokenSource(ctx, issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...) } -// NewJWTProfileSource returns an implementation of oauth2.TokenSource +// NewJWTProfileTokenSource returns an implementation of oauth2.TokenSource // It will request a token using the OAuth2 JWT Profile Grant, // therefore sending an `assertion` by singing a JWT with the provided private key. // diff --git a/pkg/client/rp/jwks.go b/pkg/client/rp/jwks.go index 7b94aff..5d2f1b6 100644 --- a/pkg/client/rp/jwks.go +++ b/pkg/client/rp/jwks.go @@ -7,7 +7,7 @@ import ( "net/http" "sync" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v4/pkg/client" httphelper "github.com/zitadel/oidc/v4/pkg/http" @@ -217,7 +217,7 @@ func (r *remoteKeySet) fetchRemoteKeys(ctx context.Context) ([]jose.JSONWebKey, ctx, span := client.Tracer.Start(ctx, "fetchRemoteKeys") defer span.End() - req, err := http.NewRequest("GET", r.jwksURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", r.jwksURL, nil) if err != nil { return nil, fmt.Errorf("oidc: can't create request: %v", err) } diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go index cbfe83b..89f248e 100644 --- a/pkg/client/rp/relying_party.go +++ b/pkg/client/rp/relying_party.go @@ -4,29 +4,41 @@ import ( "context" "encoding/base64" "errors" + "fmt" "log/slog" "net/http" "net/url" + "slices" "time" - "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" "github.com/zitadel/logging" + "github.com/zitadel/oidc/v4/pkg/client" httphelper "github.com/zitadel/oidc/v4/pkg/http" "github.com/zitadel/oidc/v4/pkg/oidc" ) const ( + pkceDisabled pkceState = iota + pkceEnabled + pkceFromDiscovery + idTokenKey = "id_token" stateParam = "state" pkceCode = "pkce" ) -var ErrUserInfoSubNotMatching = errors.New("sub from userinfo does not match the sub from the id_token") +var ( + ErrUserInfoSubNotMatching = errors.New("sub from userinfo does not match the sub from the id_token") + ErrInvalidOption = errors.New("invalid relying party option") +) + +type pkceState uint8 // RelyingParty declares the minimal interface for oidc clients type RelyingParty interface { @@ -60,7 +72,7 @@ type RelyingParty interface { // UserinfoEndpoint returns the userinfo UserinfoEndpoint() string - // GetDeviceAuthorizationEndpoint returns the enpoint which can + // GetDeviceAuthorizationEndpoint returns the endpoint which can // be used to start a DeviceAuthorization flow. GetDeviceAuthorizationEndpoint() string @@ -90,12 +102,13 @@ var DefaultUnauthorizedHandler UnauthorizedHandler = func(w http.ResponseWriter, } type relyingParty struct { - issuer string - DiscoveryEndpoint string - endpoints Endpoints - oauthConfig *oauth2.Config - oauth2Only bool - pkce bool + issuer string + DiscoveryEndpoint string + endpoints Endpoints + oauthConfig *oauth2.Config + oauth2Only bool + pkce pkceState + useSigningAlgsFromDiscovery bool httpClient *http.Client cookieHandler *httphelper.CookieHandler @@ -119,7 +132,7 @@ func (rp *relyingParty) Issuer() string { } func (rp *relyingParty) IsPKCE() bool { - return rp.pkce + return rp.pkce == pkceEnabled } func (rp *relyingParty) CookieHandler() *httphelper.CookieHandler { @@ -193,6 +206,7 @@ func NewRelyingPartyOAuth(config *oauth2.Config, options ...Option) (RelyingPart oauth2Only: true, unauthorizedHandler: DefaultUnauthorizedHandler, oauthAuthStyle: oauth2.AuthStyleAutoDetect, + pkce: pkceDisabled, } for _, optFunc := range options { @@ -201,6 +215,13 @@ func NewRelyingPartyOAuth(config *oauth2.Config, options ...Option) (RelyingPart } } + if rp.pkce == pkceFromDiscovery { + return nil, fmt.Errorf( + "%w: PKCE from discovery is not supported for OAuth2 only relying parties", + ErrInvalidOption, + ) + } + rp.oauthConfig.Endpoint.AuthStyle = rp.oauthAuthStyle // avoid races by calling these early @@ -226,6 +247,7 @@ func NewRelyingPartyOIDC(ctx context.Context, issuer, clientID, clientSecret, re httpClient: httphelper.DefaultHTTPClient, oauth2Only: false, oauthAuthStyle: oauth2.AuthStyleAutoDetect, + pkce: pkceDisabled, } for _, optFunc := range options { @@ -238,6 +260,9 @@ func NewRelyingPartyOIDC(ctx context.Context, issuer, clientID, clientSecret, re if err != nil { return nil, err } + if rp.useSigningAlgsFromDiscovery { + rp.verifierOpts = append(rp.verifierOpts, WithSupportedSigningAlgorithms(discoveryConfiguration.IDTokenSigningAlgValuesSupported...)) + } endpoints := GetEndpoints(discoveryConfiguration) rp.oauthConfig.Endpoint = endpoints.Endpoint rp.endpoints = endpoints @@ -245,6 +270,20 @@ func NewRelyingPartyOIDC(ctx context.Context, issuer, clientID, clientSecret, re rp.oauthConfig.Endpoint.AuthStyle = rp.oauthAuthStyle rp.endpoints.Endpoint.AuthStyle = rp.oauthAuthStyle + if rp.pkce == pkceFromDiscovery { + if slices.ContainsFunc( + discoveryConfiguration.CodeChallengeMethodsSupported, + func(method oidc.CodeChallengeMethod) bool { + return method == oidc.CodeChallengeMethodPlain || + method == oidc.CodeChallengeMethodS256 + }, + ) { + rp.pkce = pkceEnabled + } else { + rp.pkce = pkceDisabled + } + } + // avoid races by calling these early _ = rp.IDTokenVerifier() // sets idTokenVerifier _ = rp.ErrorHandler() // sets errorHandler @@ -276,7 +315,19 @@ func WithCookieHandler(cookieHandler *httphelper.CookieHandler) Option { // and exchanging the code challenge func WithPKCE(cookieHandler *httphelper.CookieHandler) Option { return func(rp *relyingParty) error { - rp.pkce = true + rp.pkce = pkceEnabled + rp.cookieHandler = cookieHandler + return nil + } +} + +// WithPKCEFromDiscovery enables Oauth2 Code Challenge if support is found in the discovery response from the OP. +// Passing this option to a Oauth2-only RP will result in an error, as there is no discovery call. +// It also sets a `CookieHandler` for securing the various redirects +// and exchanging the code challenge +func WithPKCEFromDiscovery(cookieHandler *httphelper.CookieHandler) Option { + return func(rp *relyingParty) error { + rp.pkce = pkceFromDiscovery rp.cookieHandler = cookieHandler return nil } @@ -320,7 +371,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)), resp. WithJWTProfile(SignerFromKeyAndKeyID(key, keyID) instead. func WithClientKey(path string) Option { return WithJWTProfile(SignerFromKeyPath(path)) } @@ -348,8 +399,19 @@ func WithLogger(logger *slog.Logger) Option { } } +// WithSigningAlgsFromDiscovery appends the [WithSupportedSigningAlgorithms] option to the Verifier Options. +// The algorithms returned in the `id_token_signing_alg_values_supported` from the discovery response will be set. +func WithSigningAlgsFromDiscovery() Option { + return func(rp *relyingParty) error { + rp.useSigningAlgsFromDiscovery = true + return nil + } +} + type SignerFromKey func() (jose.Signer, error) +// Deprecated: use [SignerFromKeyAndKeyID] instead. +// The function will be removed in the next major release. func SignerFromKeyPath(path string) SignerFromKey { return func() (jose.Signer, error) { config, err := client.ConfigFromKeyFile(path) @@ -360,6 +422,8 @@ func SignerFromKeyPath(path string) SignerFromKey { } } +// Deprecated: use [SignerFromKeyAndKeyID] instead. +// The function will be removed in the next major release. func SignerFromKeyFile(fileData []byte) SignerFromKey { return func() (jose.Signer, error) { config, err := client.ConfigFromKeyFileData(fileData) @@ -388,7 +452,7 @@ func AuthURL(state string, rp RelyingParty, opts ...AuthURLOpt) string { // AuthURLHandler extends the `AuthURL` method with a http redirect handler // including handling setting cookie for secure `state` transfer. -// Custom paramaters can optionally be set to the redirect URL. +// Custom parameters can optionally be set to the redirect URL. func AuthURLHandler(stateFn func() string, rp RelyingParty, urlParam ...URLParamOpt) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { opts := make([]AuthURLOpt, len(urlParam)) @@ -397,12 +461,19 @@ func AuthURLHandler(stateFn func() string, rp RelyingParty, urlParam ...URLParam } state := stateFn() - if err := trySetStateCookie(w, state, rp); err != nil { + if err := trySetStateCookie(r, w, state, rp); err != nil { unauthorizedError(w, r, "failed to create state cookie: "+err.Error(), state, rp) return } if rp.IsPKCE() { - codeChallenge, err := GenerateAndStoreCodeChallenge(w, rp) + var codeChallenge string + var err error + if rp.CookieHandler().IsRequestAware() { + codeChallenge, err = GenerateAndStoreCodeChallengeWithRequest(r, w, rp) + } else { + codeChallenge, err = GenerateAndStoreCodeChallenge(w, rp) + } + if err != nil { unauthorizedError(w, r, "failed to create code challenge: "+err.Error(), state, rp) return @@ -423,6 +494,15 @@ func GenerateAndStoreCodeChallenge(w http.ResponseWriter, rp RelyingParty) (stri return oidc.NewSHACodeChallenge(codeVerifier), nil } +// GenerateAndStoreCodeChallenge generates a PKCE code challenge and stores its verifier into a secure cookie +func GenerateAndStoreCodeChallengeWithRequest(r *http.Request, w http.ResponseWriter, rp RelyingParty) (string, error) { + codeVerifier := base64.RawURLEncoding.EncodeToString([]byte(uuid.New().String())) + if err := rp.CookieHandler().SetRequestAwareCookie(r, w, pkceCode, codeVerifier); err != nil { + return "", err + } + return oidc.NewSHACodeChallenge(codeVerifier), nil +} + // ErrMissingIDToken is returned when an id_token was expected, // but not received in the token response. var ErrMissingIDToken = errors.New("id_token missing") @@ -528,7 +608,7 @@ func CodeExchangeHandler[C oidc.IDClaims](callback CodeExchangeCallback[C], rp R rp.CookieHandler().DeleteCookie(w, pkceCode) } if rp.Signer() != nil { - assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer()}, time.Hour, rp.Signer()) + assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer(), rp.OAuthConfig().Endpoint.TokenURL}, time.Hour, rp.Signer()) if err != nil { unauthorizedError(w, r, "failed to build assertion: "+err.Error(), state, rp) return @@ -594,9 +674,16 @@ func Userinfo[U SubjectGetter](ctx context.Context, token, tokenType, subject st return userinfo, nil } -func trySetStateCookie(w http.ResponseWriter, state string, rp RelyingParty) error { +func trySetStateCookie(r *http.Request, w http.ResponseWriter, state string, rp RelyingParty) error { if rp.CookieHandler() != nil { - if err := rp.CookieHandler().SetCookie(w, stateParam, state); err != nil { + var err error + if rp.CookieHandler().IsRequestAware() { + err = rp.CookieHandler().SetRequestAwareCookie(r, w, stateParam, state) + } else { + err = rp.CookieHandler().SetCookie(w, stateParam, state) + } + + if err != nil { return err } } @@ -642,7 +729,7 @@ func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints { } } -// withURLParam sets custom url paramaters. +// withURLParam sets custom url parameters. // This is the generalized, unexported, function used by both // URLParamOpt and AuthURLOpt. func withURLParam(key, value string) func() []oauth2.AuthCodeOption { @@ -721,11 +808,11 @@ func (t tokenEndpointCaller) TokenEndpoint() string { type RefreshTokenRequest struct { RefreshToken string `schema:"refresh_token"` - Scopes oidc.SpaceDelimitedArray `schema:"scope"` - ClientID string `schema:"client_id"` - ClientSecret string `schema:"client_secret"` - ClientAssertion string `schema:"client_assertion"` - ClientAssertionType string `schema:"client_assertion_type"` + Scopes oidc.SpaceDelimitedArray `schema:"scope,omitempty"` + ClientID string `schema:"client_id,omitempty"` + ClientSecret string `schema:"client_secret,omitempty"` + ClientAssertion string `schema:"client_assertion,omitempty"` + ClientAssertionType string `schema:"client_assertion_type,omitempty"` GrantType oidc.GrantType `schema:"grant_type"` } @@ -734,7 +821,7 @@ type RefreshTokenRequest struct { // the old one should be considered invalid. // // In case the RP is not OAuth2 only and an IDToken was part of the response, -// the IDToken and AccessToken will be verfied +// the IDToken and AccessToken will be verified // and the IDToken and IDTokenClaims fields will be populated in the returned object. func RefreshTokens[C oidc.IDClaims](ctx context.Context, rp RelyingParty, refreshToken, clientAssertion, clientAssertionType string) (*oidc.Tokens[C], error) { ctx, span := client.Tracer.Start(ctx, "RefreshTokens") @@ -763,7 +850,7 @@ func RefreshTokens[C oidc.IDClaims](ctx context.Context, rp RelyingParty, refres return nil, err } -func EndSession(ctx context.Context, rp RelyingParty, idToken, optionalRedirectURI, optionalState string) (*url.URL, error) { +func EndSession(ctx context.Context, rp RelyingParty, idToken, optionalRedirectURI, optionalState, optionalLogoutHint string, optionalLocales oidc.Locales) (*url.URL, error) { ctx = logCtxWithRPData(ctx, rp, "function", "EndSession") ctx, span := client.Tracer.Start(ctx, "RefreshTokens") defer span.End() @@ -773,6 +860,8 @@ func EndSession(ctx context.Context, rp RelyingParty, idToken, optionalRedirectU ClientID: rp.OAuthConfig().ClientID, PostLogoutRedirectURI: optionalRedirectURI, State: optionalState, + LogoutHint: optionalLogoutHint, + UILocales: optionalLocales, } return client.CallEndSessionEndpoint(ctx, request, nil, rp) } diff --git a/pkg/client/rp/relying_party_test.go b/pkg/client/rp/relying_party_test.go index 67bad18..cd24e8f 100644 --- a/pkg/client/rp/relying_party_test.go +++ b/pkg/client/rp/relying_party_test.go @@ -2,14 +2,20 @@ package rp import ( "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + tu "github.com/zitadel/oidc/v4/internal/testutil" "github.com/zitadel/oidc/v4/pkg/oidc" - "golang.org/x/oauth2" ) func Test_verifyTokenResponse(t *testing.T) { @@ -22,6 +28,7 @@ func Test_verifyTokenResponse(t *testing.T) { KeySet: tu.KeySet{}, MaxAge: 2 * time.Minute, ACR: tu.ACRVerify, + AZP: oidc.DefaultAZPVerifier(tu.ValidClientID), Nonce: func(context.Context) string { return tu.ValidNonce }, } tests := []struct { @@ -105,3 +112,82 @@ func Test_verifyTokenResponse(t *testing.T) { }) } } + +func Test_PKCEFromDiscovery(t *testing.T) { + tests := []struct { + name string + methods []string + expected bool + }{ + {name: "nil", methods: nil, expected: false}, + {name: "empty", methods: []string{}, expected: false}, + {name: "invalid", methods: []string{"invalid"}, expected: false}, + {name: "plain", methods: []string{"plain"}, expected: true}, + {name: "S256", methods: []string{"S256"}, expected: true}, + {name: "both", methods: []string{"plain", "S256"}, expected: true}, + {name: "mixed", methods: []string{"invalid", "S256"}, expected: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != oidc.DiscoveryEndpoint { + w.WriteHeader(http.StatusNotFound) + return + } + + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": "http://" + r.Host, + "code_challenge_methods_supported": tt.methods, + }); err != nil { + t.Fatalf("unexpected error encoding '%v' to JSON: %v", tt.methods, err) + } + })) + defer server.Close() + + t.Log("issuer", server.URL) + + clientID := t.Name() + "-client" + clientSecret := t.Name() + "-secret" + targetURL := "http://local-site" + rp, err := NewRelyingPartyOIDC( + t.Context(), + server.URL, + clientID, + clientSecret, + targetURL, + nil, + WithPKCEFromDiscovery(nil), + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rp == nil { + t.Fatalf("relying party is nil") + } + + if rp.IsPKCE() != tt.expected { + t.Fatalf("expected PKCE to be %v, got %v", tt.expected, rp.IsPKCE()) + } + }) + } +} + +func Test_Oauth2OnlyRPWithPKCEFromDiscovery(t *testing.T) { + rp, err := NewRelyingPartyOAuth(&oauth2.Config{}, WithPKCEFromDiscovery(nil)) + + if !errors.Is(err, ErrInvalidOption) { + t.Fatal("Oauth2 only RP should return an invalid option error when called with 'WithPKCEFromDiscovery'") + } + + if !strings.Contains(err.Error(), "PKCE from discovery is not supported for OAuth2 only relying parties") { + t.Fatal("Wrong error message returned when calling 'WithPKCEFromDiscovery' on an OAuth2 only relying party") + } + + if rp != nil { + t.Fatal("RP should be nil when calling 'WithPKCEFromDiscovery' on an OAuth2 only relying party") + } +} diff --git a/pkg/client/rp/verifier.go b/pkg/client/rp/verifier.go index 477417f..52caf4c 100644 --- a/pkg/client/rp/verifier.go +++ b/pkg/client/rp/verifier.go @@ -4,7 +4,7 @@ import ( "context" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v4/pkg/client" "github.com/zitadel/oidc/v4/pkg/oidc" @@ -57,7 +57,7 @@ func VerifyIDToken[C oidc.Claims](ctx context.Context, token string, v *IDTokenV return nilClaims, err } - if err = oidc.CheckAuthorizedParty(claims, v.ClientID); err != nil { + if err = oidc.CheckAZPVerifier(claims, v.AZP); err != nil { return nilClaims, err } @@ -73,8 +73,10 @@ func VerifyIDToken[C oidc.Claims](ctx context.Context, token string, v *IDTokenV return nilClaims, err } - if err = oidc.CheckNonce(claims, v.Nonce(ctx)); err != nil { - return nilClaims, err + if v.Nonce != nil { + if err = oidc.CheckNonce(claims, v.Nonce(ctx)); err != nil { + return nilClaims, err + } } if err = oidc.CheckAuthorizationContextClassReference(claims, v.ACR); err != nil { @@ -84,6 +86,7 @@ func VerifyIDToken[C oidc.Claims](ctx context.Context, token string, v *IDTokenV if err = oidc.CheckAuthTime(claims, v.MaxAge); err != nil { return nilClaims, err } + return claims, nil } @@ -116,6 +119,7 @@ func NewIDTokenVerifier(issuer, clientID string, keySet oidc.KeySet, options ... Nonce: func(_ context.Context) string { return "" }, + AZP: oidc.DefaultAZPVerifier(clientID), } for _, opts := range options { @@ -157,6 +161,13 @@ func WithACRVerifier(verifier oidc.ACRVerifier) VerifierOption { } } +// WithAZPVerifier sets the verifier for the azp claim +func WithAZPVerifier(verifier oidc.AZPVerifier) VerifierOption { + return func(v *IDTokenVerifier) { + v.AZP = verifier + } +} + // WithAuthTimeMaxAge provides the ability to define the maximum duration between auth_time and now func WithAuthTimeMaxAge(maxAge time.Duration) VerifierOption { return func(v *IDTokenVerifier) { diff --git a/pkg/client/rp/verifier_test.go b/pkg/client/rp/verifier_test.go index 7f7d722..b44f4a3 100644 --- a/pkg/client/rp/verifier_test.go +++ b/pkg/client/rp/verifier_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" tu "github.com/zitadel/oidc/v4/internal/testutil" @@ -21,6 +21,7 @@ func TestVerifyTokens(t *testing.T) { KeySet: tu.KeySet{}, MaxAge: 2 * time.Minute, ACR: tu.ACRVerify, + AZP: oidc.DefaultAZPVerifier(tu.ValidClientID), Nonce: func(context.Context) string { return tu.ValidNonce }, ClientID: tu.ValidClientID, } @@ -99,23 +100,23 @@ func TestVerifyIDToken(t *testing.T) { KeySet: tu.KeySet{}, MaxAge: 2 * time.Minute, ACR: tu.ACRVerify, + AZP: oidc.DefaultAZPVerifier(tu.ValidClientID), Nonce: func(context.Context) string { return tu.ValidNonce }, + ClientID: tu.ValidClientID, } tests := []struct { - name string - clientID string - tokenClaims func() (string, *oidc.IDTokenClaims) - wantErr bool + name string + tokenClaims func() (string, *oidc.IDTokenClaims) + customVerifier func(verifier *IDTokenVerifier) + wantErr bool }{ { name: "success", - clientID: tu.ValidClientID, tokenClaims: tu.ValidIDToken, }, { - name: "custom claims", - clientID: tu.ValidClientID, + name: "custom claims", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDTokenCustom( tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, @@ -125,21 +126,31 @@ func TestVerifyIDToken(t *testing.T) { ) }, }, + { + name: "skip nonce check", + customVerifier: func(verifier *IDTokenVerifier) { + verifier.Nonce = nil + }, + 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, "", + ) + }, + }, { 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, + name: "empty subject", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDToken( tu.ValidIssuer, "", tu.ValidAudience, @@ -150,8 +161,7 @@ func TestVerifyIDToken(t *testing.T) { wantErr: true, }, { - name: "wrong issuer", - clientID: tu.ValidClientID, + name: "wrong issuer", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDToken( "foo", tu.ValidSubject, tu.ValidAudience, @@ -162,14 +172,15 @@ func TestVerifyIDToken(t *testing.T) { wantErr: true, }, { - name: "wrong clientID", - clientID: "foo", + name: "wrong clientID", + customVerifier: func(verifier *IDTokenVerifier) { + verifier.ClientID = "foo" + }, tokenClaims: tu.ValidIDToken, wantErr: true, }, { - name: "expired", - clientID: tu.ValidClientID, + name: "expired", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDToken( tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, @@ -180,8 +191,7 @@ func TestVerifyIDToken(t *testing.T) { wantErr: true, }, { - name: "wrong IAT", - clientID: tu.ValidClientID, + name: "wrong IAT", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDToken( tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, @@ -192,8 +202,7 @@ func TestVerifyIDToken(t *testing.T) { wantErr: true, }, { - name: "wrong acr", - clientID: tu.ValidClientID, + name: "wrong acr", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDToken( tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, @@ -204,8 +213,7 @@ func TestVerifyIDToken(t *testing.T) { wantErr: true, }, { - name: "expired auth", - clientID: tu.ValidClientID, + name: "expired auth", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDToken( tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, @@ -216,8 +224,7 @@ func TestVerifyIDToken(t *testing.T) { wantErr: true, }, { - name: "wrong nonce", - clientID: tu.ValidClientID, + name: "wrong nonce", tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.NewIDToken( tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience, @@ -231,7 +238,10 @@ func TestVerifyIDToken(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { token, want := tt.tokenClaims() - verifier.ClientID = tt.clientID + if tt.customVerifier != nil { + tt.customVerifier(verifier) + } + got, err := VerifyIDToken[*oidc.IDTokenClaims](context.Background(), token, verifier) if tt.wantErr { assert.Error(t, err) @@ -325,6 +335,7 @@ func TestNewIDTokenVerifier(t *testing.T) { WithIssuedAtMaxAge(time.Hour), WithNonce(nil), // otherwise assert.Equal will fail on the function WithACRVerifier(nil), + WithAZPVerifier(nil), WithAuthTimeMaxAge(2 * time.Hour), WithSupportedSigningAlgorithms("ABC", "DEF"), }, diff --git a/pkg/client/tokenexchange/tokenexchange.go b/pkg/client/tokenexchange/tokenexchange.go index dfeb88f..db58f4d 100644 --- a/pkg/client/tokenexchange/tokenexchange.go +++ b/pkg/client/tokenexchange/tokenexchange.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v4/pkg/client" httphelper "github.com/zitadel/oidc/v4/pkg/http" "github.com/zitadel/oidc/v4/pkg/oidc" diff --git a/pkg/crypto/hash.go b/pkg/crypto/hash.go index 0ed2774..14acdee 100644 --- a/pkg/crypto/hash.go +++ b/pkg/crypto/hash.go @@ -8,7 +8,7 @@ import ( "fmt" "hash" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" ) var ErrUnsupportedAlgorithm = errors.New("unsupported signing algorithm") @@ -21,6 +21,14 @@ func GetHashAlgorithm(sigAlgorithm jose.SignatureAlgorithm) (hash.Hash, error) { return sha512.New384(), nil case jose.RS512, jose.ES512, jose.PS512: return sha512.New(), nil + + // There is no published spec for this yet, but we have confirmation it will get published. + // There is consensus here: https://bitbucket.org/openid/connect/issues/1125/_hash-algorithm-for-eddsa-id-tokens + // Currently Go and go-jose only supports the ed25519 curve key for EdDSA, so we can safely assume sha512 here. + // It is unlikely ed448 will ever be supported: https://github.com/golang/go/issues/29390 + case jose.EdDSA: + return sha512.New(), nil + default: return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, sigAlgorithm) } diff --git a/pkg/crypto/key.go b/pkg/crypto/key.go index 79e2046..12bca28 100644 --- a/pkg/crypto/key.go +++ b/pkg/crypto/key.go @@ -1,22 +1,45 @@ package crypto import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" "crypto/rsa" "crypto/x509" "encoding/pem" "errors" + + "github.com/go-jose/go-jose/v4" ) -func BytesToPrivateKey(b []byte) (*rsa.PrivateKey, error) { +var ( + ErrPEMDecode = errors.New("PEM decode failed") + ErrUnsupportedFormat = errors.New("key is neither in PKCS#1 nor PKCS#8 format") + ErrUnsupportedPrivateKey = errors.New("unsupported key type, must be RSA, ECDSA or ED25519 private key") +) + +func BytesToPrivateKey(b []byte) (crypto.PublicKey, jose.SignatureAlgorithm, error) { block, _ := pem.Decode(b) if block == nil { - return nil, errors.New("PEM decode failed") + return nil, "", ErrPEMDecode } - key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err == nil { + return privateKey, jose.RS256, nil + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { - return nil, err + return nil, "", ErrUnsupportedFormat + } + switch privateKey := key.(type) { + case *rsa.PrivateKey: + return privateKey, jose.RS256, nil + case ed25519.PrivateKey: + return privateKey, jose.EdDSA, nil + case *ecdsa.PrivateKey: + return privateKey, jose.ES256, nil + default: + return nil, "", ErrUnsupportedPrivateKey } - - return key, nil } diff --git a/pkg/crypto/key_test.go b/pkg/crypto/key_test.go index 608b627..6e3329e 100644 --- a/pkg/crypto/key_test.go +++ b/pkg/crypto/key_test.go @@ -1,21 +1,64 @@ package crypto_test import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" "testing" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" - "github.com/zitadel/oidc/v4/pkg/crypto" + zcrypto "github.com/zitadel/oidc/v4/pkg/crypto" ) -func TestBytesToPrivateKey(tt *testing.T) { - tt.Run("PEMDecodeError", func(t *testing.T) { - _, err := crypto.BytesToPrivateKey([]byte("The non-PEM sequence")) - assert.EqualError(t, err, "PEM decode failed") - }) - - tt.Run("InvalidKeyFormat", func(t *testing.T) { - _, err := crypto.BytesToPrivateKey([]byte(`-----BEGIN PRIVATE KEY----- +func TestBytesToPrivateKey(t *testing.T) { + type args struct { + key []byte + } + type want struct { + key crypto.Signer + algorithm jose.SignatureAlgorithm + err error + } + tests := []struct { + name string + args args + want want + }{ + { + name: "PEMDecodeError", + args: args{ + key: []byte("The non-PEM sequence"), + }, + want: want{ + err: zcrypto.ErrPEMDecode, + }, + }, + { + name: "PKCS#1 RSA", + args: args{ + key: []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu +KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm +o3qGy0t6z09AIJtH+5OeRV1be+N4cDYJKffGzDa88vQENZiRm0GRq6a+HPGQMd2k +TQIhAKMSvzIBnni7ot/OSie2TmJLY4SwTQAevXysE2RbFDYdAiEBCUEaRQnMnbp7 +9mxDXDf6AU0cN/RPBjb9qSHDcWZHGzUCIG2Es59z8ugGrDY+pxLQnwfotadxd+Uy +v/Ow5T0q5gIJAiEAyS4RaI9YG8EWx/2w0T67ZUVAw8eOMB6BIUg0Xcu+3okCIBOs +/5OiPgoTdSy7bcF9IGpSE8ZgGKzgYQVZeN97YE00 +-----END RSA PRIVATE KEY-----`), + }, + want: want{ + key: &rsa.PrivateKey{}, + algorithm: jose.RS256, + err: nil, + }, + }, + { + name: "PKCS#8 RSA", + args: args{ + key: []byte(`-----BEGIN PRIVATE KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCfaDB7pK/fmP/I 7IusSK8lTCBnPZghqIbVLt2QHYAMoEF1CaF4F4rxo2vl1Mt8gwsq4T3osQFZMvnL YHb7KNyUoJgTjLxJQADv2u4Q3U38heAzK5Tp4ry4MCnuyJIqAPK1GiruwEq4zQrx @@ -42,21 +85,50 @@ srJnjF0H8oKmAY6hw+1Tm/n/b08p+RyL48TgVSE2vhUCgYA3BWpkD4PlCcn/FZsq OrLFyFXI6jIaxskFtsRW1IxxIlAdZmxfB26P/2gx6VjLdxJI/RRPkJyEN2dP7CbR BDjb565dy1O9D6+UrY70Iuwjz+OcALRBBGTaiF2pLn6IhSzNI2sy/tXX8q8dBlg9 OFCrqT/emes3KytTPfa5NZtYeQ== ------END PRIVATE KEY-----`)) - assert.EqualError(t, err, "x509: failed to parse private key (use ParsePKCS8PrivateKey instead for this key format)") - }) +-----END PRIVATE KEY-----`), + }, + want: want{ + key: &rsa.PrivateKey{}, + algorithm: jose.RS256, + err: nil, + }, + }, + { + name: "PKCS#8 ECDSA", + args: args{ + key: []byte(`-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwwOZSU4GlP7ps/Wp +V6o0qRwxultdfYo/uUuj48QZjSuhRANCAATMiI2Han+ABKmrk5CNlxRAGC61w4d3 +G4TAeuBpyzqJ7x/6NjCxoQzJzZHtNjIfjVATI59XFZWF59GhtSZbShAr +-----END PRIVATE KEY-----`), + }, + want: want{ + key: &ecdsa.PrivateKey{}, + algorithm: jose.ES256, + err: nil, + }, + }, + { + name: "PKCS#8 ED25519", + args: args{ + key: []byte(`-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIHu6ZtDsjjauMasBxnS9Fg87UJwKfcT/oiq6S0ktbky8 +-----END PRIVATE KEY-----`), + }, + want: want{ + key: ed25519.PrivateKey{}, + algorithm: jose.EdDSA, + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, algorithm, err := zcrypto.BytesToPrivateKey(tt.args.key) + assert.IsType(t, tt.want.key, key) + assert.Equal(t, tt.want.algorithm, algorithm) + assert.ErrorIs(t, tt.want.err, err) + }) - tt.Run("Ok", func(t *testing.T) { - key, err := crypto.BytesToPrivateKey([]byte(`-----BEGIN RSA PRIVATE KEY----- -MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu -KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm -o3qGy0t6z09AIJtH+5OeRV1be+N4cDYJKffGzDa88vQENZiRm0GRq6a+HPGQMd2k -TQIhAKMSvzIBnni7ot/OSie2TmJLY4SwTQAevXysE2RbFDYdAiEBCUEaRQnMnbp7 -9mxDXDf6AU0cN/RPBjb9qSHDcWZHGzUCIG2Es59z8ugGrDY+pxLQnwfotadxd+Uy -v/Ow5T0q5gIJAiEAyS4RaI9YG8EWx/2w0T67ZUVAw8eOMB6BIUg0Xcu+3okCIBOs -/5OiPgoTdSy7bcF9IGpSE8ZgGKzgYQVZeN97YE00 ------END RSA PRIVATE KEY-----`)) - assert.NoError(t, err) - assert.NotNil(t, key) - }) + } } diff --git a/pkg/crypto/sign.go b/pkg/crypto/sign.go index a197955..f46d535 100644 --- a/pkg/crypto/sign.go +++ b/pkg/crypto/sign.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" ) func Sign(object any, signer jose.Signer) (string, error) { diff --git a/pkg/http/cookie.go b/pkg/http/cookie.go index 1ebc9e2..7724aeb 100644 --- a/pkg/http/cookie.go +++ b/pkg/http/cookie.go @@ -8,12 +8,13 @@ import ( ) type CookieHandler struct { - securecookie *securecookie.SecureCookie - secureOnly bool - sameSite http.SameSite - maxAge int - domain string - path string + securecookie *securecookie.SecureCookie + secureCookieFunc func(r *http.Request) (*securecookie.SecureCookie, error) + secureOnly bool + sameSite http.SameSite + maxAge int + domain string + path string } func NewCookieHandler(hashKey, encryptKey []byte, opts ...CookieHandlerOpt) *CookieHandler { @@ -30,6 +31,21 @@ func NewCookieHandler(hashKey, encryptKey []byte, opts ...CookieHandlerOpt) *Coo return c } +func NewRequestAwareCookieHandler(secureCookieFunc func(r *http.Request) (*securecookie.SecureCookie, error), opts ...CookieHandlerOpt) *CookieHandler { + c := &CookieHandler{ + secureCookieFunc: secureCookieFunc, + secureOnly: true, + sameSite: http.SameSiteLaxMode, + path: "/", + } + + for _, opt := range opts { + opt(c) + } + + return c +} + type CookieHandlerOpt func(*CookieHandler) func WithUnsecure() CookieHandlerOpt { @@ -47,6 +63,10 @@ func WithSameSite(sameSite http.SameSite) CookieHandlerOpt { func WithMaxAge(maxAge int) CookieHandlerOpt { return func(c *CookieHandler) { c.maxAge = maxAge + if c.IsRequestAware() { + return + } + c.securecookie.MaxAge(maxAge) } } @@ -68,8 +88,17 @@ func (c *CookieHandler) CheckCookie(r *http.Request, name string) (string, error if err != nil { return "", err } + + secureCookie := c.securecookie + if c.IsRequestAware() { + secureCookie, err = c.secureCookieFunc(r) + if err != nil { + return "", err + } + } + var value string - if err := c.securecookie.Decode(name, cookie.Value, &value); err != nil { + if err := secureCookie.Decode(name, cookie.Value, &value); err != nil { return "", err } return value, nil @@ -87,6 +116,10 @@ func (c *CookieHandler) CheckQueryCookie(r *http.Request, name string) (string, } func (c *CookieHandler) SetCookie(w http.ResponseWriter, name, value string) error { + if c.IsRequestAware() { + return errors.New("Cookie handler is request aware") + } + encoded, err := c.securecookie.Encode(name, value) if err != nil { return err @@ -104,6 +137,35 @@ func (c *CookieHandler) SetCookie(w http.ResponseWriter, name, value string) err return nil } +func (c *CookieHandler) SetRequestAwareCookie(r *http.Request, w http.ResponseWriter, name string, value string) error { + if !c.IsRequestAware() { + return errors.New("Cookie handler is not request aware") + } + + secureCookie, err := c.secureCookieFunc(r) + if err != nil { + return err + } + + encoded, err := secureCookie.Encode(name, value) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: encoded, + Domain: c.domain, + Path: c.path, + MaxAge: c.maxAge, + HttpOnly: true, + Secure: c.secureOnly, + SameSite: c.sameSite, + }) + + return nil +} + func (c *CookieHandler) DeleteCookie(w http.ResponseWriter, name string) { http.SetCookie(w, &http.Cookie{ Name: name, @@ -116,3 +178,7 @@ func (c *CookieHandler) DeleteCookie(w http.ResponseWriter, name string) { SameSite: c.sameSite, }) } + +func (c *CookieHandler) IsRequestAware() bool { + return c.secureCookieFunc != nil +} diff --git a/pkg/oidc/device_authorization.go b/pkg/oidc/device_authorization.go index 68b8efa..a6417ba 100644 --- a/pkg/oidc/device_authorization.go +++ b/pkg/oidc/device_authorization.go @@ -1,5 +1,7 @@ package oidc +import "encoding/json" + // DeviceAuthorizationRequest implements // https://www.rfc-editor.org/rfc/rfc8628#section-3.1, // 3.1 Device Authorization Request. @@ -20,6 +22,26 @@ type DeviceAuthorizationResponse struct { Interval int `json:"interval,omitempty"` } +func (resp *DeviceAuthorizationResponse) UnmarshalJSON(data []byte) error { + type Alias DeviceAuthorizationResponse + aux := &struct { + // workaround misspelling of verification_uri + // https://stackoverflow.com/q/76696956/5690223 + // https://developers.google.com/identity/protocols/oauth2/limited-input-device?hl=fr#success-response + VerificationURL string `json:"verification_url"` + *Alias + }{ + Alias: (*Alias)(resp), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if resp.VerificationURI == "" { + resp.VerificationURI = aux.VerificationURL + } + return nil +} + // DeviceAccessTokenRequest implements // https://www.rfc-editor.org/rfc/rfc8628#section-3.4, // Device Access Token Request. diff --git a/pkg/oidc/device_authorization_test.go b/pkg/oidc/device_authorization_test.go new file mode 100644 index 0000000..c4c6637 --- /dev/null +++ b/pkg/oidc/device_authorization_test.go @@ -0,0 +1,30 @@ +package oidc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeviceAuthorizationResponse_UnmarshalJSON(t *testing.T) { + jsonStr := `{ + "device_code": "deviceCode", + "user_code": "userCode", + "verification_url": "http://example.com/verify", + "expires_in": 3600, + "interval": 5 + }` + + expected := &DeviceAuthorizationResponse{ + DeviceCode: "deviceCode", + UserCode: "userCode", + VerificationURI: "http://example.com/verify", + ExpiresIn: 3600, + Interval: 5, + } + + var resp DeviceAuthorizationResponse + err := resp.UnmarshalJSON([]byte(jsonStr)) + assert.NoError(t, err) + assert.Equal(t, expected, &resp) +} diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go index 14fce5e..62288d1 100644 --- a/pkg/oidc/discovery.go +++ b/pkg/oidc/discovery.go @@ -145,6 +145,14 @@ type DiscoveryConfiguration struct { // OPTermsOfServiceURI is a URL the OpenID Provider provides to the person registering the Client to read about OpenID Provider's terms of service. OPTermsOfServiceURI string `json:"op_tos_uri,omitempty"` + + // BackChannelLogoutSupported specifies whether the OP supports back-channel logout (https://openid.net/specs/openid-connect-backchannel-1_0.html), + // with true indicating support. If omitted, the default value is false. + BackChannelLogoutSupported bool `json:"backchannel_logout_supported,omitempty"` + + // BackChannelLogoutSessionSupported specifies whether the OP can pass a sid (session ID) Claim in the Logout Token to identify the RP session with the OP. + // If supported, the sid Claim is also included in ID Tokens issued by the OP. If omitted, the default value is false. + BackChannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported,omitempty"` } type AuthMethod string diff --git a/pkg/oidc/error.go b/pkg/oidc/error.go index 2f0572d..d93cf44 100644 --- a/pkg/oidc/error.go +++ b/pkg/oidc/error.go @@ -1,6 +1,7 @@ package oidc import ( + "encoding/json" "errors" "fmt" "log/slog" @@ -132,7 +133,28 @@ type Error struct { ErrorType errorType `json:"error" schema:"error"` Description string `json:"error_description,omitempty" schema:"error_description,omitempty"` State string `json:"state,omitempty" schema:"state,omitempty"` + SessionState string `json:"session_state,omitempty" schema:"session_state,omitempty"` redirectDisabled bool `schema:"-"` + returnParent bool `schema:"-"` +} + +func (e *Error) MarshalJSON() ([]byte, error) { + m := struct { + Error errorType `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + State string `json:"state,omitempty"` + SessionState string `json:"session_state,omitempty"` + Parent string `json:"parent,omitempty"` + }{ + Error: e.ErrorType, + ErrorDescription: e.Description, + State: e.State, + SessionState: e.SessionState, + } + if e.returnParent { + m.Parent = e.Parent.Error() + } + return json.Marshal(m) } func (e *Error) Error() string { @@ -157,7 +179,8 @@ func (e *Error) Is(target error) bool { } return e.ErrorType == t.ErrorType && (e.Description == t.Description || t.Description == "") && - (e.State == t.State || t.State == "") + (e.State == t.State || t.State == "") && + (e.SessionState == t.SessionState || t.SessionState == "") } func (e *Error) WithParent(err error) *Error { @@ -165,6 +188,18 @@ func (e *Error) WithParent(err error) *Error { return e } +// WithReturnParentToClient allows returning the set parent error to the HTTP client. +// Currently it only supports setting the parent inside JSON responses, not redirect URLs. +// As Go errors don't unmarshal well, only the marshaller is implemented for the moment. +// +// Warning: parent errors may contain sensitive data or unwanted details about the server status. +// Also, the `parent` field is not a standard error field and might confuse certain clients +// that require fully compliant responses. +func (e *Error) WithReturnParentToClient(b bool) *Error { + e.returnParent = b + return e +} + func (e *Error) WithDescription(desc string, args ...any) *Error { e.Description = fmt.Sprintf(desc, args...) return e @@ -211,6 +246,9 @@ func (e *Error) LogValue() slog.Value { if e.State != "" { attrs = append(attrs, slog.String("state", e.State)) } + if e.SessionState != "" { + attrs = append(attrs, slog.String("session_state", e.SessionState)) + } if e.redirectDisabled { attrs = append(attrs, slog.Bool("redirect_disabled", e.redirectDisabled)) } diff --git a/pkg/oidc/error_test.go b/pkg/oidc/error_test.go index 2eeb4e6..40d30b1 100644 --- a/pkg/oidc/error_test.go +++ b/pkg/oidc/error_test.go @@ -1,11 +1,14 @@ package oidc import ( + "encoding/json" + "errors" "io" "log/slog" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDefaultToServerError(t *testing.T) { @@ -151,3 +154,39 @@ func TestError_LogValue(t *testing.T) { }) } } + +func TestError_MarshalJSON(t *testing.T) { + tests := []struct { + name string + e *Error + want string + }{ + { + name: "simple error", + e: ErrAccessDenied(), + want: `{"error":"access_denied","error_description":"The authorization request was denied."}`, + }, + { + name: "with description", + e: ErrAccessDenied().WithDescription("oops"), + want: `{"error":"access_denied","error_description":"oops"}`, + }, + { + name: "with parent", + e: ErrServerError().WithParent(errors.New("oops")), + want: `{"error":"server_error"}`, + }, + { + name: "with return parent", + e: ErrServerError().WithParent(errors.New("oops")).WithReturnParentToClient(true), + want: `{"error":"server_error","parent":"oops"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.e) + require.NoError(t, err) + assert.JSONEq(t, tt.want, string(got)) + }) + } +} diff --git a/pkg/oidc/keyset.go b/pkg/oidc/keyset.go index 6031c01..948a349 100644 --- a/pkg/oidc/keyset.go +++ b/pkg/oidc/keyset.go @@ -6,8 +6,9 @@ import ( "crypto/ed25519" "crypto/rsa" "errors" + "strings" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" ) const ( @@ -92,17 +93,17 @@ func FindMatchingKey(keyID, use, expectedAlg string, keys ...jose.JSONWebKey) (k } func algToKeyType(key any, alg string) bool { - switch alg[0] { - case 'R', 'P': + if strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS") { _, ok := key.(*rsa.PublicKey) return ok - case 'E': + } + if strings.HasPrefix(alg, "ES") { _, ok := key.(*ecdsa.PublicKey) return ok - case 'O': - _, ok := key.(*ed25519.PublicKey) - return ok - default: - return false } + if alg == string(jose.EdDSA) { + _, ok := key.(ed25519.PublicKey) + return ok + } + return false } diff --git a/pkg/oidc/keyset_test.go b/pkg/oidc/keyset_test.go index f8641f2..ac1d867 100644 --- a/pkg/oidc/keyset_test.go +++ b/pkg/oidc/keyset_test.go @@ -7,7 +7,7 @@ import ( "reflect" "testing" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" ) func TestFindKey(t *testing.T) { diff --git a/pkg/oidc/session.go b/pkg/oidc/session.go index b470d1e..39f9f08 100644 --- a/pkg/oidc/session.go +++ b/pkg/oidc/session.go @@ -1,10 +1,12 @@ package oidc // EndSessionRequest for the RP-Initiated Logout according to: -//https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout +// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout type EndSessionRequest struct { - IdTokenHint string `schema:"id_token_hint"` - ClientID string `schema:"client_id"` - PostLogoutRedirectURI string `schema:"post_logout_redirect_uri"` - State string `schema:"state"` + IdTokenHint string `schema:"id_token_hint"` + LogoutHint string `schema:"logout_hint"` + ClientID string `schema:"client_id"` + PostLogoutRedirectURI string `schema:"post_logout_redirect_uri"` + State string `schema:"state"` + UILocales Locales `schema:"ui_locales"` } diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index 19b37dd..94f3804 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -5,10 +5,11 @@ import ( "os" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "golang.org/x/oauth2" "github.com/muhlemmer/gu" + "github.com/zitadel/oidc/v4/pkg/crypto" ) @@ -116,6 +117,7 @@ func NewAccessTokenClaims(issuer, subject string, audience []string, expiration Expiration: FromTime(expiration), IssuedAt: FromTime(now), NotBefore: FromTime(now), + ClientID: clientID, JWTID: jwtid, }, } @@ -228,12 +230,13 @@ func (c *ActorClaims) UnmarshalJSON(data []byte) error { } type AccessTokenResponse struct { - AccessToken string `json:"access_token,omitempty" schema:"access_token,omitempty"` - TokenType string `json:"token_type,omitempty" schema:"token_type,omitempty"` - RefreshToken string `json:"refresh_token,omitempty" schema:"refresh_token,omitempty"` - ExpiresIn uint64 `json:"expires_in,omitempty" schema:"expires_in,omitempty"` - IDToken string `json:"id_token,omitempty" schema:"id_token,omitempty"` - State string `json:"state,omitempty" schema:"state,omitempty"` + AccessToken string `json:"access_token,omitempty" schema:"access_token,omitempty"` + TokenType string `json:"token_type,omitempty" schema:"token_type,omitempty"` + RefreshToken string `json:"refresh_token,omitempty" schema:"refresh_token,omitempty"` + ExpiresIn uint64 `json:"expires_in,omitempty" schema:"expires_in,omitempty"` + IDToken string `json:"id_token,omitempty" schema:"id_token,omitempty"` + State string `json:"state,omitempty" schema:"state,omitempty"` + Scope SpaceDelimitedArray `json:"scope,omitempty" schema:"scope,omitempty"` } type JWTProfileAssertionClaims struct { @@ -344,12 +347,12 @@ func AppendClientIDToAudience(clientID string, audience []string) []string { } func GenerateJWTProfileToken(assertion *JWTProfileAssertionClaims) (string, error) { - privateKey, err := crypto.BytesToPrivateKey(assertion.PrivateKey) + privateKey, algorithm, err := crypto.BytesToPrivateKey(assertion.PrivateKey) if err != nil { return "", err } key := jose.SigningKey{ - Algorithm: jose.RS256, + Algorithm: algorithm, Key: &jose.JSONWebKey{Key: privateKey, KeyID: assertion.PrivateKeyID}, } signer, err := jose.NewSigner(key, &jose.SignerOptions{}) @@ -380,3 +383,40 @@ type TokenExchangeResponse struct { // if the requested_token_type was Access Token and scope contained openid. IDToken string `json:"id_token,omitempty"` } + +type LogoutTokenClaims struct { + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience Audience `json:"aud,omitempty"` + IssuedAt Time `json:"iat,omitempty"` + Expiration Time `json:"exp,omitempty"` + JWTID string `json:"jti,omitempty"` + Events map[string]any `json:"events,omitempty"` + SessionID string `json:"sid,omitempty"` + Claims map[string]any `json:"-"` +} + +type ltcAlias LogoutTokenClaims + +func (i *LogoutTokenClaims) MarshalJSON() ([]byte, error) { + return mergeAndMarshalClaims((*ltcAlias)(i), i.Claims) +} + +func (i *LogoutTokenClaims) UnmarshalJSON(data []byte) error { + return unmarshalJSONMulti(data, (*ltcAlias)(i), &i.Claims) +} + +func NewLogoutTokenClaims(issuer, subject string, audience Audience, expiration time.Time, jwtID, sessionID string, skew time.Duration) *LogoutTokenClaims { + return &LogoutTokenClaims{ + Issuer: issuer, + Subject: subject, + Audience: audience, + IssuedAt: FromTime(time.Now().Add(-skew)), + Expiration: FromTime(expiration), + JWTID: jwtID, + Events: map[string]any{ + "http://schemas.openid.net/event/backchannel-logout": struct{}{}, + }, + SessionID: sessionID, + } +} diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index b07b333..500c02b 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -6,7 +6,7 @@ import ( "slices" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" ) const ( @@ -72,10 +72,10 @@ type AccessTokenRequest struct { Code string `schema:"code"` RedirectURI string `schema:"redirect_uri"` ClientID string `schema:"client_id"` - ClientSecret string `schema:"client_secret"` - CodeVerifier string `schema:"code_verifier"` - ClientAssertion string `schema:"client_assertion"` - ClientAssertionType string `schema:"client_assertion_type"` + ClientSecret string `schema:"client_secret,omitempty"` + CodeVerifier string `schema:"code_verifier,omitempty"` + ClientAssertion string `schema:"client_assertion,omitempty"` + ClientAssertionType string `schema:"client_assertion_type,omitempty"` } func (a *AccessTokenRequest) GrantType() GrantType { @@ -240,6 +240,6 @@ type ClientCredentialsRequest struct { 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"` + ClientAssertion string `schema:"client_assertion,omitempty"` + ClientAssertionType string `schema:"client_assertion_type,omitempty"` } diff --git a/pkg/oidc/token_test.go b/pkg/oidc/token_test.go index 9f9ee2d..a922371 100644 --- a/pkg/oidc/token_test.go +++ b/pkg/oidc/token_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "golang.org/x/text/language" ) @@ -145,6 +145,7 @@ func TestNewAccessTokenClaims(t *testing.T) { Subject: "hello@me.com", Audience: Audience{"foo"}, Expiration: 12345, + ClientID: "foo", JWTID: "900", }, } @@ -241,3 +242,39 @@ func TestIDTokenClaims_GetUserInfo(t *testing.T) { got := idTokenData.GetUserInfo() assert.Equal(t, want, got) } + +func TestNewLogoutTokenClaims(t *testing.T) { + want := &LogoutTokenClaims{ + Issuer: "zitadel", + Subject: "hello@me.com", + Audience: Audience{"foo", "just@me.com"}, + Expiration: 12345, + JWTID: "jwtID", + Events: map[string]any{ + "http://schemas.openid.net/event/backchannel-logout": struct{}{}, + }, + SessionID: "sessionID", + Claims: nil, + } + + got := NewLogoutTokenClaims( + want.Issuer, + want.Subject, + want.Audience, + want.Expiration.AsTime(), + want.JWTID, + want.SessionID, + 1*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 0e7152c..a43cb2f 100644 --- a/pkg/oidc/types.go +++ b/pkg/oidc/types.go @@ -9,7 +9,7 @@ import ( "strings" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/muhlemmer/gu" "github.com/zitadel/schema" "golang.org/x/text/language" @@ -82,6 +82,9 @@ func (l *Locale) MarshalJSON() ([]byte, error) { // to an empty value (language "und") and no error will be returned. // This state can be checked with the `l.Tag().IsRoot()` method. func (l *Locale) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "\"\"" { + return nil + } err := json.Unmarshal(data, &l.tag) if err == nil { return nil @@ -112,6 +115,14 @@ func ParseLocales(locales []string) Locales { return out } +func (l Locales) String() string { + tags := make([]string, len(l)) + for i, tag := range l { + tags[i] = tag.String() + } + return strings.Join(tags, " ") +} + // UnmarshalText implements the [encoding.TextUnmarshaler] interface. // It decodes an unquoted space seperated string into Locales. // Undefined language tags in the input are ignored and ommited from @@ -228,6 +239,9 @@ func NewEncoder() *schema.Encoder { e.RegisterEncoder(SpaceDelimitedArray{}, func(value reflect.Value) string { return value.Interface().(SpaceDelimitedArray).String() }) + e.RegisterEncoder(Locales{}, func(value reflect.Value) string { + return value.Interface().(Locales).String() + }) return e } diff --git a/pkg/oidc/types_test.go b/pkg/oidc/types_test.go index df93a73..53a9779 100644 --- a/pkg/oidc/types_test.go +++ b/pkg/oidc/types_test.go @@ -217,6 +217,30 @@ func TestLocale_UnmarshalJSON(t *testing.T) { want dst wantErr bool }{ + { + name: "value not present", + input: `{}`, + wantErr: false, + want: dst{ + Locale: nil, + }, + }, + { + name: "null", + input: `{"locale": null}`, + wantErr: false, + want: dst{ + Locale: nil, + }, + }, + { + name: "empty, ignored", + input: `{"locale": ""}`, + wantErr: false, + want: dst{ + Locale: &Locale{}, + }, + }, { name: "afrikaans, ok", input: `{"locale": "af"}`, @@ -237,16 +261,17 @@ func TestLocale_UnmarshalJSON(t *testing.T) { wantErr: true, }, } - for _, tt := range tests { - var got dst - err := json.Unmarshal([]byte(tt.input), &got) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) + t.Run(tt.name, func(t *testing.T) { + var got dst + err := json.Unmarshal([]byte(tt.input), &got) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) } } diff --git a/pkg/oidc/verifier.go b/pkg/oidc/verifier.go index c9cc716..5b02405 100644 --- a/pkg/oidc/verifier.go +++ b/pkg/oidc/verifier.go @@ -7,12 +7,11 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "time" - jose "github.com/go-jose/go-jose/v3" - - str "github.com/zitadel/oidc/v4/pkg/strings" + "github.com/go-jose/go-jose/v4" ) type Claims interface { @@ -41,6 +40,7 @@ type IDClaims interface { var ( ErrParse = errors.New("parsing of request failed") ErrIssuerInvalid = errors.New("issuer does not match") + ErrDiscoveryFailed = errors.New("OpenID Provider Configuration Discovery has failed") ErrSubjectMissing = errors.New("subject missing") ErrAudience = errors.New("audience is not valid") ErrAzpMissing = errors.New("authorized party is not set. If Token is valid for multiple audiences, azp must not be empty") @@ -72,6 +72,7 @@ type Verifier struct { SupportedSignAlgs []string MaxAge time.Duration ACR ACRVerifier + AZP AZPVerifier KeySet KeySet Nonce func(ctx context.Context) string } @@ -83,7 +84,7 @@ type ACRVerifier func(string) error // if none of the provided values matches the acr claim func DefaultACRVerifier(possibleValues []string) ACRVerifier { return func(acr string) error { - if !str.Contains(possibleValues, acr) { + if !slices.Contains(possibleValues, acr) { return fmt.Errorf("expected one of: %v, got: %q", possibleValues, acr) } return nil @@ -122,7 +123,7 @@ func CheckIssuer(claims Claims, issuer string) error { } func CheckAudience(claims Claims, clientID string) error { - if !str.Contains(claims.GetAudience(), clientID) { + if !slices.Contains(claims.GetAudience(), clientID) { return fmt.Errorf("%w: Audience must contain client_id %q", ErrAudience, clientID) } @@ -130,26 +131,55 @@ func CheckAudience(claims Claims, clientID string) error { return nil } +// AZPVerifier specifies the function to be used by the `DefaultVerifier` for validating the azp claim +type AZPVerifier func(string) error + +// DefaultAZPVerifier implements `AZPVerifier` returning an error +// if the azp claim is set and doesn't match the clientID. +func DefaultAZPVerifier(clientID string) AZPVerifier { + return func(azp string) error { + if azp != "" && azp != clientID { + return fmt.Errorf("%w: azp %q must be equal to client_id %q", ErrAzpInvalid, azp, clientID) + } + return nil + } +} + // CheckAuthorizedParty checks azp (authorized party) claim requirements. // // If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. -// If an azp Claim is present, the Client SHOULD verify that its client_id is the Claim Value. +// If an azp Claim is present, the Client MAY verify that its client_id is the Claim Value. // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation func CheckAuthorizedParty(claims Claims, clientID string) error { + return CheckAZPVerifier(claims, DefaultAZPVerifier(clientID)) +} + +// CheckAZPVerifier checks azp (authorized party) claim requirements. +// +// If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. +// If an azp Claim is present, the Client MAY verify that its client_id is the Claim Value. +// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation +func CheckAZPVerifier(claims Claims, azp AZPVerifier) error { if len(claims.GetAudience()) > 1 { if claims.GetAuthorizedParty() == "" { return ErrAzpMissing } } - if claims.GetAuthorizedParty() != "" && claims.GetAuthorizedParty() != clientID { - return fmt.Errorf("%w: azp %q must be equal to client_id %q", ErrAzpInvalid, claims.GetAuthorizedParty(), clientID) + + if err := azp(claims.GetAuthorizedParty()); err != nil { + return fmt.Errorf("%w: %v", ErrAzpInvalid, err) } return nil } func CheckSignature(ctx context.Context, token string, payload []byte, claims ClaimsSignature, supportedSigAlgs []string, set KeySet) error { - jws, err := jose.ParseSigned(token) + jws, err := jose.ParseSigned(token, toJoseSignatureAlgorithms(supportedSigAlgs)) if err != nil { + if strings.HasPrefix(err.Error(), "go-jose/go-jose: unexpected signature algorithm") { + // TODO(v4): we should wrap errors instead of returning static ones. + // This is a workaround so we keep returning the same error for now. + return ErrSignatureUnsupportedAlg + } return ErrParse } if len(jws.Signatures) == 0 { @@ -159,12 +189,6 @@ func CheckSignature(ctx context.Context, token string, payload []byte, claims Cl return ErrSignatureMultiple } sig := jws.Signatures[0] - if len(supportedSigAlgs) == 0 { - supportedSigAlgs = []string{"RS256"} - } - if !str.Contains(supportedSigAlgs, sig.Header.Algorithm) { - return fmt.Errorf("%w: id token signed with unsupported algorithm, expected %q got %q", ErrSignatureUnsupportedAlg, supportedSigAlgs, sig.Header.Algorithm) - } signedPayload, err := set.VerifySignature(ctx, jws) if err != nil { @@ -180,6 +204,18 @@ func CheckSignature(ctx context.Context, token string, payload []byte, claims Cl return nil } +// TODO(v4): Use the new jose.SignatureAlgorithm type directly, instead of string. +func toJoseSignatureAlgorithms(algorithms []string) []jose.SignatureAlgorithm { + out := make([]jose.SignatureAlgorithm, len(algorithms)) + for i := range algorithms { + out[i] = jose.SignatureAlgorithm(algorithms[i]) + } + if len(out) == 0 { + out = append(out, jose.RS256, jose.ES256, jose.PS256) + } + return out +} + func CheckExpiration(claims Claims, offset time.Duration) error { expiration := claims.GetExpiration() if !time.Now().Add(offset).Before(expiration) { diff --git a/pkg/oidc/verifier_test.go b/pkg/oidc/verifier_test.go index 93e7157..a015414 100644 --- a/pkg/oidc/verifier_test.go +++ b/pkg/oidc/verifier_test.go @@ -145,6 +145,7 @@ func TestCheckAuthorizedParty(t *testing.T) { tests := []struct { name string claims Claims + azp AZPVerifier wantErr error }{ { @@ -174,6 +175,17 @@ func TestCheckAuthorizedParty(t *testing.T) { AuthorizedParty: clientID, }, }, + { + name: "custom azp", + claims: &TokenClaims{ + Audience: []string{"not-client-id"}, + AuthorizedParty: clientID, + }, + azp: func(s string) error { + // skip check. + return nil + }, + }, { name: "wrong azp", claims: &TokenClaims{ @@ -184,7 +196,11 @@ func TestCheckAuthorizedParty(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := CheckAuthorizedParty(tt.claims, clientID) + azp := tt.azp + if azp == nil { + azp = DefaultAZPVerifier(clientID) + } + err := CheckAZPVerifier(tt.claims, azp) assert.ErrorIs(t, err, tt.wantErr) }) } diff --git a/pkg/op/auth_request.go b/pkg/op/auth_request.go index 8891cf5..bb6e73c 100644 --- a/pkg/op/auth_request.go +++ b/pkg/op/auth_request.go @@ -11,13 +11,13 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "time" "github.com/bmatcuk/doublestar/v4" httphelper "github.com/zitadel/oidc/v4/pkg/http" "github.com/zitadel/oidc/v4/pkg/oidc" - str "github.com/zitadel/oidc/v4/pkg/strings" ) type AuthRequest interface { @@ -38,6 +38,13 @@ type AuthRequest interface { Done() bool } +// AuthRequestSessionState should be implemented if [OpenID Connect Session Management](https://openid.net/specs/openid-connect-session-1_0.html) is supported +type AuthRequestSessionState interface { + // GetSessionState returns session_state. + // session_state is related to OpenID Connect Session Management. + GetSessionState() string +} + type Authorizer interface { Storage() Storage Decoder() httphelper.Decoder @@ -55,13 +62,19 @@ type AuthorizeValidator interface { ValidateAuthRequest(context.Context, *oidc.AuthRequest, Storage, *IDTokenHintVerifier) (string, error) } +type CodeResponseType struct { + Code string `schema:"code"` + State string `schema:"state,omitempty"` + SessionState string `schema:"session_state,omitempty"` +} + func authorizeHandler(authorizer Authorizer) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { Authorize(w, r, authorizer) } } -func authorizeCallbackHandler(authorizer Authorizer) func(http.ResponseWriter, *http.Request) { +func AuthorizeCallbackHandler(authorizer Authorizer) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { AuthorizeCallback(w, r, authorizer) } @@ -82,21 +95,29 @@ func Authorize(w http.ResponseWriter, r *http.Request, authorizer Authorizer) { if authReq.RequestParam != "" && authorizer.RequestObjectSupported() { err = ParseRequestObject(ctx, authReq, authorizer.Storage(), IssuerFromContext(ctx)) if err != nil { - AuthRequestError(w, r, authReq, err, authorizer) + AuthRequestError(w, r, nil, err, authorizer) return } } if authReq.ClientID == "" { - AuthRequestError(w, r, authReq, fmt.Errorf("auth request is missing client_id"), authorizer) + AuthRequestError(w, r, nil, fmt.Errorf("auth request is missing client_id"), authorizer) return } if authReq.RedirectURI == "" { - AuthRequestError(w, r, authReq, fmt.Errorf("auth request is missing redirect_uri"), authorizer) + AuthRequestError(w, r, nil, fmt.Errorf("auth request is missing redirect_uri"), authorizer) return } - validation := ValidateAuthRequest - if validater, ok := authorizer.(AuthorizeValidator); ok { - validation = validater.ValidateAuthRequest + + var client Client + validation := func(ctx context.Context, authReq *oidc.AuthRequest, storage Storage, verifier *IDTokenHintVerifier) (sub string, err error) { + client, err = authorizer.Storage().GetClientByClientID(ctx, authReq.ClientID) + if err != nil { + return "", oidc.ErrInvalidRequestRedirectURI().WithDescription("unable to retrieve client by id").WithParent(err) + } + return ValidateAuthRequestClient(ctx, authReq, client, verifier) + } + if validator, ok := authorizer.(AuthorizeValidator); ok { + validation = validator.ValidateAuthRequest } userID, err := validation(ctx, authReq, authorizer.Storage(), authorizer.IDTokenHintVerifier(ctx)) if err != nil { @@ -112,11 +133,6 @@ func Authorize(w http.ResponseWriter, r *http.Request, authorizer Authorizer) { AuthRequestError(w, r, authReq, oidc.DefaultToServerError(err, "unable to save auth request"), authorizer) return } - 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) - return - } RedirectToLogin(req.GetID(), client, w, r) } @@ -152,12 +168,12 @@ func ParseRequestObject(ctx context.Context, authReq *oidc.AuthRequest, storage if requestObject.Issuer != requestObject.ClientID { return oidc.ErrInvalidRequest().WithDescription("missing or wrong issuer in request") } - if !str.Contains(requestObject.Audience, issuer) { + if !slices.Contains(requestObject.Audience, issuer) { return oidc.ErrInvalidRequest().WithDescription("issuer missing in audience") } keySet := &jwtProfileKeySet{storage: storage, clientID: requestObject.Issuer} if err = oidc.CheckSignature(ctx, authReq.RequestParam, payload, requestObject, nil, keySet); err != nil { - return oidc.ErrInvalidRequest().WithParent(err).WithDescription(err.Error()) + return oidc.ErrInvalidRequest().WithParent(err).WithDescription("invalid request signature") } CopyRequestObjectToAuthRequest(authReq, requestObject) return nil @@ -166,7 +182,7 @@ func ParseRequestObject(ctx context.Context, authReq *oidc.AuthRequest, storage // CopyRequestObjectToAuthRequest overwrites present values from the Request Object into the auth request // and clears the `RequestParam` of the auth request func CopyRequestObjectToAuthRequest(authReq *oidc.AuthRequest, requestObject *oidc.RequestObject) { - if str.Contains(authReq.Scopes, oidc.ScopeOpenID) && len(requestObject.Scopes) > 0 { + if slices.Contains(authReq.Scopes, oidc.ScopeOpenID) && len(requestObject.Scopes) > 0 { authReq.Scopes = requestObject.Scopes } if requestObject.RedirectURI != "" { @@ -211,26 +227,37 @@ func CopyRequestObjectToAuthRequest(authReq *oidc.AuthRequest, requestObject *oi authReq.RequestParam = "" } -// ValidateAuthRequest validates the authorize parameters and returns the userID of the id_token_hint if passed +// ValidateAuthRequest validates the authorize parameters and returns the userID of the id_token_hint if passed. +// +// Deprecated: Use [ValidateAuthRequestClient] to prevent querying for the Client twice. func ValidateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, storage Storage, verifier *IDTokenHintVerifier) (sub string, err error) { ctx, span := tracer.Start(ctx, "ValidateAuthRequest") defer span.End() + client, err := storage.GetClientByClientID(ctx, authReq.ClientID) + if err != nil { + return "", oidc.ErrInvalidRequestRedirectURI().WithDescription("unable to retrieve client by id").WithParent(err) + } + return ValidateAuthRequestClient(ctx, authReq, client, verifier) +} + +// ValidateAuthRequestClient validates the Auth request against the passed client. +// If id_token_hint is part of the request, the subject of the token is returned. +func ValidateAuthRequestClient(ctx context.Context, authReq *oidc.AuthRequest, client Client, verifier *IDTokenHintVerifier) (sub string, err error) { + ctx, span := tracer.Start(ctx, "ValidateAuthRequestClient") + defer span.End() + + if err := ValidateAuthReqRedirectURI(client, authReq.RedirectURI, authReq.ResponseType); err != nil { + return "", err + } authReq.MaxAge, err = ValidateAuthReqPrompt(authReq.Prompt, authReq.MaxAge) if err != nil { return "", err } - client, err := storage.GetClientByClientID(ctx, authReq.ClientID) - if err != nil { - return "", oidc.DefaultToServerError(err, "unable to retrieve client by id") - } authReq.Scopes, err = ValidateAuthReqScopes(client, authReq.Scopes) if err != nil { return "", err } - if err := ValidateAuthReqRedirectURI(client, authReq.RedirectURI, authReq.ResponseType); err != nil { - return "", err - } if err := ValidateAuthReqResponseType(client, authReq.ResponseType); err != nil { return "", err } @@ -250,44 +277,30 @@ func ValidateAuthReqPrompt(prompts []string, maxAge *uint) (_ *uint, err error) return maxAge, nil } -// ValidateAuthReqScopes validates the passed scopes +// ValidateAuthReqScopes validates the passed scopes and deletes any unsupported scopes. +// An error is returned if scopes is empty. func ValidateAuthReqScopes(client Client, scopes []string) ([]string, error) { if len(scopes) == 0 { return nil, oidc.ErrInvalidRequest(). WithDescription("The scope of your request is missing. Please ensure some scopes are requested. " + "If you have any questions, you may contact the administrator of the application.") } - openID := false - for i := len(scopes) - 1; i >= 0; i-- { - scope := scopes[i] - if scope == oidc.ScopeOpenID { - openID = true - continue - } - if !(scope == oidc.ScopeProfile || + scopes = slices.DeleteFunc(scopes, func(scope string) bool { + return !(scope == oidc.ScopeOpenID || + scope == oidc.ScopeProfile || scope == oidc.ScopeEmail || scope == oidc.ScopePhone || scope == oidc.ScopeAddress || scope == oidc.ScopeOfflineAccess) && - !client.IsScopeAllowed(scope) { - scopes[i] = scopes[len(scopes)-1] - scopes[len(scopes)-1] = "" - scopes = scopes[:len(scopes)-1] - } - } - if !openID { - return nil, oidc.ErrInvalidScope().WithDescription("The scope openid is missing in your request. " + - "Please ensure the scope openid is added to the request. " + - "If you have any questions, you may contact the administrator of the application.") - } - + !client.IsScopeAllowed(scope) + }) return scopes, nil } // checkURIAgainstRedirects just checks aginst the valid redirect URIs and ignores // other factors. func checkURIAgainstRedirects(client Client, uri string) error { - if str.Contains(client.RedirectURIs(), uri) { + if slices.Contains(client.RedirectURIs(), uri) { return nil } if globClient, ok := client.(HasRedirectGlobs); ok { @@ -308,16 +321,17 @@ func checkURIAgainstRedirects(client Client, uri string) error { // ValidateAuthReqRedirectURI validates the passed redirect_uri and response_type to the registered uris and client type func ValidateAuthReqRedirectURI(client Client, uri string, responseType oidc.ResponseType) error { - if uri == "" { + uri, err := url.QueryUnescape(uri) + if uri == "" || err != nil { return oidc.ErrInvalidRequestRedirectURI().WithDescription("The redirect_uri is missing in the request. " + "Please ensure it is added to the request. If you have any questions, you may contact the administrator of the application.") } - if strings.HasPrefix(uri, "https://") { - return checkURIAgainstRedirects(client, uri) - } if client.ApplicationType() == ApplicationTypeNative { return validateAuthReqRedirectURINative(client, uri) } + if strings.HasPrefix(uri, "https://") { + return checkURIAgainstRedirects(client, uri) + } if err := checkURIAgainstRedirects(client, uri); err != nil { return err } @@ -338,12 +352,15 @@ func ValidateAuthReqRedirectURI(client Client, uri string, responseType oidc.Res // ValidateAuthReqRedirectURINative validates the passed redirect_uri and response_type to the registered uris and client type func validateAuthReqRedirectURINative(client Client, uri string) error { parsedURL, isLoopback := HTTPLoopbackOrLocalhost(uri) - isCustomSchema := !strings.HasPrefix(uri, "http://") + isCustomSchema := !(strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://")) if err := checkURIAgainstRedirects(client, uri); err == nil { if client.DevMode() { return nil } - // The RedirectURIs are only valid for native clients when localhost or non-"http://" + if !isLoopback && strings.HasPrefix(uri, "https://") { + return nil + } + // The RedirectURIs are only valid for native clients when localhost or non-"http://" and "https://" if isLoopback || isCustomSchema { return nil } @@ -373,11 +390,11 @@ func HTTPLoopbackOrLocalhost(rawURL string) (*url.URL, bool) { if err != nil { return nil, false } - if parsedURL.Scheme != "http" { - return nil, false + if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" { + hostName := parsedURL.Hostname() + return parsedURL, hostName == "localhost" || net.ParseIP(hostName).IsLoopback() } - hostName := parsedURL.Hostname() - return parsedURL, hostName == "localhost" || net.ParseIP(hostName).IsLoopback() + return nil, false } // ValidateAuthReqResponseType validates the passed response_type to the registered response types @@ -467,41 +484,70 @@ func AuthResponse(authReq AuthRequest, authorizer Authorizer, w http.ResponseWri AuthResponseToken(w, r, authReq, authorizer, client) } -// AuthResponseCode creates the successful code authentication response +// AuthResponseCode handles the creation of a successful authentication response using an authorization code func AuthResponseCode(w http.ResponseWriter, r *http.Request, authReq AuthRequest, authorizer Authorizer) { ctx, span := tracer.Start(r.Context(), "AuthResponseCode") - r = r.WithContext(ctx) defer span.End() + r = r.WithContext(ctx) - code, err := CreateAuthRequestCode(r.Context(), authReq, authorizer.Storage(), authorizer.Crypto()) - if err != nil { - AuthRequestError(w, r, authReq, err, authorizer) - return - } - codeResponse := struct { - Code string `schema:"code"` - State string `schema:"state,omitempty"` - }{ - Code: code, - State: authReq.GetState(), - } - + var err error if authReq.GetResponseMode() == oidc.ResponseModeFormPost { - err := AuthResponseFormPost(w, authReq.GetRedirectURI(), &codeResponse, authorizer.Encoder()) - if err != nil { - AuthRequestError(w, r, authReq, err, authorizer) - return - } - - return + err = handleFormPostResponse(w, r, authReq, authorizer) + } else { + err = handleRedirectResponse(w, r, authReq, authorizer) } - callback, err := AuthResponseURL(authReq.GetRedirectURI(), authReq.GetResponseType(), authReq.GetResponseMode(), &codeResponse, authorizer.Encoder()) if err != nil { AuthRequestError(w, r, authReq, err, authorizer) - return } - http.Redirect(w, r, callback, http.StatusFound) +} + +// handleFormPostResponse processes the authentication response using form post method +func handleFormPostResponse(w http.ResponseWriter, r *http.Request, authReq AuthRequest, authorizer Authorizer) error { + codeResponse, err := BuildAuthResponseCodeResponsePayload(r.Context(), authReq, authorizer) + if err != nil { + return err + } + return AuthResponseFormPost(w, authReq.GetRedirectURI(), codeResponse, authorizer.Encoder()) +} + +// handleRedirectResponse processes the authentication response using the redirect method +func handleRedirectResponse(w http.ResponseWriter, r *http.Request, authReq AuthRequest, authorizer Authorizer) error { + callbackURL, err := BuildAuthResponseCallbackURL(r.Context(), authReq, authorizer) + if err != nil { + return err + } + http.Redirect(w, r, callbackURL, http.StatusFound) + return nil +} + +// BuildAuthResponseCodeResponsePayload generates the authorization code response payload for the authentication request +func BuildAuthResponseCodeResponsePayload(ctx context.Context, authReq AuthRequest, authorizer Authorizer) (*CodeResponseType, error) { + code, err := CreateAuthRequestCode(ctx, authReq, authorizer.Storage(), authorizer.Crypto()) + if err != nil { + return nil, err + } + + sessionState := "" + if authRequestSessionState, ok := authReq.(AuthRequestSessionState); ok { + sessionState = authRequestSessionState.GetSessionState() + } + + return &CodeResponseType{ + Code: code, + State: authReq.GetState(), + SessionState: sessionState, + }, nil +} + +// BuildAuthResponseCallbackURL generates the callback URL for a successful authorization code response +func BuildAuthResponseCallbackURL(ctx context.Context, authReq AuthRequest, authorizer Authorizer) (string, error) { + codeResponse, err := BuildAuthResponseCodeResponsePayload(ctx, authReq, authorizer) + if err != nil { + return "", err + } + + return AuthResponseURL(authReq.GetRedirectURI(), authReq.GetResponseType(), authReq.GetResponseMode(), codeResponse, authorizer.Encoder()) } // AuthResponseToken creates the successful token(s) authentication response diff --git a/pkg/op/auth_request_test.go b/pkg/op/auth_request_test.go index 46d532e..61b8067 100644 --- a/pkg/op/auth_request_test.go +++ b/pkg/op/auth_request_test.go @@ -137,11 +137,6 @@ func TestValidateAuthRequest(t *testing.T) { args{&oidc.AuthRequest{}, mock.NewMockStorageExpectValidClientID(t), nil}, oidc.ErrInvalidRequest(), }, - { - "scope openid missing fails", - args{&oidc.AuthRequest{Scopes: []string{"profile"}}, mock.NewMockStorageExpectValidClientID(t), nil}, - oidc.ErrInvalidScope(), - }, { "response_type missing fails", args{&oidc.AuthRequest{Scopes: []string{"openid"}}, mock.NewMockStorageExpectValidClientID(t), nil}, @@ -287,16 +282,6 @@ func TestValidateAuthReqScopes(t *testing.T) { err: true, }, }, - { - "scope openid missing fails", - args{ - mock.NewClientExpectAny(t, op.ApplicationTypeWeb), - []string{"email"}, - }, - res{ - err: true, - }, - }, { "scope ok", args{ @@ -448,6 +433,24 @@ func TestValidateAuthReqRedirectURI(t *testing.T) { }, false, }, + { + "code flow registered https loopback v4 native ok", + args{ + "https://127.0.0.1:4200/callback", + mock.NewClientWithConfig(t, []string{"https://127.0.0.1/callback"}, op.ApplicationTypeNative, nil, false), + oidc.ResponseTypeCode, + }, + false, + }, + { + "code flow registered https loopback v6 native ok", + args{ + "https://[::1]:4200/callback", + mock.NewClientWithConfig(t, []string{"https://[::1]/callback"}, op.ApplicationTypeNative, nil, false), + oidc.ResponseTypeCode, + }, + false, + }, { "code flow unregistered http native fails", args{ @@ -637,6 +640,15 @@ func TestValidateAuthReqRedirectURI(t *testing.T) { }, true, }, + { + "code flow encoded redirect_uri should pass after unescaping", + args{ + "https%3A%2F%2Fregistered.com%2Fcallback", + mock.NewClientWithConfig(t, []string{"https://registered.com/callback"}, op.ApplicationTypeWeb, nil, false), + oidc.ResponseTypeCode, + }, + false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1087,6 +1099,34 @@ func TestAuthResponseCode(t *testing.T) { wantBody: "", }, }, + { + name: "success with state and session_state", + args: args{ + authReq: &storage.AuthRequestWithSessionState{ + AuthRequest: &storage.AuthRequest{ + ID: "id1", + TransferState: "state1", + }, + SessionState: "session_state1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + authorizer.EXPECT().Encoder().Return(schema.NewEncoder()) + return authorizer + }, + }, + res: res{ + wantCode: http.StatusFound, + wantLocationHeader: "/auth/callback/?code=id1&session_state=session_state1&state=state1", + wantBody: "", + }, + }, { name: "success without state", // reproduce issue #415 args: args{ @@ -1194,6 +1234,133 @@ func Test_parseAuthorizeCallbackRequest(t *testing.T) { } } +func TestBuildAuthResponseCodeResponsePayload(t *testing.T) { + type args struct { + authReq op.AuthRequest + authorizer func(*testing.T) op.Authorizer + } + type res struct { + wantCode string + wantState string + wantSessionState string + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + name: "create code error", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{ + returnErr: io.ErrClosedPipe, + }) + return authorizer + }, + }, + res: res{ + wantErr: true, + }, + }, + { + name: "success with state", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + TransferState: "state1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + return authorizer + }, + }, + res: res{ + wantCode: "id1", + wantState: "state1", + }, + }, + { + name: "success without state", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + TransferState: "", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + return authorizer + }, + }, + res: res{ + wantCode: "id1", + wantState: "", + }, + }, + { + name: "success with session_state", + args: args{ + authReq: &storage.AuthRequestWithSessionState{ + AuthRequest: &storage.AuthRequest{ + ID: "id1", + TransferState: "state1", + }, + SessionState: "session_state1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + return authorizer + }, + }, + res: res{ + wantCode: "id1", + wantState: "state1", + wantSessionState: "session_state1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.BuildAuthResponseCodeResponsePayload(context.Background(), tt.args.authReq, tt.args.authorizer(t)) + if tt.res.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.res.wantCode, got.Code) + assert.Equal(t, tt.res.wantState, got.State) + assert.Equal(t, tt.res.wantSessionState, got.SessionState) + }) + } +} + func TestValidateAuthReqIDTokenHint(t *testing.T) { token, _ := tu.ValidIDToken() tests := []struct { @@ -1224,3 +1391,231 @@ func TestValidateAuthReqIDTokenHint(t *testing.T) { }) } } + +func TestBuildAuthResponseCallbackURL(t *testing.T) { + type args struct { + authReq op.AuthRequest + authorizer func(*testing.T) op.Authorizer + } + type res struct { + wantURL string + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + name: "error when generating code response", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{ + returnErr: io.ErrClosedPipe, + }) + return authorizer + }, + }, + res: res{ + wantErr: true, + }, + }, + { + name: "error when generating callback URL", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + CallbackURI: "://invalid-url", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + authorizer.EXPECT().Encoder().Return(schema.NewEncoder()) + return authorizer + }, + }, + res: res{ + wantErr: true, + }, + }, + { + name: "success with state", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + CallbackURI: "https://example.com/callback", + TransferState: "state1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + authorizer.EXPECT().Encoder().Return(schema.NewEncoder()) + return authorizer + }, + }, + res: res{ + wantURL: "https://example.com/callback?code=id1&state=state1", + wantErr: false, + }, + }, + { + name: "success without state", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + CallbackURI: "https://example.com/callback", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + authorizer.EXPECT().Encoder().Return(schema.NewEncoder()) + return authorizer + }, + }, + res: res{ + wantURL: "https://example.com/callback?code=id1", + wantErr: false, + }, + }, + { + name: "success with session_state", + args: args{ + authReq: &storage.AuthRequestWithSessionState{ + AuthRequest: &storage.AuthRequest{ + ID: "id1", + CallbackURI: "https://example.com/callback", + TransferState: "state1", + }, + SessionState: "session_state1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + authorizer.EXPECT().Encoder().Return(schema.NewEncoder()) + return authorizer + }, + }, + res: res{ + wantURL: "https://example.com/callback?code=id1&session_state=session_state1&state=state1", + wantErr: false, + }, + }, + { + name: "success with existing query parameters", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + CallbackURI: "https://example.com/callback?param=value", + TransferState: "state1", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + authorizer.EXPECT().Encoder().Return(schema.NewEncoder()) + return authorizer + }, + }, + res: res{ + wantURL: "https://example.com/callback?param=value&code=id1&state=state1", + wantErr: false, + }, + }, + { + name: "success with fragment response mode", + args: args{ + authReq: &storage.AuthRequest{ + ID: "id1", + CallbackURI: "https://example.com/callback", + TransferState: "state1", + ResponseMode: "fragment", + }, + authorizer: func(t *testing.T) op.Authorizer { + ctrl := gomock.NewController(t) + storage := mock.NewMockStorage(ctrl) + storage.EXPECT().SaveAuthCode(gomock.Any(), "id1", "id1") + + authorizer := mock.NewMockAuthorizer(ctrl) + authorizer.EXPECT().Storage().Return(storage) + authorizer.EXPECT().Crypto().Return(&mockCrypto{}) + authorizer.EXPECT().Encoder().Return(schema.NewEncoder()) + return authorizer + }, + }, + res: res{ + wantURL: "https://example.com/callback#code=id1&state=state1", + wantErr: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := op.BuildAuthResponseCallbackURL(context.Background(), tt.args.authReq, tt.args.authorizer(t)) + if tt.res.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + if tt.res.wantURL != "" { + // Parse the URLs to compare components instead of direct string comparison + expectedURL, err := url.Parse(tt.res.wantURL) + require.NoError(t, err) + actualURL, err := url.Parse(got) + require.NoError(t, err) + + // Compare the base parts (scheme, host, path) + assert.Equal(t, expectedURL.Scheme, actualURL.Scheme) + assert.Equal(t, expectedURL.Host, actualURL.Host) + assert.Equal(t, expectedURL.Path, actualURL.Path) + + // Compare the fragment if any + assert.Equal(t, expectedURL.Fragment, actualURL.Fragment) + + // For query parameters, compare them independently of order + expectedQuery := expectedURL.Query() + actualQuery := actualURL.Query() + + assert.Equal(t, len(expectedQuery), len(actualQuery), "Query parameter count does not match") + + for key, expectedValues := range expectedQuery { + actualValues, exists := actualQuery[key] + assert.True(t, exists, "Expected query parameter %s not found", key) + assert.ElementsMatch(t, expectedValues, actualValues, "Values for parameter %s don't match", key) + } + } + }) + } +} diff --git a/pkg/op/config.go b/pkg/op/config.go index 9fec7cc..b271765 100644 --- a/pkg/op/config.go +++ b/pkg/op/config.go @@ -30,6 +30,7 @@ type Configuration interface { EndSessionEndpoint() *Endpoint KeysEndpoint() *Endpoint DeviceAuthorizationEndpoint() *Endpoint + CheckSessionIframe() *Endpoint AuthMethodPostSupported() bool CodeMethodS256Supported() bool @@ -49,6 +50,9 @@ type Configuration interface { SupportedUILocales() []language.Tag DeviceAuthorization() DeviceAuthorizationConfig + + BackChannelLogoutSupported() bool + BackChannelLogoutSessionSupported() bool } type IssuerFromRequest func(r *http.Request) string diff --git a/pkg/op/device.go b/pkg/op/device.go index bf8a344..919ee6a 100644 --- a/pkg/op/device.go +++ b/pkg/op/device.go @@ -9,12 +9,12 @@ import ( "math/big" "net/http" "net/url" + "slices" "strings" "time" httphelper "github.com/zitadel/oidc/v4/pkg/http" "github.com/zitadel/oidc/v4/pkg/oidc" - strs "github.com/zitadel/oidc/v4/pkg/strings" ) type DeviceAuthorizationConfig struct { @@ -91,10 +91,7 @@ func createDeviceAuthorization(ctx context.Context, req *oidc.DeviceAuthorizatio } config := o.DeviceAuthorization() - deviceCode, err := NewDeviceCode(RecommendedDeviceCodeBytes) - if err != nil { - return nil, NewStatusError(err, http.StatusInternalServerError) - } + deviceCode, _ := NewDeviceCode(RecommendedDeviceCodeBytes) userCode, err := NewUserCode([]rune(config.UserCode.CharSet), config.UserCode.CharAmount, config.UserCode.DashInterval) if err != nil { return nil, NewStatusError(err, http.StatusInternalServerError) @@ -163,11 +160,14 @@ func ParseDeviceCodeRequest(r *http.Request, o OpenIDProvider) (*oidc.DeviceAuth // results in a 22 character base64 encoded string. const RecommendedDeviceCodeBytes = 16 +// NewDeviceCode generates a new cryptographically secure device code as a base64 encoded string. +// The length of the string is nBytes * 4 / 3. +// An error is never returned. +// +// TODO(v4): change return type to string alone. 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) - } + rand.Read(bytes) return base64.RawURLEncoding.EncodeToString(bytes), nil } @@ -276,7 +276,7 @@ func (r *DeviceAuthorizationState) GetAMR() []string { } func (r *DeviceAuthorizationState) GetAudience() []string { - if !strs.Contains(r.Audience, r.ClientID) { + if !slices.Contains(r.Audience, r.ClientID) { r.Audience = append(r.Audience, r.ClientID) } return r.Audience @@ -344,10 +344,11 @@ func CreateDeviceTokenResponse(ctx context.Context, tokenRequest TokenRequest, c RefreshToken: refreshToken, TokenType: oidc.BearerToken, ExpiresIn: uint64(validity.Seconds()), + Scope: tokenRequest.GetScopes(), } // TODO(v4): remove type assertion - if idTokenRequest, ok := tokenRequest.(IDTokenRequest); ok && strs.Contains(tokenRequest.GetScopes(), oidc.ScopeOpenID) { + if idTokenRequest, ok := tokenRequest.(IDTokenRequest); ok && slices.Contains(tokenRequest.GetScopes(), oidc.ScopeOpenID) { response.IDToken, err = CreateIDToken(ctx, IssuerFromContext(ctx), idTokenRequest, client.IDTokenLifetime(), accessToken, "", creator.Storage(), client) if err != nil { return nil, err diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go index 686c833..04d5ac3 100644 --- a/pkg/op/device_test.go +++ b/pkg/op/device_test.go @@ -145,21 +145,11 @@ func runWithRandReader(r io.Reader, f func()) { } 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)) - } - }) - + 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) { diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go index 07c52e9..660f899 100644 --- a/pkg/op/discovery.go +++ b/pkg/op/discovery.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" httphelper "github.com/zitadel/oidc/v4/pkg/http" "github.com/zitadel/oidc/v4/pkg/oidc" @@ -45,6 +45,7 @@ func CreateDiscoveryConfig(ctx context.Context, config Configuration, storage Di EndSessionEndpoint: config.EndSessionEndpoint().Absolute(issuer), JwksURI: config.KeysEndpoint().Absolute(issuer), DeviceAuthorizationEndpoint: config.DeviceAuthorizationEndpoint().Absolute(issuer), + CheckSessionIframe: config.CheckSessionIframe().Absolute(issuer), ScopesSupported: Scopes(config), ResponseTypesSupported: ResponseTypes(config), GrantTypesSupported: GrantTypes(config), @@ -61,6 +62,8 @@ func CreateDiscoveryConfig(ctx context.Context, config Configuration, storage Di CodeChallengeMethodsSupported: CodeChallengeMethods(config), UILocalesSupported: config.SupportedUILocales(), RequestParameterSupported: config.RequestObjectSupported(), + BackChannelLogoutSupported: config.BackChannelLogoutSupported(), + BackChannelLogoutSessionSupported: config.BackChannelLogoutSessionSupported(), } } @@ -92,11 +95,17 @@ func createDiscoveryConfigV2(ctx context.Context, config Configuration, storage CodeChallengeMethodsSupported: CodeChallengeMethods(config), UILocalesSupported: config.SupportedUILocales(), RequestParameterSupported: config.RequestObjectSupported(), + BackChannelLogoutSupported: config.BackChannelLogoutSupported(), + BackChannelLogoutSessionSupported: config.BackChannelLogoutSessionSupported(), } } func Scopes(c Configuration) []string { - return DefaultSupportedScopes // TODO: config + provider, ok := c.(*Provider) + if ok && provider.config.SupportedScopes != nil { + return provider.config.SupportedScopes + } + return DefaultSupportedScopes } func ResponseTypes(c Configuration) []string { @@ -131,7 +140,7 @@ func GrantTypes(c Configuration) []oidc.GrantType { } func SubjectTypes(c Configuration) []string { - return []string{"public"} //TODO: config + return []string{"public"} // TODO: config } func SigAlgorithms(ctx context.Context, storage DiscoverStorage) []string { diff --git a/pkg/op/discovery_test.go b/pkg/op/discovery_test.go index 3637a9f..43ad60b 100644 --- a/pkg/op/discovery_test.go +++ b/pkg/op/discovery_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -81,6 +81,11 @@ func Test_scopes(t *testing.T) { args{}, op.DefaultSupportedScopes, }, + { + "custom scopes", + args{newTestProvider(&op.Config{SupportedScopes: []string{"test1", "test2"}})}, + []string{"test1", "test2"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/op/error.go b/pkg/op/error.go index 99f203b..6b78ad9 100644 --- a/pkg/op/error.go +++ b/pkg/op/error.go @@ -46,6 +46,12 @@ func AuthRequestError(w http.ResponseWriter, r *http.Request, authReq ErrAuthReq return } e.State = authReq.GetState() + var sessionState string + authRequestSessionState, ok := authReq.(AuthRequestSessionState) + if ok { + sessionState = authRequestSessionState.GetSessionState() + } + e.SessionState = sessionState var responseMode oidc.ResponseMode if rm, ok := authReq.(interface{ GetResponseMode() oidc.ResponseMode }); ok { responseMode = rm.GetResponseMode() @@ -92,6 +98,12 @@ func TryErrorRedirect(ctx context.Context, authReq ErrAuthRequest, parent error, } e.State = authReq.GetState() + var sessionState string + authRequestSessionState, ok := authReq.(AuthRequestSessionState) + if ok { + sessionState = authRequestSessionState.GetSessionState() + } + e.SessionState = sessionState var responseMode oidc.ResponseMode if rm, ok := authReq.(interface{ GetResponseMode() oidc.ResponseMode }); ok { responseMode = rm.GetResponseMode() diff --git a/pkg/op/error_test.go b/pkg/op/error_test.go index b5c40b3..1301d4c 100644 --- a/pkg/op/error_test.go +++ b/pkg/op/error_test.go @@ -428,7 +428,8 @@ func TestTryErrorRedirect(t *testing.T) { parent: oidc.ErrInteractionRequired().WithDescription("sign in"), }, want: &Redirect{ - URL: "http://example.com/callback?error=interaction_required&error_description=sign+in&state=state1", + Header: make(http.Header), + URL: "http://example.com/callback?error=interaction_required&error_description=sign+in&state=state1", }, wantLog: `{ "level":"WARN", diff --git a/pkg/op/keys.go b/pkg/op/keys.go index bd217b8..0cb1097 100644 --- a/pkg/op/keys.go +++ b/pkg/op/keys.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" httphelper "github.com/zitadel/oidc/v4/pkg/http" ) diff --git a/pkg/op/keys_test.go b/pkg/op/keys_test.go index 2ca3f49..81efe9d 100644 --- a/pkg/op/keys_test.go +++ b/pkg/op/keys_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" diff --git a/pkg/op/mock/authorizer.mock.impl.go b/pkg/op/mock/authorizer.mock.impl.go index af75ec3..c293f3d 100644 --- a/pkg/op/mock/authorizer.mock.impl.go +++ b/pkg/op/mock/authorizer.mock.impl.go @@ -4,7 +4,7 @@ import ( "context" "testing" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" "github.com/golang/mock/gomock" "github.com/zitadel/schema" diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go index 7b03e8e..9cc6d43 100644 --- a/pkg/op/mock/configuration.mock.go +++ b/pkg/op/mock/configuration.mock.go @@ -78,6 +78,48 @@ func (mr *MockConfigurationMockRecorder) AuthorizationEndpoint() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationEndpoint", reflect.TypeOf((*MockConfiguration)(nil).AuthorizationEndpoint)) } +// BackChannelLogoutSessionSupported mocks base method. +func (m *MockConfiguration) BackChannelLogoutSessionSupported() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BackChannelLogoutSessionSupported") + ret0, _ := ret[0].(bool) + return ret0 +} + +// BackChannelLogoutSessionSupported indicates an expected call of BackChannelLogoutSessionSupported. +func (mr *MockConfigurationMockRecorder) BackChannelLogoutSessionSupported() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackChannelLogoutSessionSupported", reflect.TypeOf((*MockConfiguration)(nil).BackChannelLogoutSessionSupported)) +} + +// BackChannelLogoutSupported mocks base method. +func (m *MockConfiguration) BackChannelLogoutSupported() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BackChannelLogoutSupported") + ret0, _ := ret[0].(bool) + return ret0 +} + +// BackChannelLogoutSupported indicates an expected call of BackChannelLogoutSupported. +func (mr *MockConfigurationMockRecorder) BackChannelLogoutSupported() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackChannelLogoutSupported", reflect.TypeOf((*MockConfiguration)(nil).BackChannelLogoutSupported)) +} + +// CheckSessionIframe mocks base method. +func (m *MockConfiguration) CheckSessionIframe() *op.Endpoint { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckSessionIframe") + ret0, _ := ret[0].(*op.Endpoint) + return ret0 +} + +// CheckSessionIframe indicates an expected call of CheckSessionIframe. +func (mr *MockConfigurationMockRecorder) CheckSessionIframe() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckSessionIframe", reflect.TypeOf((*MockConfiguration)(nil).CheckSessionIframe)) +} + // CodeMethodS256Supported mocks base method. func (m *MockConfiguration) CodeMethodS256Supported() bool { m.ctrl.T.Helper() diff --git a/pkg/op/mock/discovery.mock.go b/pkg/op/mock/discovery.mock.go index d524283..4890752 100644 --- a/pkg/op/mock/discovery.mock.go +++ b/pkg/op/mock/discovery.mock.go @@ -8,7 +8,7 @@ import ( context "context" reflect "reflect" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" gomock "github.com/golang/mock/gomock" ) diff --git a/pkg/op/mock/signer.mock.go b/pkg/op/mock/signer.mock.go index d9ff4a9..ab8a365 100644 --- a/pkg/op/mock/signer.mock.go +++ b/pkg/op/mock/signer.mock.go @@ -7,7 +7,7 @@ package mock import ( reflect "reflect" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" gomock "github.com/golang/mock/gomock" ) diff --git a/pkg/op/mock/storage.mock.go b/pkg/op/mock/storage.mock.go index 27f0848..5d0d85b 100644 --- a/pkg/op/mock/storage.mock.go +++ b/pkg/op/mock/storage.mock.go @@ -9,7 +9,7 @@ import ( reflect "reflect" time "time" - jose "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" gomock "github.com/golang/mock/gomock" oidc "github.com/zitadel/oidc/v4/pkg/oidc" op "github.com/zitadel/oidc/v4/pkg/op" diff --git a/pkg/op/op.go b/pkg/op/op.go index 31de4f2..46b81ea 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -8,10 +8,10 @@ import ( "time" "github.com/go-chi/chi/v5" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/rs/cors" + "github.com/zitadel/oidc/v4/internal/otel" "github.com/zitadel/schema" - "go.opentelemetry.io/otel" "golang.org/x/text/language" httphelper "github.com/zitadel/oidc/v4/pkg/http" @@ -135,7 +135,7 @@ func CreateRouter(o OpenIDProvider, interceptors ...HttpInterceptor) chi.Router router.HandleFunc(readinessEndpoint, readyHandler(o.Probes())) router.HandleFunc(oidc.DiscoveryEndpoint, discoveryHandler(o, o.Storage())) router.HandleFunc(o.AuthorizationEndpoint().Relative(), authorizeHandler(o)) - router.HandleFunc(authCallbackPath(o), authorizeCallbackHandler(o)) + router.HandleFunc(authCallbackPath(o), AuthorizeCallbackHandler(o)) router.HandleFunc(o.TokenEndpoint().Relative(), tokenHandler(o)) router.HandleFunc(o.IntrospectionEndpoint().Relative(), introspectionHandler(o)) router.HandleFunc(o.UserinfoEndpoint().Relative(), userinfoHandler(o)) @@ -158,16 +158,19 @@ func authCallbackPath(o OpenIDProvider) string { } type Config struct { - CryptoKey [32]byte - DefaultLogoutRedirectURI string - CodeMethodS256 bool - AuthMethodPost bool - AuthMethodPrivateKeyJWT bool - GrantTypeRefreshToken bool - RequestObjectSupported bool - SupportedUILocales []language.Tag - SupportedClaims []string - DeviceAuthorization DeviceAuthorizationConfig + CryptoKey [32]byte // for encrypting access token via NewAESCrypto; will be overwritten by WithCrypto + DefaultLogoutRedirectURI string + CodeMethodS256 bool + AuthMethodPost bool + AuthMethodPrivateKeyJWT bool + GrantTypeRefreshToken bool + RequestObjectSupported bool + SupportedUILocales []language.Tag + SupportedClaims []string + SupportedScopes []string + DeviceAuthorization DeviceAuthorizationConfig + BackChannelLogoutSupported bool + BackChannelLogoutSessionSupported bool } // Endpoints defines endpoint routes. @@ -256,6 +259,7 @@ func NewProvider(config *Config, storage Storage, issuer func(insecure bool) (Is storage: storage, accessTokenKeySet: keySet, idTokenHinKeySet: keySet, + crypto: NewAESCrypto(config.CryptoKey), endpoints: DefaultEndpoints, timer: make(<-chan time.Time), corsOpts: &defaultCORSOptions, @@ -276,7 +280,6 @@ func NewProvider(config *Config, storage Storage, issuer func(insecure bool) (Is o.decoder = schema.NewDecoder() o.decoder.IgnoreUnknownKeys(true) o.encoder = oidc.NewEncoder() - o.crypto = NewAESCrypto(config.CryptoKey) return o, nil } @@ -336,6 +339,10 @@ func (o *Provider) DeviceAuthorizationEndpoint() *Endpoint { return o.endpoints.DeviceAuthorization } +func (o *Provider) CheckSessionIframe() *Endpoint { + return o.endpoints.CheckSessionIframe +} + func (o *Provider) KeysEndpoint() *Endpoint { return o.endpoints.JwksURI } @@ -411,6 +418,14 @@ func (o *Provider) DeviceAuthorization() DeviceAuthorizationConfig { return o.config.DeviceAuthorization } +func (o *Provider) BackChannelLogoutSupported() bool { + return o.config.BackChannelLogoutSupported +} + +func (o *Provider) BackChannelLogoutSessionSupported() bool { + return o.config.BackChannelLogoutSessionSupported +} + func (o *Provider) Storage() Storage { return o.storage } @@ -646,6 +661,16 @@ func WithLogger(logger *slog.Logger) Option { } } +// WithCrypto allows the user to pass their own Crypto implementation. +// +// If provided, this will overwrite Config.CryptoKey. +func WithCrypto(crypto Crypto) Option { + return func(o *Provider) error { + o.crypto = crypto + return nil + } +} + func intercept(i IssuerFromRequest, interceptors ...HttpInterceptor) func(handler http.Handler) http.Handler { issuerInterceptor := NewIssuerInterceptor(i) return func(handler http.Handler) http.Handler { diff --git a/pkg/op/op_test.go b/pkg/op/op_test.go index b76972f..f3b804c 100644 --- a/pkg/op/op_test.go +++ b/pkg/op/op_test.go @@ -102,6 +102,7 @@ func TestRoutes(t *testing.T) { authReq, err := storage.CreateAuthRequest(ctx, oidcAuthReq, "id1") require.NoError(t, err) storage.AuthRequestDone(authReq.GetID()) + storage.SaveAuthCode(ctx, authReq.GetID(), "123") accessToken, refreshToken, _, err := op.CreateAccessToken(ctx, authReq, op.AccessTokenTypeBearer, testProvider, client, "") require.NoError(t, err) @@ -232,7 +233,7 @@ func TestRoutes(t *testing.T) { "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.String(), }, wantCode: http.StatusOK, - contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299}`}, + contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299,"scope":"openid offline_access"}`}, }, { // This call will fail. A successful test is already diff --git a/pkg/op/server.go b/pkg/op/server.go index 20ddc0d..68d8410 100644 --- a/pkg/op/server.go +++ b/pkg/op/server.go @@ -218,7 +218,8 @@ type Response struct { // without custom headers. func NewResponse(data any) *Response { return &Response{ - Data: data, + Header: make(http.Header), + Data: data, } } @@ -242,11 +243,14 @@ type Redirect struct { } func NewRedirect(url string) *Redirect { - return &Redirect{URL: url} + return &Redirect{ + Header: make(http.Header), + URL: url, + } } func (red *Redirect) writeOut(w http.ResponseWriter, r *http.Request) { - gu.MapMerge(r.Header, w.Header()) + gu.MapMerge(red.Header, w.Header()) http.Redirect(w, r, red.URL, http.StatusFound) } diff --git a/pkg/op/server_http_routes_test.go b/pkg/op/server_http_routes_test.go index 9b3e567..4535c57 100644 --- a/pkg/op/server_http_routes_test.go +++ b/pkg/op/server_http_routes_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" "time" @@ -14,25 +15,26 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v4/example/server/storage" "github.com/zitadel/oidc/v4/pkg/client" "github.com/zitadel/oidc/v4/pkg/oidc" "github.com/zitadel/oidc/v4/pkg/op" ) func jwtProfile() (string, error) { - keyData, err := client.ConfigFromKeyFile("../../example/server/service-key1.json") + data, err := os.ReadFile("../../example/server/service-key1.pem") if err != nil { return "", err } - signer, err := client.NewSignerFromPrivateKeyByte([]byte(keyData.Key), keyData.KeyID) + signer, err := client.NewSignerFromPrivateKeyByte(data, storage.ServiceUserKeyID) if err != nil { return "", err } - return client.SignedJWTProfileAssertion(keyData.UserID, []string{testIssuer}, time.Hour, signer) + return client.SignedJWTProfileAssertion(storage.ServiceUserID, []string{testIssuer}, time.Hour, signer) } func TestServerRoutes(t *testing.T) { - server := op.RegisterLegacyServer(op.NewLegacyServer(testProvider, *op.DefaultEndpoints)) + server := op.RegisterLegacyServer(op.NewLegacyServer(testProvider, *op.DefaultEndpoints), op.AuthorizeCallbackHandler(testProvider)) storage := testProvider.Storage().(routesTestStorage) ctx := op.ContextWithIssuer(context.Background(), testIssuer) @@ -130,7 +132,7 @@ func TestServerRoutes(t *testing.T) { "client_id": client.GetID(), "client_secret": "secret", "redirect_uri": "https://example.com", - "code": "123", + "code": "abc", }, wantCode: http.StatusBadRequest, json: `{"error":"invalid_grant", "error_description":"invalid code"}`, @@ -145,7 +147,7 @@ func TestServerRoutes(t *testing.T) { "assertion": jwtProfileToken, }, wantCode: http.StatusOK, - contains: []string{`{"access_token":`, `"token_type":"Bearer","expires_in":299}`}, + contains: []string{`{"access_token":`, `"token_type":"Bearer","expires_in":299,"scope":"openid"}`}, }, { name: "Token exchange", @@ -174,7 +176,7 @@ func TestServerRoutes(t *testing.T) { "scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.String(), }, wantCode: http.StatusOK, - contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299}`}, + contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299,"scope":"openid offline_access"}`}, }, { // This call will fail. A successful test is already diff --git a/pkg/op/server_legacy.go b/pkg/op/server_legacy.go index 04bfd95..c995357 100644 --- a/pkg/op/server_legacy.go +++ b/pkg/op/server_legacy.go @@ -22,17 +22,16 @@ type ExtendedLegacyServer interface { } // RegisterLegacyServer registers a [LegacyServer] or an extension thereof. -// It takes care of registering the IssuerFromRequest middleware -// and Authorization Callback Routes. +// It takes care of registering the IssuerFromRequest middleware. +// The authorizeCallbackHandler is registered on `/callback` under the authorization endpoint. // Neither are part of the bare [Server] interface. // // EXPERIMENTAL: may change until v4 -func RegisterLegacyServer(s ExtendedLegacyServer, options ...ServerOption) http.Handler { - provider := s.Provider() +func RegisterLegacyServer(s ExtendedLegacyServer, authorizeCallbackHandler http.HandlerFunc, options ...ServerOption) http.Handler { options = append(options, - WithHTTPMiddleware(intercept(provider.IssuerFromRequest)), + WithHTTPMiddleware(intercept(s.Provider().IssuerFromRequest)), WithSetRouter(func(r chi.Router) { - r.HandleFunc(s.Endpoints().Authorization.Relative()+authCallbackPathSuffix, authorizeCallbackHandler(provider)) + r.HandleFunc(s.Endpoints().Authorization.Relative()+authCallbackPathSuffix, authorizeCallbackHandler) }), ) return RegisterServer(s, s.Endpoints(), options...) @@ -120,8 +119,10 @@ func (s *LegacyServer) Keys(ctx context.Context, r *Request[struct{}]) (*Respons return NewResponse(jsonWebKeySet(keys)), nil } +const authReqMissingClientID = "auth request is missing client_id" + var ( - ErrAuthReqMissingClientID = errors.New("auth request is missing client_id") + ErrAuthReqMissingClientID = errors.New(authReqMissingClientID) ErrAuthReqMissingRedirectURI = errors.New("auth request is missing redirect_uri") ) @@ -139,7 +140,7 @@ func (s *LegacyServer) VerifyAuthRequest(ctx context.Context, r *Request[oidc.Au } } if r.Data.ClientID == "" { - return nil, oidc.ErrInvalidRequest().WithParent(ErrAuthReqMissingClientID).WithDescription(ErrAuthReqMissingClientID.Error()) + return nil, oidc.ErrInvalidRequest().WithParent(ErrAuthReqMissingClientID).WithDescription(authReqMissingClientID) } client, err := s.provider.Storage().GetClientByClientID(ctx, r.Data.ClientID) if err != nil { diff --git a/pkg/op/session.go b/pkg/op/session.go index 1985667..a42a183 100644 --- a/pkg/op/session.go +++ b/pkg/op/session.go @@ -73,6 +73,8 @@ func ValidateEndSessionRequest(ctx context.Context, req *oidc.EndSessionRequest, session := &EndSessionRequest{ RedirectURI: ender.DefaultLogoutRedirectURI(), + LogoutHint: req.LogoutHint, + UILocales: req.UILocales, } if req.IdTokenHint != "" { claims, err := VerifyIDTokenHint[*oidc.IDTokenClaims](ctx, req.IdTokenHint, ender.IDTokenHintVerifier(ctx)) diff --git a/pkg/op/signer.go b/pkg/op/signer.go index b220739..a1053db 100644 --- a/pkg/op/signer.go +++ b/pkg/op/signer.go @@ -3,7 +3,7 @@ package op import ( "errors" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" ) var ErrSignerCreationFailed = errors.New("signer creation failed") diff --git a/pkg/op/storage.go b/pkg/op/storage.go index a3ee7bc..3fb7ddf 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -5,7 +5,8 @@ import ( "errors" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" + "golang.org/x/text/language" "github.com/zitadel/oidc/v4/pkg/oidc" ) @@ -40,8 +41,14 @@ type AuthStorage interface { // 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) + // + // CreateAccessAndRefreshToken creates both access and refresh tokens. + // The returned refresh token is the actual token value that will be passed + // directly to the client. The storage implementation is responsible for + // creating the complete refresh token (JWT or opaque format). For refresh tokens, + // in either format, the token itself serves as both the identifier and the credential. + CreateAccessAndRefreshTokens(ctx context.Context, request TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) + TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (RefreshTokenRequest, error) TerminateSession(ctx context.Context, userID string, clientID string) error @@ -144,6 +151,12 @@ type CanSetUserinfoFromRequest interface { SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, request IDTokenRequest, scopes []string) error } +// CanGetPrivateClaimsFromRequest is an optional additional interface that may be implemented by +// implementors of Storage. It allows setting the jwt token claims based on the request. +type CanGetPrivateClaimsFromRequest interface { + GetPrivateClaimsFromRequest(ctx context.Context, request TokenRequest, restrictedScopes []string) (map[string]any, 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 @@ -164,6 +177,8 @@ type EndSessionRequest struct { ClientID string IDTokenHintClaims *oidc.IDTokenClaims RedirectURI string + LogoutHint string + UILocales []language.Tag } var ErrDuplicateUserCode = errors.New("user code already exists") diff --git a/pkg/op/token.go b/pkg/op/token.go index 63804f2..bf1eb8a 100644 --- a/pkg/op/token.go +++ b/pkg/op/token.go @@ -2,11 +2,11 @@ package op import ( "context" + "slices" "time" "github.com/zitadel/oidc/v4/pkg/crypto" "github.com/zitadel/oidc/v4/pkg/oidc" - "github.com/zitadel/oidc/v4/pkg/strings" ) type TokenCreator interface { @@ -65,9 +65,17 @@ func CreateTokenResponse(ctx context.Context, request IDTokenRequest, client Cli TokenType: oidc.BearerToken, ExpiresIn: exp, State: state, + Scope: request.GetScopes(), }, nil } +// createTokens delegates token creation to the appropriate storage method based on +// the request type and requirements. It returns an access token ID and expiration +// in all cases, but the refresh token handling varies: +// - When needsRefreshToken() returns true: calls CreateAccessAndRefreshTokens, +// which returns both tokens. The newRefreshToken will contain the actual token value. +// - When needsRefreshToken() returns false: calls CreateAccessToken only. +// The newRefreshToken will be an empty string in this case. func createTokens(ctx context.Context, tokenRequest TokenRequest, storage Storage, refreshToken string, client AccessTokenClient) (id, newRefreshToken string, exp time.Time, err error) { ctx, span := tracer.Start(ctx, "createTokens") defer span.End() @@ -76,24 +84,33 @@ func createTokens(ctx context.Context, tokenRequest TokenRequest, storage Storag return storage.CreateAccessAndRefreshTokens(ctx, tokenRequest, refreshToken) } id, exp, err = storage.CreateAccessToken(ctx, tokenRequest) - return + return id, "", exp, err } 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) + return slices.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && req.GetResponseType() == oidc.ResponseTypeCode && ValidateGrantType(client, oidc.GrantTypeRefreshToken) case TokenExchangeRequest: return req.GetRequestedTokenType() == oidc.RefreshTokenType case RefreshTokenRequest: return true case *DeviceAuthorizationState: - return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && ValidateGrantType(client, oidc.GrantTypeRefreshToken) + return slices.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && ValidateGrantType(client, oidc.GrantTypeRefreshToken) default: return false } } +// CreateAccessToken creates an access token and may return a refresh token from storage. +// This function always creates the access token using the ID returned from storage. +// The refresh token is obtained from the storage layer and passed through unchanged. +// Whether a refresh token is included depends on the request: +// - Authorization code flow with offline_access scope: returns refresh token +// - Refresh token grant (rotation): returns new refresh token +// - Client credentials, implicit flow: returns empty string +// +// The function returns both tokens to support all flows with a single signature. func CreateAccessToken(ctx context.Context, tokenRequest TokenRequest, accessTokenType AccessTokenType, creator TokenCreator, client AccessTokenClient, refreshToken string) (accessToken, newRefreshToken string, validity time.Duration, err error) { ctx, span := tracer.Start(ctx, "CreateAccessToken") defer span.End() @@ -109,12 +126,12 @@ func CreateAccessToken(ctx context.Context, tokenRequest TokenRequest, accessTok validity = exp.Add(clockSkew).Sub(time.Now().UTC()) if accessTokenType == AccessTokenTypeJWT { accessToken, err = CreateJWT(ctx, IssuerFromContext(ctx), tokenRequest, exp, id, client, creator.Storage()) - return + return accessToken, newRefreshToken, validity, err } _, span = tracer.Start(ctx, "CreateBearerToken") accessToken, err = CreateBearerToken(id, tokenRequest.GetSubject(), creator.Crypto()) span.End() - return + return accessToken, newRefreshToken, validity, err } func CreateBearerToken(tokenID, subject string, crypto Crypto) (string, error) { @@ -146,7 +163,11 @@ func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, ex tokenExchangeRequest, ) } else { - privateClaims, err = storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes)) + if fromRequest, ok := storage.(CanGetPrivateClaimsFromRequest); ok { + privateClaims, err = fromRequest.GetPrivateClaimsFromRequest(ctx, tokenRequest, removeUserinfoScopes(restrictedScopes)) + } else { + privateClaims, err = storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes)) + } } if err != nil { diff --git a/pkg/op/token_client_credentials.go b/pkg/op/token_client_credentials.go index f59aac8..ee8d059 100644 --- a/pkg/op/token_client_credentials.go +++ b/pkg/op/token_client_credentials.go @@ -120,5 +120,6 @@ func CreateClientCredentialsTokenResponse(ctx context.Context, tokenRequest Toke AccessToken: accessToken, TokenType: oidc.BearerToken, ExpiresIn: uint64(validity.Seconds()), + Scope: tokenRequest.GetScopes(), }, nil } diff --git a/pkg/op/token_code.go b/pkg/op/token_code.go index a11d7d7..f6dfc6c 100644 --- a/pkg/op/token_code.go +++ b/pkg/op/token_code.go @@ -74,6 +74,17 @@ func AuthorizeCodeClient(ctx context.Context, tokenReq *oidc.AccessTokenRequest, ctx, span := tracer.Start(ctx, "AuthorizeCodeClient") defer span.End() + request, err = AuthRequestByCode(ctx, exchanger.Storage(), tokenReq.Code) + if err != nil { + return nil, nil, err + } + + codeChallenge := request.GetCodeChallenge() + err = AuthorizeCodeChallenge(tokenReq.CodeVerifier, codeChallenge) + if err != nil { + return nil, nil, err + } + if tokenReq.ClientAssertionType == oidc.ClientAssertionTypeJWTAssertion { jwtExchanger, ok := exchanger.(JWTAuthorizationGrantExchanger) if !ok || !exchanger.AuthMethodPrivateKeyJWTSupported() { @@ -83,9 +94,9 @@ func AuthorizeCodeClient(ctx context.Context, tokenReq *oidc.AccessTokenRequest, if err != nil { return nil, nil, err } - request, err = AuthRequestByCode(ctx, exchanger.Storage(), tokenReq.Code) return request, client, err } + client, err = exchanger.Storage().GetClientByClientID(ctx, tokenReq.ClientID) if err != nil { return nil, nil, oidc.ErrInvalidClient().WithParent(err) @@ -94,12 +105,10 @@ func AuthorizeCodeClient(ctx context.Context, tokenReq *oidc.AccessTokenRequest, return nil, nil, oidc.ErrInvalidClient().WithDescription("private_key_jwt not allowed for this client") } if client.AuthMethod() == oidc.AuthMethodNone { - request, err = AuthRequestByCode(ctx, exchanger.Storage(), tokenReq.Code) - if err != nil { - return nil, nil, err + if codeChallenge == nil { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("PKCE required") } - err = AuthorizeCodeChallenge(tokenReq.CodeVerifier, request.GetCodeChallenge()) - return request, client, err + return request, client, nil } if client.AuthMethod() == oidc.AuthMethodPost && !exchanger.AuthMethodPostSupported() { return nil, nil, oidc.ErrInvalidClient().WithDescription("auth_method post not supported") @@ -108,7 +117,7 @@ func AuthorizeCodeClient(ctx context.Context, tokenReq *oidc.AccessTokenRequest, if err != nil { return nil, nil, err } - request, err = AuthRequestByCode(ctx, exchanger.Storage(), tokenReq.Code) + return request, client, err } diff --git a/pkg/op/token_jwt_profile.go b/pkg/op/token_jwt_profile.go index bfd409c..0208782 100644 --- a/pkg/op/token_jwt_profile.go +++ b/pkg/op/token_jwt_profile.go @@ -89,6 +89,7 @@ func CreateJWTTokenResponse(ctx context.Context, tokenRequest TokenRequest, crea AccessToken: accessToken, TokenType: oidc.BearerToken, ExpiresIn: uint64(validity.Seconds()), + Scope: tokenRequest.GetScopes(), }, nil } diff --git a/pkg/op/token_refresh.go b/pkg/op/token_refresh.go index eaac5aa..cc00407 100644 --- a/pkg/op/token_refresh.go +++ b/pkg/op/token_refresh.go @@ -4,11 +4,11 @@ import ( "context" "errors" "net/http" + "slices" "time" httphelper "github.com/zitadel/oidc/v4/pkg/http" "github.com/zitadel/oidc/v4/pkg/oidc" - "github.com/zitadel/oidc/v4/pkg/strings" ) type RefreshTokenRequest interface { @@ -85,7 +85,7 @@ func ValidateRefreshTokenScopes(requestedScopes []string, authRequest RefreshTok return nil } for _, scope := range requestedScopes { - if !strings.Contains(authRequest.GetScopes(), scope) { + if !slices.Contains(authRequest.GetScopes(), scope) { return oidc.ErrInvalidScope() } } diff --git a/pkg/op/token_request.go b/pkg/op/token_request.go index dcb33ee..07581e1 100644 --- a/pkg/op/token_request.go +++ b/pkg/op/token_request.go @@ -132,11 +132,19 @@ func AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string, // AuthorizeCodeChallenge authorizes a client by validating the code_verifier against the previously sent // code_challenge of the auth request (PKCE) func AuthorizeCodeChallenge(codeVerifier string, challenge *oidc.CodeChallenge) error { + if challenge == nil { + if codeVerifier != "" { + return oidc.ErrInvalidRequest().WithDescription("code_verifier unexpectedly provided") + } + + return nil + } + if codeVerifier == "" { - return oidc.ErrInvalidRequest().WithDescription("code_challenge required") + return oidc.ErrInvalidRequest().WithDescription("code_verifier required") } if !oidc.VerifyCodeChallenge(challenge, codeVerifier) { - return oidc.ErrInvalidGrant().WithDescription("invalid code challenge") + return oidc.ErrInvalidGrant().WithDescription("invalid code_verifier") } return nil } diff --git a/pkg/op/token_request_test.go b/pkg/op/token_request_test.go new file mode 100644 index 0000000..b27a925 --- /dev/null +++ b/pkg/op/token_request_test.go @@ -0,0 +1,75 @@ +package op_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/oidc/v3/pkg/op" +) + +func TestAuthorizeCodeChallenge(t *testing.T) { + tests := []struct { + name string + codeVerifier string + codeChallenge *oidc.CodeChallenge + want func(t *testing.T, err error) + }{ + { + name: "missing both code_verifier and code_challenge", + codeVerifier: "", + codeChallenge: nil, + want: func(t *testing.T, err error) { + assert.Nil(t, err) + }, + }, + { + name: "valid code_verifier", + codeVerifier: "Hello World!", + codeChallenge: &oidc.CodeChallenge{ + Challenge: "f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", + Method: oidc.CodeChallengeMethodS256, + }, + want: func(t *testing.T, err error) { + assert.Nil(t, err) + }, + }, + { + name: "invalid code_verifier", + codeVerifier: "Hi World!", + codeChallenge: &oidc.CodeChallenge{ + Challenge: "f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", + Method: oidc.CodeChallengeMethodS256, + }, + want: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "invalid code_verifier") + }, + }, + { + name: "code_verifier provided without code_challenge", + codeVerifier: "code_verifier", + codeChallenge: nil, + want: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "code_verifier unexpectedly provided") + }, + }, + { + name: "empty code_verifier", + codeVerifier: "", + codeChallenge: &oidc.CodeChallenge{ + Challenge: "f4OxZX_x_FO5LcGBSKHWXfwtSx-j1ncoSt3SABJtkGk", + Method: oidc.CodeChallengeMethodS256, + }, + want: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "code_verifier required") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := op.AuthorizeCodeChallenge(tt.codeVerifier, tt.codeChallenge) + + tt.want(t, err) + }) + } +} diff --git a/pkg/op/verifier_jwt_profile.go b/pkg/op/verifier_jwt_profile.go index ec7d22c..7208c1a 100644 --- a/pkg/op/verifier_jwt_profile.go +++ b/pkg/op/verifier_jwt_profile.go @@ -6,12 +6,12 @@ import ( "fmt" "time" - jose "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v4/pkg/oidc" ) -// JWTProfileVerfiier extends oidc.Verifier with +// JWTProfileVerifier extends oidc.Verifier with // a jwtProfileKeyStorage and a function to check // the subject in a token. type JWTProfileVerifier struct { @@ -100,7 +100,7 @@ func VerifyJWTAssertion(ctx context.Context, assertion string, v *JWTProfileVeri } type JWTProfileKeyStorage interface { - GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) + GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) } // SubjectIsIssuer diff --git a/pkg/strings/strings.go b/pkg/strings/strings.go index af48cf3..b8f43a1 100644 --- a/pkg/strings/strings.go +++ b/pkg/strings/strings.go @@ -1,10 +1,9 @@ package strings +import "slices" + +// Deprecated: Use standard library [slices.Contains] instead. func Contains(list []string, needle string) bool { - for _, item := range list { - if item == needle { - return true - } - } - return false + // TODO(v4): remove package. + return slices.Contains(list, needle) }