feat(jwt): allow explicit JWKS URL per extra JWT issuer
Add --extra-jwt-issuer-jwks-url to override the JWKS URL for an extra JWT issuer instead of relying on OIDC discovery or the hardcoded <issuer>/.well-known/jwks.json derivation. This unblocks issuers such as AD FS, whose iss claim (http://host/adfs/services/trust) is not an OIDC discovery base and whose JWKS is served at a different path/scheme (https://host/adfs/discovery/keys). The issuer match against the token's iss claim is preserved. Signed-off-by: Sebastian Schmidt <sebastian.schmidt@dewango.de>
This commit is contained in:
parent
10b68716e5
commit
d65167194b
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
## Changes since v7.15.3
|
## Changes since v7.15.3
|
||||||
|
|
||||||
|
- [#3455](https://github.com/oauth2-proxy/oauth2-proxy/pull/3455) feat: allow configuring an explicit JWKS URL per extra JWT issuer via `--extra-jwt-issuer-jwks-url` (fixes AD FS-style issuers whose JWKS is not served at `<issuer>/.well-known/jwks.json`)
|
||||||
|
|
||||||
# V7.15.3
|
# V7.15.3
|
||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,7 @@ When `--reverse-proxy` is enabled, configure `--trusted-proxy-ip` to the IPs or
|
||||||
| flag: `--email-domain`<br/>toml: `email_domains` | string \| list | authenticate emails with the specified domain (may be given multiple times). Use `*` to authenticate any email | |
|
| flag: `--email-domain`<br/>toml: `email_domains` | string \| list | authenticate emails with the specified domain (may be given multiple times). Use `*` to authenticate any email | |
|
||||||
| flag: `--encode-state`<br/>toml: `encode_state` | bool | encode the state parameter as UrlEncodedBase64 | false |
|
| flag: `--encode-state`<br/>toml: `encode_state` | bool | encode the state parameter as UrlEncodedBase64 | false |
|
||||||
| flag: `--extra-jwt-issuers`<br/>toml: `extra_jwt_issuers` | string | if `--skip-jwt-bearer-tokens` is set, a list of extra JWT `issuer=audience` (see a token's `iss`, `aud` fields) pairs (where the issuer URL has a `.well-known/openid-configuration` or a `.well-known/jwks.json`) | |
|
| flag: `--extra-jwt-issuers`<br/>toml: `extra_jwt_issuers` | string | if `--skip-jwt-bearer-tokens` is set, a list of extra JWT `issuer=audience` (see a token's `iss`, `aud` fields) pairs (where the issuer URL has a `.well-known/openid-configuration` or a `.well-known/jwks.json`) | |
|
||||||
|
| flag: `--extra-jwt-issuer-jwks-url`<br/>toml: `extra_jwt_issuer_jwks_urls` | string | for an extra JWT issuer whose JWKS is not served at `<issuer>/.well-known/jwks.json` (e.g. AD FS, where the `iss` is `http://host/adfs/services/trust` but the keys live at `https://host/adfs/discovery/keys`), an explicit `issuer=jwksURL` pair to use instead of discovery (may be given multiple times) | |
|
||||||
| flag: `--force-https`<br/>toml: `force_https` | bool | enforce https redirect | `false` |
|
| flag: `--force-https`<br/>toml: `force_https` | bool | enforce https redirect | `false` |
|
||||||
| flag: `--force-json-errors`<br/>toml: `force_json_errors` | bool | force JSON errors instead of HTTP error pages or redirects | `false` |
|
| flag: `--force-json-errors`<br/>toml: `force_json_errors` | bool | force JSON errors instead of HTTP error pages or redirects | `false` |
|
||||||
| flag: `--htpasswd-file`<br/>toml: `htpasswd_file` | string | additionally authenticate against a htpasswd file. Entries must be created with `htpasswd -B` for bcrypt encryption | |
|
| flag: `--htpasswd-file`<br/>toml: `htpasswd_file` | string | additionally authenticate against a htpasswd file. Entries must be created with `htpasswd -B` for bcrypt encryption | |
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ type Options struct {
|
||||||
SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"`
|
SkipJwtBearerTokens bool `flag:"skip-jwt-bearer-tokens" cfg:"skip_jwt_bearer_tokens"`
|
||||||
BearerTokenLoginFallback bool `flag:"bearer-token-login-fallback" cfg:"bearer_token_login_fallback"`
|
BearerTokenLoginFallback bool `flag:"bearer-token-login-fallback" cfg:"bearer_token_login_fallback"`
|
||||||
ExtraJwtIssuers []string `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers"`
|
ExtraJwtIssuers []string `flag:"extra-jwt-issuers" cfg:"extra_jwt_issuers"`
|
||||||
|
ExtraJwtIssuerJWKsURLs []string `flag:"extra-jwt-issuer-jwks-url" cfg:"extra_jwt_issuer_jwks_urls"`
|
||||||
SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"`
|
SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"`
|
||||||
SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"`
|
SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"`
|
||||||
SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"`
|
SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"`
|
||||||
|
|
@ -137,6 +138,7 @@ func NewFlagSet() *pflag.FlagSet {
|
||||||
flagSet.Bool("encode-state", false, "will encode oauth state with base64")
|
flagSet.Bool("encode-state", false, "will encode oauth state with base64")
|
||||||
flagSet.Bool("allow-query-semicolons", false, "allow the use of semicolons in query args")
|
flagSet.Bool("allow-query-semicolons", false, "allow the use of semicolons in query args")
|
||||||
flagSet.StringSlice("extra-jwt-issuers", []string{}, "if skip-jwt-bearer-tokens is set, a list of extra JWT issuer=audience pairs (where the issuer URL has a .well-known/openid-configuration or a .well-known/jwks.json)")
|
flagSet.StringSlice("extra-jwt-issuers", []string{}, "if skip-jwt-bearer-tokens is set, a list of extra JWT issuer=audience pairs (where the issuer URL has a .well-known/openid-configuration or a .well-known/jwks.json)")
|
||||||
|
flagSet.StringSlice("extra-jwt-issuer-jwks-url", []string{}, "for an extra JWT issuer whose JWKS is not served at <issuer>/.well-known/jwks.json (e.g. AD FS), an explicit issuer=jwksURL pair to use instead of discovery (may be given multiple times)")
|
||||||
|
|
||||||
flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
|
flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
|
||||||
flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . or a *. to allow subdomains (eg .example.com, *.example.com)")
|
flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . or a *. to allow subdomains (eg .example.com, *.example.com)")
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ func Validate(o *options.Options) error {
|
||||||
// Configure extra issuers
|
// Configure extra issuers
|
||||||
if len(o.ExtraJwtIssuers) > 0 {
|
if len(o.ExtraJwtIssuers) > 0 {
|
||||||
var jwtIssuers []jwtIssuer
|
var jwtIssuers []jwtIssuer
|
||||||
jwtIssuers, msgs = parseJwtIssuers(o.ExtraJwtIssuers, msgs)
|
jwtIssuers, msgs = parseJwtIssuers(o.ExtraJwtIssuers, o.ExtraJwtIssuerJWKsURLs, msgs)
|
||||||
for _, jwtIssuer := range jwtIssuers {
|
for _, jwtIssuer := range jwtIssuers {
|
||||||
verifier, err := newVerifierFromJwtIssuer(
|
verifier, err := newVerifierFromJwtIssuer(
|
||||||
o.Providers[0].OIDCConfig.AudienceClaims,
|
o.Providers[0].OIDCConfig.AudienceClaims,
|
||||||
|
|
@ -126,8 +126,10 @@ func parseSignatureKey(o *options.Options, msgs []string) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJwtIssuers takes in an array of strings in the form of issuer=audience
|
// parseJwtIssuers takes in an array of strings in the form of issuer=audience
|
||||||
// and parses to an array of jwtIssuer structs.
|
// and parses to an array of jwtIssuer structs. The optional jwksURLs array holds
|
||||||
func parseJwtIssuers(issuers []string, msgs []string) ([]jwtIssuer, []string) {
|
// issuer=jwksURL pairs that override the discovered/derived JWKS URL per issuer.
|
||||||
|
func parseJwtIssuers(issuers []string, jwksURLs []string, msgs []string) ([]jwtIssuer, []string) {
|
||||||
|
jwksByIssuer, msgs := parseJwtIssuerJWKsURLs(jwksURLs, msgs)
|
||||||
parsedIssuers := make([]jwtIssuer, 0, len(issuers))
|
parsedIssuers := make([]jwtIssuer, 0, len(issuers))
|
||||||
for _, jwtVerifier := range issuers {
|
for _, jwtVerifier := range issuers {
|
||||||
components := strings.Split(jwtVerifier, "=")
|
components := strings.Split(jwtVerifier, "=")
|
||||||
|
|
@ -136,11 +138,27 @@ func parseJwtIssuers(issuers []string, msgs []string) ([]jwtIssuer, []string) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
uri, audience := components[0], strings.Join(components[1:], "=")
|
uri, audience := components[0], strings.Join(components[1:], "=")
|
||||||
parsedIssuers = append(parsedIssuers, jwtIssuer{issuerURI: uri, audience: audience})
|
parsedIssuers = append(parsedIssuers, jwtIssuer{issuerURI: uri, audience: audience, jwksURI: jwksByIssuer[uri]})
|
||||||
}
|
}
|
||||||
return parsedIssuers, msgs
|
return parsedIssuers, msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseJwtIssuerJWKsURLs parses an array of strings in the form of issuer=jwksURL
|
||||||
|
// into a map keyed by issuer. The jwksURL value may itself contain "=", so only the
|
||||||
|
// first separator is significant.
|
||||||
|
func parseJwtIssuerJWKsURLs(jwksURLs []string, msgs []string) (map[string]string, []string) {
|
||||||
|
jwksByIssuer := make(map[string]string, len(jwksURLs))
|
||||||
|
for _, spec := range jwksURLs {
|
||||||
|
issuer, jwksURL, found := strings.Cut(spec, "=")
|
||||||
|
if !found || issuer == "" || jwksURL == "" {
|
||||||
|
msgs = append(msgs, fmt.Sprintf("invalid jwt issuer jwks url spec: %s", spec))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jwksByIssuer[issuer] = jwksURL
|
||||||
|
}
|
||||||
|
return jwksByIssuer, msgs
|
||||||
|
}
|
||||||
|
|
||||||
// newVerifierFromJwtIssuer takes in issuer information in jwtIssuer info and returns
|
// newVerifierFromJwtIssuer takes in issuer information in jwtIssuer info and returns
|
||||||
// a verifier for that issuer.
|
// a verifier for that issuer.
|
||||||
func newVerifierFromJwtIssuer(audienceClaims []string, extraAudiences []string, jwtIssuer jwtIssuer) (internaloidc.IDTokenVerifier, error) {
|
func newVerifierFromJwtIssuer(audienceClaims []string, extraAudiences []string, jwtIssuer jwtIssuer) (internaloidc.IDTokenVerifier, error) {
|
||||||
|
|
@ -151,6 +169,22 @@ func newVerifierFromJwtIssuer(audienceClaims []string, extraAudiences []string,
|
||||||
IssuerURL: jwtIssuer.issuerURI,
|
IssuerURL: jwtIssuer.issuerURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If an explicit JWKS URL is configured for this issuer, use it directly and
|
||||||
|
// skip both discovery and the hardcoded <issuer>/.well-known/jwks.json fallback.
|
||||||
|
// This is required for issuers (e.g. AD FS) whose JWKS lives at a different
|
||||||
|
// path/host/scheme than the issuer-derived default, while keeping the issuer
|
||||||
|
// match against the token's "iss" claim intact.
|
||||||
|
if jwtIssuer.jwksURI != "" {
|
||||||
|
pvOpts.JWKsURL = jwtIssuer.jwksURI
|
||||||
|
pvOpts.SkipDiscovery = true
|
||||||
|
|
||||||
|
pv, err := internaloidc.NewProviderVerifier(context.TODO(), pvOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not construct provider verifier for JWT Issuer: %v", err)
|
||||||
|
}
|
||||||
|
return pv.Verifier(), nil
|
||||||
|
}
|
||||||
|
|
||||||
pv, err := internaloidc.NewProviderVerifier(context.TODO(), pvOpts)
|
pv, err := internaloidc.NewProviderVerifier(context.TODO(), pvOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the discovery didn't work, try again without discovery
|
// If the discovery didn't work, try again without discovery
|
||||||
|
|
@ -170,6 +204,7 @@ func newVerifierFromJwtIssuer(audienceClaims []string, extraAudiences []string,
|
||||||
type jwtIssuer struct {
|
type jwtIssuer struct {
|
||||||
issuerURI string
|
issuerURI string
|
||||||
audience string
|
audience string
|
||||||
|
jwksURI string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseURL(toParse string, urltype string, msgs []string) (*url.URL, []string) {
|
func parseURL(toParse string, urltype string, msgs []string) (*url.URL, []string) {
|
||||||
|
|
|
||||||
|
|
@ -236,3 +236,128 @@ func TestProviderCAFilesError(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "unable to load provider CA file(s)")
|
assert.Contains(t, err.Error(), "unable to load provider CA file(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseJwtIssuers(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
issuers []string
|
||||||
|
jwksURLs []string
|
||||||
|
expected []jwtIssuer
|
||||||
|
msgs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "issuer and audience without jwks url",
|
||||||
|
issuers: []string{"https://issuer.example.com=client-id"},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{issuerURI: "https://issuer.example.com", audience: "client-id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audience containing an equals sign",
|
||||||
|
issuers: []string{"https://issuer.example.com=aud=with=equals"},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{issuerURI: "https://issuer.example.com", audience: "aud=with=equals"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit jwks url overrides the derived url",
|
||||||
|
issuers: []string{"http://sts.example.com/adfs/services/trust=urn:microsoft:userinfo"},
|
||||||
|
jwksURLs: []string{"http://sts.example.com/adfs/services/trust=https://sts.example.com/adfs/discovery/keys"},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{
|
||||||
|
issuerURI: "http://sts.example.com/adfs/services/trust",
|
||||||
|
audience: "urn:microsoft:userinfo",
|
||||||
|
jwksURI: "https://sts.example.com/adfs/discovery/keys",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jwks url containing an equals sign",
|
||||||
|
issuers: []string{"https://issuer.example.com=client-id"},
|
||||||
|
jwksURLs: []string{"https://issuer.example.com=https://issuer.example.com/keys?format=jwks"},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{
|
||||||
|
issuerURI: "https://issuer.example.com",
|
||||||
|
audience: "client-id",
|
||||||
|
jwksURI: "https://issuer.example.com/keys?format=jwks",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jwks url for an unrelated issuer is ignored",
|
||||||
|
issuers: []string{"https://issuer.example.com=client-id"},
|
||||||
|
jwksURLs: []string{"https://other.example.com=https://other.example.com/keys"},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{issuerURI: "https://issuer.example.com", audience: "client-id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid issuer spec is reported",
|
||||||
|
issuers: []string{"https://issuer.example.com"},
|
||||||
|
expected: []jwtIssuer{},
|
||||||
|
msgs: []string{"invalid jwt verifier uri=audience spec: https://issuer.example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid jwks url spec is reported",
|
||||||
|
issuers: []string{"https://issuer.example.com=client-id"},
|
||||||
|
jwksURLs: []string{"https://issuer.example.com"},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{issuerURI: "https://issuer.example.com", audience: "client-id"},
|
||||||
|
},
|
||||||
|
msgs: []string{"invalid jwt issuer jwks url spec: https://issuer.example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty jwks url value is reported",
|
||||||
|
issuers: []string{"https://issuer.example.com=client-id"},
|
||||||
|
jwksURLs: []string{"https://issuer.example.com="},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{issuerURI: "https://issuer.example.com", audience: "client-id"},
|
||||||
|
},
|
||||||
|
msgs: []string{"invalid jwt issuer jwks url spec: https://issuer.example.com="},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty issuer in jwks url spec is reported",
|
||||||
|
issuers: []string{"https://issuer.example.com=client-id"},
|
||||||
|
jwksURLs: []string{"=https://issuer.example.com/keys"},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{issuerURI: "https://issuer.example.com", audience: "client-id"},
|
||||||
|
},
|
||||||
|
msgs: []string{"invalid jwt issuer jwks url spec: =https://issuer.example.com/keys"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple issuers each with their own jwks url",
|
||||||
|
issuers: []string{"https://a.example.com=aud-a", "https://b.example.com=aud-b"},
|
||||||
|
jwksURLs: []string{
|
||||||
|
"https://a.example.com=https://a.example.com/keys",
|
||||||
|
"https://b.example.com=https://b.example.com/keys",
|
||||||
|
},
|
||||||
|
expected: []jwtIssuer{
|
||||||
|
{issuerURI: "https://a.example.com", audience: "aud-a", jwksURI: "https://a.example.com/keys"},
|
||||||
|
{issuerURI: "https://b.example.com", audience: "aud-b", jwksURI: "https://b.example.com/keys"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
issuers, msgs := parseJwtIssuers(tc.issuers, tc.jwksURLs, nil)
|
||||||
|
assert.Equal(t, tc.expected, issuers)
|
||||||
|
assert.Equal(t, tc.msgs, msgs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewVerifierFromJwtIssuerWithExplicitJWKsURL(t *testing.T) {
|
||||||
|
// An explicit JWKS URL must skip discovery entirely, so the verifier can be
|
||||||
|
// constructed without reaching the issuer (the JWKS is fetched lazily).
|
||||||
|
issuer := jwtIssuer{
|
||||||
|
issuerURI: "http://sts.example.com/adfs/services/trust",
|
||||||
|
audience: "urn:microsoft:userinfo",
|
||||||
|
jwksURI: "https://sts.example.com/adfs/discovery/keys",
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier, err := newVerifierFromJwtIssuer(nil, nil, issuer)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, verifier)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue