diff --git a/CHANGELOG.md b/CHANGELOG.md index bd83ae08..44936eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - [#2620](https://github.com/oauth2-proxy/oauth2-proxy/pull/2620) fix: update code_verifier to use recommended method (@vishvananda) - [#2392](https://github.com/oauth2-proxy/oauth2-proxy/pull/2392) chore: extend test cases for oidc provider and documentation regarding implicit setting of the groups scope when no scope was specified in the config (@jjlakis / @tuunit) - [#2902](https://github.com/oauth2-proxy/oauth2-proxy/pull/2902) feat(entra): add Workload Identity support for Entra ID (@jjlakis) +- [#2376](https://github.com/oauth2-proxy/oauth2-proxy/pull/2376) feat: static public keys file support for oidc provider (@axel7083 / @jjlakis) # V7.7.1 diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 0108810f..e833f53c 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -410,6 +410,7 @@ character. | `insecureSkipNonce` | _bool_ | InsecureSkipNonce skips verifying the ID Token's nonce claim that must match
the random nonce sent in the initial OAuth flow. Otherwise, the nonce is checked
after the initial OAuth redeem & subsequent token refreshes.
default set to 'true'
Warning: In a future release, this will change to 'false' by default for enhanced security. | | `skipDiscovery` | _bool_ | SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints
default set to 'false' | | `jwksURL` | _string_ | JwksURL is the OpenID Connect JWKS URL
eg: https://www.googleapis.com/oauth2/v3/certs | +| `publicKeyFiles` | _[]string_ | PublicKeyFiles is a list of paths pointing to public key files in PEM format to use
for verifying JWT tokens | | `emailClaim` | _string_ | EmailClaim indicates which claim contains the user email,
default set to 'email' | | `groupsClaim` | _string_ | GroupsClaim indicates which claim contains the user groups
default set to 'groups' | | `userIDClaim` | _string_ | UserIDClaim indicates which claim contains the user ID
default set to 'email' | diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index 79a041b5..7ce5a6e3 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -96,7 +96,8 @@ Provider specific options can be found on their respective subpages. | flag: `--oidc-extra-audience`
toml: `oidc_extra_audiences` | string \| list | additional audiences which are allowed to pass verification | `"[]"` | | flag: `--oidc-groups-claim`
toml: `oidc_groups_claim` | string | which OIDC claim contains the user groups | `"groups"` | | flag: `--oidc-issuer-url`
toml: `oidc_issuer_url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | | -| flag: `--oidc-jwks-url`
toml: `oidc_jwks_url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled | | +| flag: `--oidc-jwks-url`
toml: `oidc_jwks_url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled and public key files are not provided | | +| flag: `--oidc-public-key-file`
toml: `oidc_public_key_files` | string | Path to public key file in PEM format to use for verifying JWT tokens (may be given multiple times). Required if OIDC discovery is disabled na JWKS URL isn't provided | string \| list | | flag: `--profile-url`
toml: `profile_url` | string | Profile access endpoint | | | flag: `--prompt`
toml: `prompt` | string | [OIDC prompt](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest); if present, `approval-prompt` is ignored | `""` | | flag: `--provider-ca-file`
toml: `provider_ca_files` | string \| list | Paths to CA certificates that should be used when connecting to the provider. If not specified, the default Go trust sources are used instead. | diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 6cf9ba8d..163aaa2c 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -522,6 +522,7 @@ type LegacyProvider struct { OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"` OIDCAudienceClaims []string `flag:"oidc-audience-claim" cfg:"oidc_audience_claims"` OIDCExtraAudiences []string `flag:"oidc-extra-audience" cfg:"oidc_extra_audiences"` + OIDCPublicKeyFiles []string `flag:"oidc-public-key-file" cfg:"oidc_public_key_files"` LoginURL string `flag:"login-url" cfg:"login_url"` RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` ProfileURL string `flag:"profile-url" cfg:"profile_url"` @@ -581,6 +582,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { flagSet.String("oidc-email-claim", OIDCEmailClaim, "which OIDC claim contains the user's email") flagSet.StringSlice("oidc-audience-claim", OIDCAudienceClaims, "which OIDC claims are used as audience to verify against client id") flagSet.StringSlice("oidc-extra-audience", []string{}, "additional audiences allowed to pass audience verification") + flagSet.StringSlice("oidc-public-key-file", []string{}, "path to public key file in PEM format to use for verifying JWT tokens (may be given multiple times)") flagSet.String("login-url", "", "Authentication endpoint") flagSet.String("redeem-url", "", "Token redemption endpoint") flagSet.String("profile-url", "", "Profile access endpoint") @@ -697,6 +699,7 @@ func (l *LegacyProvider) convert() (Providers, error) { GroupsClaim: l.OIDCGroupsClaim, AudienceClaims: l.OIDCAudienceClaims, ExtraAudiences: l.OIDCExtraAudiences, + PublicKeyFiles: l.OIDCPublicKeyFiles, } // Support for legacy configuration option diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 740a0d0f..94c23ce1 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -242,6 +242,9 @@ type OIDCOptions struct { // JwksURL is the OpenID Connect JWKS URL // eg: https://www.googleapis.com/oauth2/v3/certs JwksURL string `json:"jwksURL,omitempty"` + // PublicKeyFiles is a list of paths pointing to public key files in PEM format to use + // for verifying JWT tokens + PublicKeyFiles []string `json:"publicKeyFiles,omitempty"` // EmailClaim indicates which claim contains the user email, // default set to 'email' EmailClaim string `json:"emailClaim,omitempty"` diff --git a/pkg/providers/oidc/provider_verifier.go b/pkg/providers/oidc/provider_verifier.go index cc12036b..b6b9a970 100644 --- a/pkg/providers/oidc/provider_verifier.go +++ b/pkg/providers/oidc/provider_verifier.go @@ -2,11 +2,14 @@ package oidc import ( "context" + "crypto" + "crypto/x509" + "encoding/pem" "errors" "fmt" + "os" "github.com/coreos/go-oidc/v3/oidc" - "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" k8serrors "k8s.io/apimachinery/pkg/util/errors" ) @@ -38,6 +41,10 @@ type ProviderVerifierOptions struct { // eg: https://www.googleapis.com/oauth2/v3/certs JWKsURL string + // PublicKeyFiles is a list of paths pointing to public key files in PEM format to use + // for verifying JWT tokens + PublicKeyFiles []string + // SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints SkipDiscovery bool @@ -59,8 +66,12 @@ func (p ProviderVerifierOptions) validate() error { errs = append(errs, errors.New("missing required setting: issuer-url")) } - if p.SkipDiscovery && p.JWKsURL == "" { - errs = append(errs, errors.New("missing required setting: jwks-url")) + if p.SkipDiscovery && p.JWKsURL == "" && len(p.PublicKeyFiles) == 0 { + errs = append(errs, errors.New("missing required setting: jwks-url or public-key-files")) + } + + if p.JWKsURL != "" && len(p.PublicKeyFiles) > 0 { + errs = append(errs, errors.New("mutually exclusive settings: jwks-url and public-key-files")) } if len(errs) > 0 { @@ -91,12 +102,12 @@ func (p ProviderVerifierOptions) toOIDCConfig() *oidc.Config { // NewProviderVerifier constructs a ProviderVerifier from the options given. func NewProviderVerifier(ctx context.Context, opts ProviderVerifierOptions) (ProviderVerifier, error) { if err := opts.validate(); err != nil { - return nil, fmt.Errorf("invalid provider verifier options: %v", err) + return nil, fmt.Errorf("invalid provider verifier options: %w", err) } verifierBuilder, provider, err := getVerifierBuilder(ctx, opts) if err != nil { - return nil, fmt.Errorf("could not get verifier builder: %v", err) + return nil, fmt.Errorf("could not get verifier builder: %w", err) } verifier := NewVerifier(verifierBuilder(opts.toOIDCConfig()), opts.toVerificationOptions()) @@ -117,22 +128,78 @@ type verifierBuilder func(*oidc.Config) *oidc.IDTokenVerifier func getVerifierBuilder(ctx context.Context, opts ProviderVerifierOptions) (verifierBuilder, DiscoveryProvider, error) { if opts.SkipDiscovery { + var keySet oidc.KeySet + var err error + + if opts.JWKsURL != "" { + keySet = oidc.NewRemoteKeySet(ctx, opts.JWKsURL) + } else { + keySet, err = newKeySetFromStatic(opts.PublicKeyFiles) + if err != nil { + return nil, nil, fmt.Errorf("error while parsing public keys: %w", err) + } + } // Instead of discovering the JWKs URL, it needs to be specified in the opts already - return newVerifierBuilder(ctx, opts.IssuerURL, opts.JWKsURL, opts.SupportedSigningAlgs), nil, nil + return newVerifierBuilder( + opts.IssuerURL, + keySet, + opts.SupportedSigningAlgs, + ), nil, nil } provider, err := NewProvider(ctx, opts.IssuerURL, opts.SkipIssuerVerification) if err != nil { - return nil, nil, fmt.Errorf("error while discovery OIDC configuration: %v", err) + return nil, nil, fmt.Errorf("error while discovery OIDC configuration: %w", err) } - verifierBuilder := newVerifierBuilder(ctx, opts.IssuerURL, provider.Endpoints().JWKsURL, provider.SupportedSigningAlgs()) - return verifierBuilder, provider, nil + + return newVerifierBuilder( + opts.IssuerURL, + oidc.NewRemoteKeySet(ctx, provider.Endpoints().JWKsURL), + provider.SupportedSigningAlgs(), + ), provider, nil +} + +// GetPublicKeyFromBytes parses a PEM-encoded public key from a byte array +// and returns a crypto.PublicKey object. +func getPublicKeyFromBytes(bytes []byte) (crypto.PublicKey, error) { + block, _ := pem.Decode(bytes) + if block == nil { + return nil, errors.New("failed to parse PEM block containing the public key") + } + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + + cryptoPublicKey, ok := publicKey.(crypto.PublicKey) + if !ok { + return nil, errors.New("failed to cast public key to crypto.PublicKey") + } + + return cryptoPublicKey, nil +} + +// newKeySetFromStatic create a StaticKeySet from a set of files +func newKeySetFromStatic(keys []string) (*oidc.StaticKeySet, error) { + keySet := []crypto.PublicKey{} + for _, keyFile := range keys { + bytes, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + publicKey, err := getPublicKeyFromBytes(bytes) + if err != nil { + return nil, fmt.Errorf("failed to create keys: %w", err) + } + keySet = append(keySet, publicKey) + } + + return &oidc.StaticKeySet{PublicKeys: keySet}, nil } // newVerifierBuilder returns a function to create a IDToken verifier from an OIDC config. -func newVerifierBuilder(ctx context.Context, issuerURL, jwksURL string, supportedSigningAlgs []string) verifierBuilder { - ctx = oidc.ClientContext(ctx, requests.DefaultHTTPClient) - keySet := oidc.NewRemoteKeySet(ctx, jwksURL) +func newVerifierBuilder(issuerURL string, keySet oidc.KeySet, supportedSigningAlgs []string) verifierBuilder { return func(oidcConfig *oidc.Config) *oidc.IDTokenVerifier { if len(supportedSigningAlgs) > 0 { oidcConfig.SupportedSigningAlgs = supportedSigningAlgs diff --git a/pkg/providers/oidc/provider_verifier_test.go b/pkg/providers/oidc/provider_verifier_test.go index e3084a06..ff91e016 100644 --- a/pkg/providers/oidc/provider_verifier_test.go +++ b/pkg/providers/oidc/provider_verifier_test.go @@ -2,6 +2,8 @@ package oidc import ( "context" + "os" + "path/filepath" "time" "github.com/golang-jwt/jwt/v5" @@ -10,6 +12,37 @@ import ( . "github.com/onsi/gomega" ) +var tempDir string +var invalidPublicKeyFilePath string +var validPublicKeyFilePath string + +var _ = BeforeSuite(func() { + var err error + + // Create a temporary directory and public key file + tempDir, err = os.MkdirTemp("/tmp", "provider-verifier-test") + Expect(err).ToNot(HaveOccurred()) + + invalidPublicKeyFilePath = filepath.Join(tempDir, "invalid.key") + validPublicKeyFilePath = filepath.Join(tempDir, "valid.key") + + invalidKeyContents := []byte(`-----BEGIN INVALID KEY----- +ThisIsNotAValidKey +-----END INVALID KEY-----`) + Expect(os.WriteFile(invalidPublicKeyFilePath, invalidKeyContents, 0644)).To(Succeed()) + + validKeyContents := []byte(`-----BEGIN PUBLIC KEY----- +MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALBJK+8qU+aQu2bHxJ8E95AIu2NINztM +NmX9R2zI9xlXN8wGQG8kWLYoRLbyiZwY9kdzOBGvYci64wHIjtFswHcCAwEAAQ== +-----END PUBLIC KEY-----`) + Expect(os.WriteFile(validPublicKeyFilePath, validKeyContents, 0644)).To(Succeed()) +}) + +var _ = AfterSuite(func() { + // Clean up temporary directory + Expect(os.RemoveAll(tempDir)).To(Succeed()) +}) + var _ = Describe("ProviderVerifier", func() { var m *mockoidc.MockOIDC @@ -77,14 +110,42 @@ var _ = Describe("ProviderVerifier", func() { p.SkipDiscovery = true p.JWKsURL = "" }, - expectedError: "invalid provider verifier options: missing required setting: jwks-url", + expectedError: "invalid provider verifier options: missing required setting: jwks-url or public-key-files", }), - Entry("should be succesfful when skipping discovery with the JWKs URL specified", &newProviderVerifierTableInput{ + Entry("with skip discovery, the JWKs URL not empty and len(PublicKeyFiles) is greater than 0", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.SkipDiscovery = true + p.JWKsURL = "notEmpty" + p.PublicKeyFiles = []string{"notEmpty"} + }, + expectedError: "invalid provider verifier options: mutually exclusive settings: jwks-url and public-key-files", + }), + Entry("should be successful when skipping discovery with the JWKs URL specified", &newProviderVerifierTableInput{ modifyOpts: func(p *ProviderVerifierOptions) { p.SkipDiscovery = true p.JWKsURL = m.JWKSEndpoint() }, }), + Entry("should pass when the key is valid", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.SkipDiscovery = true + p.PublicKeyFiles = []string{validPublicKeyFilePath} + }, + }), + Entry("should fail when the key is invalid", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.SkipDiscovery = true + p.PublicKeyFiles = []string{invalidPublicKeyFilePath} + }, + expectedError: "could not get verifier builder: error while parsing public keys", + }), + Entry("should fail when the key file is not found", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.SkipDiscovery = true + p.PublicKeyFiles = []string{"non-existing"} + }, + expectedError: "could not get verifier builder: error while parsing public keys: failed to read file", + }), ) type verifierTableInput struct { diff --git a/providers/providers.go b/providers/providers.go index 72204e55..1e2d4044 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -92,6 +92,7 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, ExtraAudiences: providerConfig.OIDCConfig.ExtraAudiences, IssuerURL: providerConfig.OIDCConfig.IssuerURL, JWKsURL: providerConfig.OIDCConfig.JwksURL, + PublicKeyFiles: providerConfig.OIDCConfig.PublicKeyFiles, SkipDiscovery: providerConfig.OIDCConfig.SkipDiscovery, SkipIssuerVerification: providerConfig.OIDCConfig.InsecureSkipIssuerVerification, }) diff --git a/providers/providers_test.go b/providers/providers_test.go index ce74099d..82961d84 100644 --- a/providers/providers_test.go +++ b/providers/providers_test.go @@ -86,7 +86,7 @@ func TestSkipOIDCDiscovery(t *testing.T) { } _, err := newProviderDataFromConfig(providerConfig) - g.Expect(err).To(MatchError("error building OIDC ProviderVerifier: invalid provider verifier options: missing required setting: jwks-url")) + g.Expect(err).To(MatchError("error building OIDC ProviderVerifier: invalid provider verifier options: missing required setting: jwks-url or public-key-files")) providerConfig.LoginURL = msAuthURL providerConfig.RedeemURL = msTokenURL