feat: support JWT client assertion for OIDC and Keycloak provider
Signed-off-by: Tsubasa Nagasawa <toversus2357@gmail.com>
This commit is contained in:
parent
10b68716e5
commit
caab19b085
|
|
@ -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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `azureConfig` | _[AzureOptions](#azureoptions)_ | AzureConfig holds all configurations for Azure provider. |
|
||||
| `microsoftEntraIDConfig` | _[MicrosoftEntraIDOptions](#microsoftentraidoptions)_ | MicrosoftEntraIDConfig holds all configurations for Entra ID provider. |
|
||||
|
|
|
|||
|
|
@ -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: `--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: `--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-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 | |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ title: Keycloak OIDC
|
|||
```
|
||||
--provider=keycloak-oidc
|
||||
--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
|
||||
--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
|
||||
|
|
@ -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 `<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**
|
||||
|
||||
_OAuth2 Proxy will perform authorization by requiring a valid user, this authorization can be extended to take into
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue