This commit is contained in:
Tsubasa Nagasawa 2026-08-13 08:01:29 +09:00 committed by GitHub
commit d4f630ecdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 635 additions and 31 deletions

View File

@ -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<br/>This value is required for all providers. | | `clientID` | _string_ | ClientID is the OAuth Client ID that is defined in the provider<br/>This value is required for all providers. |
| `clientSecret` | _string_ | ClientSecret is the OAuth Client Secret that is defined in the provider<br/>This value is required for all providers. | | `clientSecret` | _string_ | ClientSecret is the OAuth Client Secret that is defined in the provider<br/>This value is required for all providers. |
| `clientSecretFile` | _string_ | ClientSecretFile is the name of the file<br/>containing the OAuth Client Secret, it will be used if ClientSecret is not set. | | `clientSecretFile` | _string_ | ClientSecretFile is the name of the file<br/>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<br/>at the token endpoint instead of a client secret. It is re-read on every<br/>token request, so it can hold a credential rotated in place. It is<br/>mutually exclusive with ClientSecret and ClientSecretFile. |
| `keycloakConfig` | _[KeycloakOptions](#keycloakoptions)_ | KeycloakConfig holds all configurations for Keycloak provider. | | `keycloakConfig` | _[KeycloakOptions](#keycloakoptions)_ | KeycloakConfig holds all configurations for Keycloak provider. |
| `azureConfig` | _[AzureOptions](#azureoptions)_ | AzureConfig holds all configurations for Azure provider. | | `azureConfig` | _[AzureOptions](#azureoptions)_ | AzureConfig holds all configurations for Azure provider. |
| `microsoftEntraIDConfig` | _[MicrosoftEntraIDOptions](#microsoftentraidoptions)_ | MicrosoftEntraIDConfig holds all configurations for Entra ID provider. | | `microsoftEntraIDConfig` | _[MicrosoftEntraIDOptions](#microsoftentraidoptions)_ | MicrosoftEntraIDConfig holds all configurations for Entra ID provider. |

View File

@ -82,6 +82,7 @@ Provider specific options can be found on their respective subpages.
| flag: `--allowed-group`<br/>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: `--allowed-group`<br/>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`<br/>toml: `approval_prompt` | string | OAuth approval_prompt | `"force"` | | flag: `--approval-prompt`<br/>toml: `approval_prompt` | string | OAuth approval_prompt | `"force"` |
| flag: `--backend-logout-url`<br/>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: `--backend-logout-url`<br/>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`<br/>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`<br/>toml: `client_id` | string | the OAuth Client ID, e.g. `"123456.apps.googleusercontent.com"` | | | flag: `--client-id`<br/>toml: `client_id` | string | the OAuth Client ID, e.g. `"123456.apps.googleusercontent.com"` | |
| flag: `--client-secret-file`<br/>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-file`<br/>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`<br/>toml: `client_secret` | string | the OAuth Client Secret | | | flag: `--client-secret`<br/>toml: `client_secret` | string | the OAuth Client Secret | |

View File

@ -14,7 +14,7 @@ title: Keycloak OIDC
``` ```
--provider=keycloak-oidc --provider=keycloak-oidc
--client-id=<your client's id> --client-id=<your client's id>
--client-secret=<your client's secret> --client-secret=<your client's secret> // Or --client-assertion-file, see client authentication with a JWT assertion
--redirect-url=https://internal.yourcompany.com/oauth2/callback --redirect-url=https://internal.yourcompany.com/oauth2/callback
--oidc-issuer-url=https://<keycloak host>/realms/<your realm> // For Keycloak versions <17: --oidc-issuer-url=https://<keycloak host>/auth/realms/<your realm> --oidc-issuer-url=https://<keycloak host>/realms/<your realm> // For Keycloak versions <17: --oidc-issuer-url=https://<keycloak host>/auth/realms/<your realm>
--email-domain=<yourcompany.com> // Validate email domain for users, see option documentation --email-domain=<yourcompany.com> // 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 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 `<yourcompany.com>` and select _Email verified_. an email address matching `<yourcompany.com>` 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** `<your identity provider's 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
`<issuer>/.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:<namespace>:<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** -> **\<your client's id\>** -> **Credentials**
* **Client Authenticator** 'Signed JWT - Federated'
* **Identity provider** select `<your identity provider's alias>` from step 1.
* **Federated subject** `system:serviceaccount:<namespace>:<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://<keycloak host>/realms/<your realm>
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** **Authorization**
_OAuth2 Proxy will perform authorization by requiring a valid user, this authorization can be extended to take into _OAuth2 Proxy will perform authorization by requiring a valid user, this authorization can be extended to take into

View File

@ -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" # http_address = "0.0.0.0:4180"
``` ```
7. Then you can start the oauth2-proxy with `./oauth2-proxy --config /etc/localhost.cfg` 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.

View File

@ -502,9 +502,10 @@ func legacyServerFlagset() *pflag.FlagSet {
} }
type LegacyProvider struct { type LegacyProvider struct {
ClientID string `flag:"client-id" cfg:"client_id"` ClientID string `flag:"client-id" cfg:"client_id"`
ClientSecret string `flag:"client-secret" cfg:"client_secret"` ClientSecret string `flag:"client-secret" cfg:"client_secret"`
ClientSecretFile string `flag:"client-secret-file" cfg:"client_secret_file"` 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"` KeycloakGroups []string `flag:"keycloak-group" cfg:"keycloak_groups"`
AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` 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-id", "", "the OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"")
flagSet.String("client-secret", "", "the OAuth Client Secret") flagSet.String("client-secret", "", "the OAuth Client Secret")
flagSet.String("client-secret-file", "", "the file with 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", "google", "OAuth provider")
flagSet.String("provider-display-name", "", "Provider display name") flagSet.String("provider-display-name", "", "Provider display name")
@ -700,6 +702,7 @@ func (l *LegacyProvider) convert() (Providers, error) {
ClientID: l.ClientID, ClientID: l.ClientID,
ClientSecret: l.ClientSecret, ClientSecret: l.ClientSecret,
ClientSecretFile: l.ClientSecretFile, ClientSecretFile: l.ClientSecretFile,
ClientAssertionFile: l.ClientAssertionFile,
Type: ProviderType(l.ProviderType), Type: ProviderType(l.ProviderType),
CAFiles: l.ProviderCAFiles, CAFiles: l.ProviderCAFiles,
UseSystemTrustStore: &l.UseSystemTrustStore, UseSystemTrustStore: &l.UseSystemTrustStore,

View File

@ -71,6 +71,11 @@ type Provider struct {
// ClientSecretFile is the name of the file // ClientSecretFile is the name of the file
// containing the OAuth Client Secret, it will be used if ClientSecret is not set. // containing the OAuth Client Secret, it will be used if ClientSecret is not set.
ClientSecretFile string `yaml:"clientSecretFile,omitempty"` 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 holds all configurations for Keycloak provider.
KeycloakConfig KeycloakOptions `yaml:"keycloakConfig,omitempty"` KeycloakConfig KeycloakOptions `yaml:"keycloakConfig,omitempty"`

View File

@ -68,6 +68,8 @@ func validateProvider(provider options.Provider, providerIDs map[string]struct{}
msgs = append(msgs, validateClientSecret(provider)...) msgs = append(msgs, validateClientSecret(provider)...)
} }
msgs = append(msgs, validateClientAssertion(provider)...)
if provider.Type == "google" { if provider.Type == "google" {
msgs = append(msgs, validateGoogleConfig(provider)...) 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 // providerRequiresClientSecret checks if provider requires client secret to be set
// or it can be omitted in favor of JWT token to authenticate oAuth client // or it can be omitted in favor of JWT token to authenticate oAuth client
func providerRequiresClientSecret(provider options.Provider) bool { func providerRequiresClientSecret(provider options.Provider) bool {
if provider.ClientAssertionFile != "" {
return false
}
if provider.Type == "entra-id" && ptr.Deref(provider.MicrosoftEntraIDConfig.FederatedTokenAuth, options.DefaultMicrosoftEntraIDUseFederatedToken) { if provider.Type == "entra-id" && ptr.Deref(provider.MicrosoftEntraIDConfig.FederatedTokenAuth, options.DefaultMicrosoftEntraIDUseFederatedToken) {
return false return false
} }
@ -109,6 +115,37 @@ func providerRequiresClientSecret(provider options.Provider) bool {
return true 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 { func validateClientSecret(provider options.Provider) []string {
msgs := []string{} msgs := []string{}

View File

@ -1,6 +1,10 @@
package validation package validation
import ( import (
"fmt"
"os"
"path/filepath"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -133,4 +137,73 @@ var _ = Describe("Providers", func() {
errStrings: []string{invalidOIDCSigningAlgorithmCaseMsg}, 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",
))
})
})
}) })

160
providers/client_auth.go Normal file
View File

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

View File

@ -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")
}

View File

@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"time"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "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 // Redeem exchanges the OAuth2 authentication token for an ID token
func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) { 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 { if err != nil {
return nil, err 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)) opts = append(opts, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
} }
c := oauth2.Config{ authOpts, err := p.clientAuthCodeOptions()
ClientID: p.ClientID, if err != nil {
ClientSecret: clientSecret, return nil, err
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
RedirectURL: redirectURL,
} }
opts = append(opts, authOpts...)
ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient)
token, err := c.Exchange(ctx, code, opts...) 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. // Access Token and (optionally) the ID Token.
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse // https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
func (p *OIDCProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error { 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 { if err != nil {
return err 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) newSession, err := p.createSession(ctx, token, true)
if err != nil { if err != nil {
return fmt.Errorf("unable create new session state from response: %v", err) return fmt.Errorf("unable create new session state from response: %v", err)

View File

@ -36,7 +36,9 @@ type ProviderData struct {
ClientID string ClientID string
ClientSecret string ClientSecret string
ClientSecretFile 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") // The response mode requested from the provider or empty for default ("query")
AuthRequestResponseMode string AuthRequestResponseMode string
// The picked CodeChallenge Method or empty if none. // The picked CodeChallenge Method or empty if none.

View File

@ -83,6 +83,7 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData,
ClientID: providerConfig.ClientID, ClientID: providerConfig.ClientID,
ClientSecret: providerConfig.ClientSecret, ClientSecret: providerConfig.ClientSecret,
ClientSecretFile: providerConfig.ClientSecretFile, ClientSecretFile: providerConfig.ClientSecretFile,
ClientAssertionFile: providerConfig.ClientAssertionFile,
AuthRequestResponseMode: providerConfig.AuthRequestResponseMode, AuthRequestResponseMode: providerConfig.AuthRequestResponseMode,
AdditionalClaims: providerConfig.AdditionalClaims, AdditionalClaims: providerConfig.AdditionalClaims,
} }