diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 680741ba..84c62aa4 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -564,6 +564,7 @@ Provider holds all configuration for a single provider | `clientID` | _string_ | ClientID is the OAuth Client ID that is defined in the provider
This value is required for all providers. | | `clientSecret` | _string_ | ClientSecret is the OAuth Client Secret that is defined in the provider
This value is required for all providers. | | `clientSecretFile` | _string_ | ClientSecretFile is the name of the file
containing the OAuth Client Secret, it will be used if ClientSecret is not set. | +| `clientAssertionFile` | _string_ | ClientAssertionFile is the path to a JWT client assertion (RFC 7523) used
at the token endpoint instead of a client secret. It is re-read on every
token request, so it can hold a credential rotated in place. It is
mutually exclusive with ClientSecret and ClientSecretFile. | | `keycloakConfig` | _[KeycloakOptions](#keycloakoptions)_ | KeycloakConfig holds all configurations for Keycloak provider. | | `azureConfig` | _[AzureOptions](#azureoptions)_ | AzureConfig holds all configurations for Azure provider. | | `microsoftEntraIDConfig` | _[MicrosoftEntraIDOptions](#microsoftentraidoptions)_ | MicrosoftEntraIDConfig holds all configurations for Entra ID provider. | diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index 965953fa..7c20f3dc 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -82,6 +82,7 @@ Provider specific options can be found on their respective subpages. | flag: `--allowed-group`
toml: `allowed_groups` | string \| list | Restrict login to members of a group or list of groups. Furthermore, if you aren't setting the `scope` and use `allowed_groups` with the generic OIDC provider the scope `groups` gets added implicitly. | | | flag: `--approval-prompt`
toml: `approval_prompt` | string | OAuth approval_prompt | `"force"` | | flag: `--backend-logout-url`
toml: `backend_logout_url` | string | URL to perform backend logout, if you use `{id_token}` in the url it will be replaced by the actual `id_token` of the user session | | +| flag: `--client-assertion-file`
toml: `client_assertion_file` | string | path to a file holding a JWT that is sent as `client_assertion` to authenticate at the token endpoint ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523)), instead of a client secret. The file is re-read on every token request, so it can hold a credential rotated in place, such as a Kubernetes projected service account token. Mutually exclusive with `client_secret` and `client_secret_file`. Supported by the `oidc` and `keycloak-oidc` providers, and requires an identity provider that accepts assertions issued by a third party | | | flag: `--client-id`
toml: `client_id` | string | the OAuth Client ID, e.g. `"123456.apps.googleusercontent.com"` | | | flag: `--client-secret-file`
toml: `client_secret_file` | string | the file with OAuth Client Secret. The file must contain the secret only, with no trailing newline | | | flag: `--client-secret`
toml: `client_secret` | string | the OAuth Client Secret | | diff --git a/docs/docs/configuration/providers/keycloak_oidc.md b/docs/docs/configuration/providers/keycloak_oidc.md index b29096e3..97ab1285 100644 --- a/docs/docs/configuration/providers/keycloak_oidc.md +++ b/docs/docs/configuration/providers/keycloak_oidc.md @@ -14,7 +14,7 @@ title: Keycloak OIDC ``` --provider=keycloak-oidc --client-id= - --client-secret= + --client-secret= // Or --client-assertion-file, see client authentication with a JWT assertion --redirect-url=https://internal.yourcompany.com/oauth2/callback --oidc-issuer-url=https:///realms/ // For Keycloak versions <17: --oidc-issuer-url=https:///auth/realms/ --email-domain= // Validate email domain for users, see option documentation @@ -84,6 +84,89 @@ through "dedicated" client mappers._ You should now be able to create a test user in Keycloak and get access to the OAuth2 Proxy instance, make sure to set an email address matching `` and select _Email verified_. +**Client authentication with a JWT assertion** + +Instead of a client secret, OAuth2 Proxy can authenticate at the token endpoint with a JWT sent as the `client_assertion` +parameter, as described in [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523). On Kubernetes this JWT can be a +projected service account token, which Keycloak accepts as a client credential through [federated client authentication](https://www.keycloak.org/2026/01/federated-client-authentication) removing the need to manage a client secret at all. + +:::note +This requires Keycloak 26.6 or newer, where +federated client authentication and the Kubernetes +trust relationship provider are supported. On Keycloak 26.4 or 26.5 both were preview features and have to be enabled explicitly +with `--features=client-auth-federated,kubernetes-service-accounts`. +::: + +1. Register your cluster as a trust relationship provider by navigating to: + **Identity providers** -> **Add provider** -> **Kubernetes** + * **Alias** `` + * **Issuer** your cluster's OIDC issuer. Managed clusters usually publish an external issuer URL. + * Keycloak reads the service account signing keys from the issuer, so it must be able to reach + `/.well-known/openid-configuration` and the JWKS endpoint it advertises. +2. Make the **Signed JWT - Federated** client authenticator available in the realm. + * Keycloak only adds it to the built-in **clients** flow while creating a realm, and never backfills it afterwards, so + a realm that predates federated client authentication needs the execution added by hand. Skip this step if + **Authentication** -> **clients** already lists **Signed JWT - Federated**. + * Built-in flows cannot be edited, so start by duplicating the existing one. Navigate to **Authentication** and select + **Duplicate** from the **Action** dropdown of the **clients** flow + * **Name** 'clients with federated jwt' + * **Description** 'Client authentication flow with Kubernetes ServiceAccount federated JWT support.' + * _Select **Add**._ + * Select **Add execution**, tick **Signed JWT - Federated** and select **Add** + * Set the **Requirement** dropdown of the new execution to 'Alternative', which is how the other client authenticators + in this flow are bound + * Select **Bind flow** from the **Action** dropdown, choose 'Client authentication flow' as the binding type and select + **Save** +3. Create the client for OAuth2 Proxy as described under *Creating the client* above, with one difference: + * **Client ID** `system:serviceaccount::`, the service account OAuth2 Proxy runs as + * _This is also the value of `--client-id`. Naming the client after the service account keeps the mapping obvious, + any Client ID works as long as **Federated subject** below matches the token._ + * If you already have an equivalent client, select **Export** from its **Action** dropdown, then **Import client** and + **Browse** to the exported JSON, overriding **Client ID** with the value above. This clones the redirect URIs, mappers + and scopes instead of repeating their configuration. +4. Point the client at the service account by navigating to **Clients** -> **\** -> **Credentials** + * **Client Authenticator** 'Signed JWT - Federated' + * **Identity provider** select `` from step 1. + * **Federated subject** `system:serviceaccount::`, matching the `sub` claim of the projected + token. + * No client secret is generated, so `--client-secret` can be dropped. +5. Project the service account token into the OAuth2 Proxy pod, requesting the realm issuer URL as its audience. + + ```yaml + spec: + serviceAccountName: oauth2-proxy + containers: + - name: oauth2-proxy + volumeMounts: + - name: keycloak-token + mountPath: /var/run/secrets/tokens + readOnly: true + volumes: + - name: keycloak-token + projected: + sources: + - serviceAccountToken: + path: keycloak + audience: https:///realms/ + expirationSeconds: 3600 + ``` + + * Keycloak caps the lifetime of a Kubernetes client assertion at one hour, matching the Kubernetes default, so do not + raise `expirationSeconds` above `3600`. +6. Replace `--client-secret` with the path of the projected token: + + ``` + --client-assertion-file=/var/run/secrets/tokens/keycloak + ``` + +The file is re-read on every token request, so Kubernetes is free to rotate it in place. + +:::note +Keycloak normally rejects a client assertion whose `jti` it has already seen. The Kubernetes trust relationship provider +deliberately permits reuse, which is what makes it safe for OAuth2 Proxy to send the same projected token for several token +requests until Kubernetes replaces the file. +::: + **Authorization** _OAuth2 Proxy will perform authorization by requiring a valid user, this authorization can be extended to take into diff --git a/docs/docs/configuration/providers/openid_connect.md b/docs/docs/configuration/providers/openid_connect.md index de170058..40c54987 100644 --- a/docs/docs/configuration/providers/openid_connect.md +++ b/docs/docs/configuration/providers/openid_connect.md @@ -144,3 +144,11 @@ Then you can start the oauth2-proxy with `./oauth2-proxy --config /etc/example.c # http_address = "0.0.0.0:4180" ``` 7. Then you can start the oauth2-proxy with `./oauth2-proxy --config /etc/localhost.cfg` + +#### Client authentication with a JWT assertion + +Instead of a client secret, oauth2-proxy can authenticate at the token endpoint +with a JWT sent as the `client_assertion` parameter, as described in +[RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), using +`--client-assertion-file`. See [Keycloak OIDC](keycloak_oidc.md#usage) for a +worked example using a Kubernetes projected service account token. diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index e53fd480..4827ce26 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -502,9 +502,10 @@ func legacyServerFlagset() *pflag.FlagSet { } type LegacyProvider struct { - ClientID string `flag:"client-id" cfg:"client_id"` - ClientSecret string `flag:"client-secret" cfg:"client_secret"` - ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"` + ClientID string `flag:"client-id" cfg:"client_id"` + ClientSecret string `flag:"client-secret" cfg:"client_secret"` + ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"` + ClientAssertionFile string `flag:"client-assertion-file" cfg:"client_assertion_file"` KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"` AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` @@ -592,6 +593,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { flagSet.String("client-id", "", "the OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"") flagSet.String("client-secret", "", "the OAuth Client Secret") flagSet.String("client-secret-file", "", "the file with OAuth Client Secret") + flagSet.String("client-assertion-file", "", "the file containing a JWT client assertion (RFC 7523) for token endpoint authentication, used instead of a client secret") flagSet.String("provider", "google", "OAuth provider") flagSet.String("provider-display-name", "", "Provider display name") @@ -700,6 +702,7 @@ func (l *LegacyProvider) convert() (Providers, error) { ClientID: l.ClientID, ClientSecret: l.ClientSecret, ClientSecretFile: l.ClientSecretFile, + ClientAssertionFile: l.ClientAssertionFile, Type: ProviderType(l.ProviderType), CAFiles: l.ProviderCAFiles, UseSystemTrustStore: &l.UseSystemTrustStore, diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 6f115f8a..2f7d5c63 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -71,6 +71,11 @@ type Provider struct { // ClientSecretFile is the name of the file // containing the OAuth Client Secret, it will be used if ClientSecret is not set. ClientSecretFile string `yaml:"clientSecretFile,omitempty"` + // ClientAssertionFile is the path to a JWT client assertion (RFC 7523) used + // at the token endpoint instead of a client secret. It is re-read on every + // token request, so it can hold a credential rotated in place. It is + // mutually exclusive with ClientSecret and ClientSecretFile. + ClientAssertionFile string `yaml:"clientAssertionFile,omitempty"` // KeycloakConfig holds all configurations for Keycloak provider. KeycloakConfig KeycloakOptions `yaml:"keycloakConfig,omitempty"` diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go index 0c8e28db..8d3ea537 100644 --- a/pkg/validation/providers.go +++ b/pkg/validation/providers.go @@ -68,6 +68,8 @@ func validateProvider(provider options.Provider, providerIDs map[string]struct{} msgs = append(msgs, validateClientSecret(provider)...) } + msgs = append(msgs, validateClientAssertion(provider)...) + if provider.Type == "google" { msgs = append(msgs, validateGoogleConfig(provider)...) } @@ -98,6 +100,10 @@ func validateOIDCSigningAlgorithms(provider options.Provider) []string { // providerRequiresClientSecret checks if provider requires client secret to be set // or it can be omitted in favor of JWT token to authenticate oAuth client func providerRequiresClientSecret(provider options.Provider) bool { + if provider.ClientAssertionFile != "" { + return false + } + if provider.Type == "entra-id" && ptr.Deref(provider.MicrosoftEntraIDConfig.FederatedTokenAuth, options.DefaultMicrosoftEntraIDUseFederatedToken) { return false } @@ -109,6 +115,37 @@ func providerRequiresClientSecret(provider options.Provider) bool { return true } +// clientAssertionSupportedProviders use the OIDC token request implementation +// and are known to work against a provider that accepts externally issued +// assertions. +var clientAssertionSupportedProviders = map[options.ProviderType]struct{}{ + options.OIDCProvider: {}, + options.KeycloakOIDCProvider: {}, +} + +// validateClientAssertion checks JWT client assertion settings. +func validateClientAssertion(provider options.Provider) []string { + if provider.ClientAssertionFile == "" { + return nil + } + + msgs := []string{} + + if _, ok := clientAssertionSupportedProviders[provider.Type]; !ok { + msgs = append(msgs, fmt.Sprintf("provider %s: client-assertion-file is not supported by the %q provider", provider.ID, provider.Type)) + } + + if provider.ClientSecret != "" || provider.ClientSecretFile != "" { + msgs = append(msgs, fmt.Sprintf("provider %s: client-assertion-file is mutually exclusive with client-secret and client-secret-file", provider.ID)) + } + + if _, err := os.Stat(provider.ClientAssertionFile); err != nil { // #nosec G304 + msgs = append(msgs, "could not read client assertion file: "+provider.ClientAssertionFile) + } + + return msgs +} + func validateClientSecret(provider options.Provider) []string { msgs := []string{} diff --git a/pkg/validation/providers_test.go b/pkg/validation/providers_test.go index 3c3531d7..6168287e 100644 --- a/pkg/validation/providers_test.go +++ b/pkg/validation/providers_test.go @@ -1,6 +1,10 @@ package validation import ( + "fmt" + "os" + "path/filepath" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -133,4 +137,73 @@ var _ = Describe("Providers", func() { errStrings: []string{invalidOIDCSigningAlgorithmCaseMsg}, }), ) + + Context("with a client assertion file", func() { + var assertionFile string + + BeforeEach(func() { + assertionFile = filepath.Join(GinkgoT().TempDir(), "token") + Expect(os.WriteFile(assertionFile, []byte("assertion"), 0600)).To(Succeed()) + }) + + assertionProvider := func(providerType options.ProviderType, file string) options.Provider { + return options.Provider{ + ID: "ProviderIDAssertion", + Type: providerType, + ClientID: "ClientID", + ClientAssertionFile: file, + } + } + + validate := func(provider options.Provider) []string { + return validateProviders(&options.Options{Providers: options.Providers{provider}}) + } + + It("does not require a client secret", func() { + Expect(validate(assertionProvider(options.OIDCProvider, assertionFile))).To(BeEmpty()) + }) + + It("is accepted by the keycloak-oidc provider", func() { + Expect(validate(assertionProvider(options.KeycloakOIDCProvider, assertionFile))).To(BeEmpty()) + }) + + It("rejects a client secret set alongside it", func() { + provider := assertionProvider(options.OIDCProvider, assertionFile) + provider.ClientSecret = "ClientSecret" + + Expect(validate(provider)).To(ConsistOf( + "provider ProviderIDAssertion: client-assertion-file is mutually exclusive with client-secret and client-secret-file", + )) + }) + + It("rejects a client secret file set alongside it", func() { + provider := assertionProvider(options.OIDCProvider, assertionFile) + provider.ClientSecretFile = filepath.Join(GinkgoT().TempDir(), "client-secret") + Expect(os.WriteFile(provider.ClientSecretFile, []byte("secret"), 0600)).To(Succeed()) + + Expect(validate(provider)).To(ConsistOf( + "provider ProviderIDAssertion: client-assertion-file is mutually exclusive with client-secret and client-secret-file", + )) + }) + + It("rejects unsupported providers", func() { + for _, providerType := range []options.ProviderType{ + options.MicrosoftEntraIDProvider, + options.GoogleProvider, + options.GitLabProvider, + options.ADFSProvider, + options.CidaasProvider, + } { + Expect(validate(assertionProvider(providerType, assertionFile))).To(ConsistOf( + fmt.Sprintf("provider ProviderIDAssertion: client-assertion-file is not supported by the %q provider", providerType), + ), string(providerType)) + } + }) + + It("rejects an unreadable file", func() { + Expect(validate(assertionProvider(options.OIDCProvider, "/does/not/exist"))).To(ConsistOf( + "could not read client assertion file: /does/not/exist", + )) + }) + }) }) diff --git a/providers/client_auth.go b/providers/client_auth.go new file mode 100644 index 00000000..5b80cdc2 --- /dev/null +++ b/providers/client_auth.go @@ -0,0 +1,160 @@ +package providers + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" + "golang.org/x/oauth2" +) + +// clientAssertionTypeJWTBearer is the RFC 7523 JWT bearer assertion type. +const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + +// UsesClientAssertion reports whether this provider authenticates to the token +// endpoint with a JWT assertion rather than a client secret. +func (p *ProviderData) UsesClientAssertion() bool { + return p.ClientAssertionFile != "" +} + +// GetClientAssertion returns the JWT used as the token request client assertion. +// +// The file is read on each call so externally rotated projected tokens are used +// before the provider rejects an expired assertion. +func (p *ProviderData) GetClientAssertion() (string, error) { + assertion, err := os.ReadFile(p.ClientAssertionFile) // #nosec G304 + if err != nil { + logger.Errorf("error reading client assertion file %s: %v", p.ClientAssertionFile, err) + return "", errors.New("could not read client assertion file") + } + + return strings.TrimSpace(string(assertion)), nil +} + +// tokenEndpointConfig builds the oauth2.Config used for token endpoint +// requests, applying whichever client authentication method is configured. +func (p *ProviderData) tokenEndpointConfig(redirectURL string) (*oauth2.Config, error) { + c := &oauth2.Config{ + ClientID: p.ClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: p.RedeemURL.String(), + }, + RedirectURL: redirectURL, + } + + if p.UsesClientAssertion() { + // Avoid AuthStyleAutoDetect probing Basic auth with an empty secret. + c.Endpoint.AuthStyle = oauth2.AuthStyleInParams + return c, nil + } + + clientSecret, err := p.GetClientSecret() + if err != nil { + return nil, err + } + c.ClientSecret = clientSecret + + return c, nil +} + +// clientAuthCodeOptions returns the extra token request parameters needed to +// authenticate this client when redeeming a code with oauth2.Config.Exchange. +// It returns no options when a client secret is used, as oauth2.Config carries +// the secret itself. +func (p *ProviderData) clientAuthCodeOptions() ([]oauth2.AuthCodeOption, error) { + if !p.UsesClientAssertion() { + return nil, nil + } + + assertion, err := p.GetClientAssertion() + if err != nil { + return nil, err + } + + return []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("client_assertion_type", clientAssertionTypeJWTBearer), + oauth2.SetAuthURLParam("client_assertion", assertion), + }, nil +} + +// refreshToken exchanges a refresh token for a new token, applying whichever +// client authentication method is configured. +func (p *ProviderData) refreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + if p.UsesClientAssertion() { + return p.refreshTokenWithClientAssertion(ctx, refreshToken) + } + + c, err := p.tokenEndpointConfig("") + if err != nil { + return nil, err + } + + t := &oauth2.Token{ + RefreshToken: refreshToken, + Expiry: time.Now().Add(-time.Hour), + } + token, err := c.TokenSource(ctx, t).Token() + if err != nil { + return nil, fmt.Errorf("failed to get token: %v", err) + } + + return token, nil +} + +// refreshTokenWithClientAssertion exchanges a refresh token for a new token, +// authenticating with a client assertion. +// +// oauth2.Config.TokenSource offers no way to add parameters to the refresh +// request, so the request is built here instead. +func (p *ProviderData) refreshTokenWithClientAssertion(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + assertion, err := p.GetClientAssertion() + if err != nil { + return nil, err + } + + params := url.Values{} + params.Add("client_id", p.ClientID) + params.Add("client_assertion", assertion) + params.Add("client_assertion_type", clientAssertionTypeJWTBearer) + params.Add("refresh_token", refreshToken) + params.Add("grant_type", "refresh_token") + + return p.requestToken(ctx, params) +} + +// requestToken posts params to the token endpoint and builds an oauth2.Token +// from the response. +func (p *ProviderData) requestToken(ctx context.Context, params url.Values) (*oauth2.Token, error) { + result := requests.New(p.RedeemURL.String()). + WithContext(ctx). + WithMethod(http.MethodPost). + WithBody(bytes.NewBufferString(params.Encode())). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + Do() + + var token oauth2.Token + if err := result.UnmarshalInto(&token); err != nil { + return nil, fmt.Errorf("unable to unmarshal token response: %w", err) + } + + // oauth2.Token keeps the wire value separate from the computed expiry. + if token.ExpiresIn != 0 { + token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + } + + // Keep id_token and other provider-specific fields available through Extra. + var raw map[string]interface{} + if err := result.UnmarshalInto(&raw); err != nil { + return nil, fmt.Errorf("unable to unmarshal token response: %w", err) + } + + return token.WithExtra(raw), nil +} diff --git a/providers/client_auth_test.go b/providers/client_auth_test.go new file mode 100644 index 00000000..8f4df74c --- /dev/null +++ b/providers/client_auth_test.go @@ -0,0 +1,250 @@ +package providers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +const testClientAssertion = "eyJhbGciOiJSUzI1NiJ9.dGVzdA.c2ln" + +func writeTestAssertionFile(t *testing.T, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "token") + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + + return path +} + +func newAssertionOIDCSetup(t *testing.T, assertionFile string, body []byte) (*httptest.Server, *OIDCProvider, *capturedTokenRequest) { + t.Helper() + + captured := &capturedTokenRequest{} + s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + captured.form = r.PostForm + captured.authorization = r.Header.Get("Authorization") + + rw.Header().Add("content-type", "application/json") + _, _ = rw.Write(body) + })) + + u, err := url.Parse(s.URL) + require.NoError(t, err) + + provider := newOIDCProvider(u, false) + provider.ClientSecret = "" + provider.ClientAssertionFile = assertionFile + + return s, provider, captured +} + +type capturedTokenRequest struct { + form url.Values + authorization string +} + +func TestUsesClientAssertion(t *testing.T) { + assert.False(t, (&ProviderData{}).UsesClientAssertion()) + assert.True(t, (&ProviderData{ClientAssertionFile: "/token"}).UsesClientAssertion()) +} + +func TestGetClientAssertion(t *testing.T) { + t.Run("reads the assertion and strips surrounding whitespace", func(t *testing.T) { + p := &ProviderData{ClientAssertionFile: writeTestAssertionFile(t, testClientAssertion+"\n")} + + assertion, err := p.GetClientAssertion() + assert.NoError(t, err) + assert.Equal(t, testClientAssertion, assertion) + }) + + t.Run("rereads the file so rotated assertions are picked up", func(t *testing.T) { + path := writeTestAssertionFile(t, "first") + p := &ProviderData{ClientAssertionFile: path} + + assertion, err := p.GetClientAssertion() + assert.NoError(t, err) + assert.Equal(t, "first", assertion) + + require.NoError(t, os.WriteFile(path, []byte("second"), 0600)) + + assertion, err = p.GetClientAssertion() + assert.NoError(t, err) + assert.Equal(t, "second", assertion) + }) + + t.Run("errors without leaking the path when the file is missing", func(t *testing.T) { + p := &ProviderData{ClientAssertionFile: "/does/not/exist"} + + assertion, err := p.GetClientAssertion() + assert.Error(t, err) + assert.Equal(t, "could not read client assertion file", err.Error()) + assert.Empty(t, assertion) + }) +} + +func TestTokenEndpointConfig(t *testing.T) { + redeemURL, err := url.Parse("https://example.com/token") + require.NoError(t, err) + + t.Run("uses the client secret by default", func(t *testing.T) { + p := &ProviderData{ClientID: "client", ClientSecret: "secret", RedeemURL: redeemURL} + + c, err := p.tokenEndpointConfig("https://example.com/callback") + assert.NoError(t, err) + assert.Equal(t, "secret", c.ClientSecret) + assert.Equal(t, oauth2.AuthStyleAutoDetect, c.Endpoint.AuthStyle) + assert.Equal(t, "https://example.com/callback", c.RedirectURL) + }) + + t.Run("sends no secret and pins the auth style when using an assertion", func(t *testing.T) { + p := &ProviderData{ClientID: "client", ClientAssertionFile: "/token", RedeemURL: redeemURL} + + c, err := p.tokenEndpointConfig("") + assert.NoError(t, err) + assert.Empty(t, c.ClientSecret) + // Otherwise oauth2 probes the endpoint with an empty Basic credential + assert.Equal(t, oauth2.AuthStyleInParams, c.Endpoint.AuthStyle) + }) +} + +func TestOIDCProviderRedeemWithClientAssertion(t *testing.T) { + idToken, err := newSignedTestIDToken(defaultIDToken) + require.NoError(t, err) + + body, err := json.Marshal(redeemTokenResponse{ + AccessToken: accessToken, + ExpiresIn: 10, + TokenType: "Bearer", + RefreshToken: refreshToken, + IDToken: idToken, + }) + require.NoError(t, err) + + assertionFile := writeTestAssertionFile(t, testClientAssertion) + server, provider, captured := newAssertionOIDCSetup(t, assertionFile, body) + defer server.Close() + + session, err := provider.Redeem(context.Background(), "https://example.com/callback", "code1234", "verifier") + assert.NoError(t, err) + assert.Equal(t, accessToken, session.AccessToken) + assert.Equal(t, idToken, session.IDToken) + assert.Equal(t, defaultIDToken.Email, session.Email) + + assert.Equal(t, testClientAssertion, captured.form.Get("client_assertion")) + assert.Equal(t, clientAssertionTypeJWTBearer, captured.form.Get("client_assertion_type")) + assert.Equal(t, "authorization_code", captured.form.Get("grant_type")) + assert.Equal(t, "code1234", captured.form.Get("code")) + assert.Equal(t, "verifier", captured.form.Get("code_verifier")) + assert.Equal(t, oidcClientID, captured.form.Get("client_id")) + + assert.Empty(t, captured.form.Get("client_secret")) + assert.Empty(t, captured.authorization) +} + +func TestOIDCProviderRefreshSessionWithClientAssertion(t *testing.T) { + idToken, err := newSignedTestIDToken(defaultIDToken) + require.NoError(t, err) + + body, err := json.Marshal(redeemTokenResponse{ + AccessToken: accessToken, + ExpiresIn: 3600, + TokenType: "Bearer", + RefreshToken: refreshToken, + IDToken: idToken, + }) + require.NoError(t, err) + + assertionFile := writeTestAssertionFile(t, testClientAssertion) + server, provider, captured := newAssertionOIDCSetup(t, assertionFile, body) + defer server.Close() + + session := &sessions.SessionState{ + AccessToken: "changeit", + IDToken: "changeit", + RefreshToken: "oldrefresh", + Email: "changeit", + User: "changeit", + } + + refreshed, err := provider.RefreshSession(context.Background(), session) + assert.NoError(t, err) + assert.True(t, refreshed) + assert.Equal(t, accessToken, session.AccessToken) + assert.Equal(t, idToken, session.IDToken) + assert.Equal(t, refreshToken, session.RefreshToken) + assert.Equal(t, defaultIDToken.Email, session.Email) + + assert.Equal(t, testClientAssertion, captured.form.Get("client_assertion")) + assert.Equal(t, clientAssertionTypeJWTBearer, captured.form.Get("client_assertion_type")) + assert.Equal(t, "refresh_token", captured.form.Get("grant_type")) + assert.Equal(t, "oldrefresh", captured.form.Get("refresh_token")) + assert.Equal(t, oidcClientID, captured.form.Get("client_id")) + assert.Empty(t, captured.form.Get("client_secret")) + assert.Empty(t, captured.authorization) + + require.NotNil(t, session.ExpiresOn) + assert.WithinDuration(t, time.Now().Add(time.Hour), *session.ExpiresOn, time.Minute) +} + +func TestOIDCProviderRefreshSessionWithClientAssertionWithoutIDToken(t *testing.T) { + body, err := json.Marshal(redeemTokenResponse{ + AccessToken: accessToken, + ExpiresIn: 3600, + TokenType: "Bearer", + RefreshToken: refreshToken, + }) + require.NoError(t, err) + + assertionFile := writeTestAssertionFile(t, testClientAssertion) + server, provider, captured := newAssertionOIDCSetup(t, assertionFile, body) + defer server.Close() + + session := &sessions.SessionState{ + AccessToken: "oldaccess", + IDToken: "oldidtoken", + RefreshToken: "oldrefresh", + Email: "old@example.com", + User: "olduser", + Groups: []string{"oldgroup"}, + PreferredUsername: "oldpreferred", + } + + refreshed, err := provider.RefreshSession(context.Background(), session) + assert.NoError(t, err) + assert.True(t, refreshed) + + assert.Equal(t, accessToken, session.AccessToken) + assert.Equal(t, refreshToken, session.RefreshToken) + assert.Equal(t, "oldidtoken", session.IDToken) + assert.Equal(t, "old@example.com", session.Email) + assert.Equal(t, "olduser", session.User) + assert.Equal(t, []string{"oldgroup"}, session.Groups) + assert.Equal(t, "oldpreferred", session.PreferredUsername) + + assert.Equal(t, testClientAssertion, captured.form.Get("client_assertion")) + assert.Equal(t, clientAssertionTypeJWTBearer, captured.form.Get("client_assertion_type")) + assert.Equal(t, "refresh_token", captured.form.Get("grant_type")) + assert.Empty(t, captured.form.Get("client_secret")) + assert.Empty(t, captured.authorization) +} + +func TestOIDCProviderRedeemWithMissingClientAssertionFile(t *testing.T) { + server, provider, _ := newAssertionOIDCSetup(t, "/does/not/exist", []byte("{}")) + defer server.Close() + + _, err := provider.Redeem(context.Background(), "https://example.com/callback", "code1234", "") + assert.EqualError(t, err, "could not read client assertion file") +} diff --git a/providers/oidc.go b/providers/oidc.go index aa022f63..b05f6924 100644 --- a/providers/oidc.go +++ b/providers/oidc.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/url" - "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" @@ -73,7 +72,7 @@ func (p *OIDCProvider) GetLoginURL(redirectURI, state, nonce string, extraParams // Redeem exchanges the OAuth2 authentication token for an ID token func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) { - clientSecret, err := p.GetClientSecret() + c, err := p.tokenEndpointConfig(redirectURL) if err != nil { return nil, err } @@ -83,14 +82,11 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifi opts = append(opts, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) } - c := oauth2.Config{ - ClientID: p.ClientID, - ClientSecret: clientSecret, - Endpoint: oauth2.Endpoint{ - TokenURL: p.RedeemURL.String(), - }, - RedirectURL: redirectURL, + authOpts, err := p.clientAuthCodeOptions() + if err != nil { + return nil, err } + opts = append(opts, authOpts...) ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) token, err := c.Exchange(ctx, code, opts...) @@ -162,27 +158,11 @@ func (p *OIDCProvider) RefreshSession(ctx context.Context, s *sessions.SessionSt // Access Token and (optionally) the ID Token. // https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error { - clientSecret, err := p.GetClientSecret() + token, err := p.refreshToken(ctx, s.RefreshToken) if err != nil { return err } - c := oauth2.Config{ - ClientID: p.ClientID, - ClientSecret: clientSecret, - Endpoint: oauth2.Endpoint{ - TokenURL: p.RedeemURL.String(), - }, - } - t := &oauth2.Token{ - RefreshToken: s.RefreshToken, - Expiry: time.Now().Add(-time.Hour), - } - token, err := c.TokenSource(ctx, t).Token() - if err != nil { - return fmt.Errorf("failed to get token: %v", err) - } - newSession, err := p.createSession(ctx, token, true) if err != nil { return fmt.Errorf("unable create new session state from response: %v", err) diff --git a/providers/provider_data.go b/providers/provider_data.go index 80bd77ae..0c69cefa 100644 --- a/providers/provider_data.go +++ b/providers/provider_data.go @@ -36,7 +36,9 @@ type ProviderData struct { ClientID string ClientSecret string ClientSecretFile string - Scope string + // ClientAssertionFile contains a JWT client assertion for token requests. + ClientAssertionFile string + Scope string // The response mode requested from the provider or empty for default ("query") AuthRequestResponseMode string // The picked CodeChallenge Method or empty if none. diff --git a/providers/providers.go b/providers/providers.go index f87d26a2..6a9991d3 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -83,6 +83,7 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, ClientID: providerConfig.ClientID, ClientSecret: providerConfig.ClientSecret, ClientSecretFile: providerConfig.ClientSecretFile, + ClientAssertionFile: providerConfig.ClientAssertionFile, AuthRequestResponseMode: providerConfig.AuthRequestResponseMode, AdditionalClaims: providerConfig.AdditionalClaims, }