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,
}