From e3678aaaff5ccf99518d5ca91291949662794845 Mon Sep 17 00:00:00 2001 From: Joel Speed Date: Wed, 16 Feb 2022 10:15:36 +0000 Subject: [PATCH] Add ProviderVerifier to providers/oidc --- pkg/providers/oidc/provider_verifier.go | 155 +++++++++++++++++ pkg/providers/oidc/provider_verifier_test.go | 173 +++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 pkg/providers/oidc/provider_verifier.go create mode 100644 pkg/providers/oidc/provider_verifier_test.go diff --git a/pkg/providers/oidc/provider_verifier.go b/pkg/providers/oidc/provider_verifier.go new file mode 100644 index 0000000..6da100a --- /dev/null +++ b/pkg/providers/oidc/provider_verifier.go @@ -0,0 +1,155 @@ +package oidc + +import ( + "context" + "errors" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + k8serrors "k8s.io/apimachinery/pkg/util/errors" +) + +// ProviderVerifier represents the OIDC discovery and verification process +type ProviderVerifier interface { + DiscoveryEnabled() bool + Provider() DiscoveryProvider + Verifier() IDTokenVerifier +} + +// ProviderVerifierOptions allows you to configure a ProviderVerifier +type ProviderVerifierOptions struct { + // AudienceClaim allows to define any claim that is verified against the client id + // By default `aud` claim is used for verification. + AudienceClaims []string + + // ClientID is the OAuth Client ID that is defined in the provider + ClientID string + + // ExtraAudiences is a list of additional audiences that are allowed + // to pass verification in addition to the client id. + ExtraAudiences []string + + // IssuerURL is the OpenID Connect issuer URL + // eg: https://accounts.google.com + IssuerURL string + + // JWKsURL is the OpenID Connect JWKS URL + // eg: https://www.googleapis.com/oauth2/v3/certs + JWKsURL string + + // SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints + SkipDiscovery bool + + // SkipIssuerVerification skips verification of ID token issuers. + // When false, ID Token Issuers must match the OIDC discovery URL. + SkipIssuerVerification bool +} + +// validate checks that the required options are present before attempting to create +// the ProviderVerifier. +func (p ProviderVerifierOptions) validate() error { + var errs []error + + if p.IssuerURL == "" { + 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 len(errs) > 0 { + return k8serrors.NewAggregate(errs) + } + return nil +} + +// toVerificationOptions returns an IDTokenVerificationOptions based on the configured options. +func (p ProviderVerifierOptions) toVerificationOptions() IDTokenVerificationOptions { + return IDTokenVerificationOptions{ + AudienceClaims: p.AudienceClaims, + ClientID: p.ClientID, + ExtraAudiences: p.ExtraAudiences, + } +} + +// toOIDCConfig returns an oidc.Config based on the configured options. +func (p ProviderVerifierOptions) toOIDCConfig() *oidc.Config { + return &oidc.Config{ + ClientID: p.ClientID, + SkipIssuerCheck: p.SkipIssuerVerification, + SkipClientIDCheck: true, + } +} + +// 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) + } + + verifierBuilder, provider, err := getVerifierBuilder(ctx, opts) + if err != nil { + return nil, fmt.Errorf("could not get verifier builder: %v", err) + } + verifier := NewVerifier(verifierBuilder(opts.toOIDCConfig()), opts.toVerificationOptions()) + + if provider == nil { + // To avoid the possibility of nil pointers, always return an empty provider if discovery didn't occur. + // Users are expected to check whether discovery was enabled before using the provider. + provider = &discoveryProvider{} + } + + return &providerVerifier{ + discoveryEnabled: !opts.SkipDiscovery, + provider: provider, + verifier: verifier, + }, nil +} + +type verifierBuilder func(*oidc.Config) *oidc.IDTokenVerifier + +func getVerifierBuilder(ctx context.Context, opts ProviderVerifierOptions) (verifierBuilder, DiscoveryProvider, error) { + if opts.SkipDiscovery { + // Instead of discovering the JWKs URK, it needs to be specified in the opts already + return newVerifierBuilder(ctx, opts.IssuerURL, opts.JWKsURL), 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) + } + verifierBuilder := newVerifierBuilder(ctx, opts.IssuerURL, provider.Endpoints().JWKsURL) + return verifierBuilder, provider, nil +} + +// newVerifierBuilder returns a function to create a IDToken verifier from an OIDC config. +func newVerifierBuilder(ctx context.Context, issuerURL, jwksURL string) verifierBuilder { + keySet := oidc.NewRemoteKeySet(ctx, jwksURL) + return func(oidcConfig *oidc.Config) *oidc.IDTokenVerifier { + return oidc.NewVerifier(issuerURL, keySet, oidcConfig) + } +} + +// providerVerifier is an implementation of the ProviderVerifier interface +type providerVerifier struct { + discoveryEnabled bool + provider DiscoveryProvider + verifier IDTokenVerifier +} + +// DiscoveryEnabled returns whether the provider verifier was constructed +// using the OIDC discovery process or whether it was manually discovered. +func (p *providerVerifier) DiscoveryEnabled() bool { + return p.discoveryEnabled +} + +// Provider returns the OIDC discovery provider +func (p *providerVerifier) Provider() DiscoveryProvider { + return p.provider +} + +// Verifier returns the ID token verifier +func (p *providerVerifier) Verifier() IDTokenVerifier { + return p.verifier +} diff --git a/pkg/providers/oidc/provider_verifier_test.go b/pkg/providers/oidc/provider_verifier_test.go new file mode 100644 index 0000000..86bfff9 --- /dev/null +++ b/pkg/providers/oidc/provider_verifier_test.go @@ -0,0 +1,173 @@ +package oidc + +import ( + "context" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/oauth2-proxy/mockoidc" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("ProviderVerifier", func() { + var m *mockoidc.MockOIDC + + BeforeEach(func() { + var err error + m, err = mockoidc.Run() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + Expect(m.Shutdown()).To(Succeed()) + }) + + type newProviderVerifierTableInput struct { + modifyOpts func(*ProviderVerifierOptions) + expectedError string + } + + DescribeTable("when constructing the provider verifier", func(in *newProviderVerifierTableInput) { + opts := ProviderVerifierOptions{ + AudienceClaims: []string{"aud"}, + ClientID: m.Config().ClientID, + ExtraAudiences: []string{}, + IssuerURL: m.Issuer(), + } + if in.modifyOpts != nil { + in.modifyOpts(&opts) + } + + pv, err := NewProviderVerifier(context.Background(), opts) + if in.expectedError != "" { + Expect(err).To(MatchError(HavePrefix(in.expectedError))) + return + } + Expect(err).ToNot(HaveOccurred()) + + Expect(pv.DiscoveryEnabled()).ToNot(Equal(opts.SkipDiscovery), "DiscoveryEnabled should be the reverse of skip discovery") + Expect(pv.Provider()).ToNot(BeNil()) + + if pv.DiscoveryEnabled() { + endpoints := pv.Provider().Endpoints() + Expect(endpoints.AuthURL).To(Equal(m.AuthorizationEndpoint())) + Expect(endpoints.TokenURL).To(Equal(m.TokenEndpoint())) + Expect(endpoints.JWKsURL).To(Equal(m.JWKSEndpoint())) + Expect(endpoints.UserInfoURL).To(Equal(m.UserinfoEndpoint())) + } + }, + Entry("should be succesfful when discovering the OIDC provider", &newProviderVerifierTableInput{ + modifyOpts: func(_ *ProviderVerifierOptions) {}, + }), + Entry("when the issuer URL is missing", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.IssuerURL = "" + }, + expectedError: "invalid provider verifier options: missing required setting: issuer-url", + }), + Entry("when the issuer URL is invalid", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.IssuerURL = "invalid" + }, + expectedError: "could not get verifier builder: error while discovery OIDC configuration: failed to discover OIDC configuration: error performing request: Get \"invalid/.well-known/openid-configuration\": unsupported protocol scheme \"\"", + }), + Entry("with skip discovery and the JWKs URL is missing", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.SkipDiscovery = true + p.JWKsURL = "" + }, + expectedError: "invalid provider verifier options: missing required setting: jwks-url", + }), + Entry("should be succesfful when skipping discovery with the JWKs URL specified", &newProviderVerifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.SkipDiscovery = true + p.JWKsURL = m.JWKSEndpoint() + }, + }), + ) + + type verifierTableInput struct { + modifyOpts func(*ProviderVerifierOptions) + modifyClaims func(*jwt.StandardClaims) + expectedError string + } + + DescribeTable("when constructing the provider verifier", func(in *verifierTableInput) { + opts := ProviderVerifierOptions{ + AudienceClaims: []string{"aud"}, + ClientID: m.Config().ClientID, + ExtraAudiences: []string{}, + IssuerURL: m.Issuer(), + } + if in.modifyOpts != nil { + in.modifyOpts(&opts) + } + + pv, err := NewProviderVerifier(context.Background(), opts) + Expect(err).ToNot(HaveOccurred()) + + now := time.Now() + claims := jwt.StandardClaims{ + Audience: m.Config().ClientID, + Issuer: m.Issuer(), + ExpiresAt: now.Add(1 * time.Hour).Unix(), + IssuedAt: now.Unix(), + Subject: "user", + } + if in.modifyClaims != nil { + in.modifyClaims(&claims) + } + + rawIDToken, err := m.Keypair.SignJWT(claims) + Expect(err).ToNot(HaveOccurred()) + + idToken, err := pv.Verifier().Verify(context.Background(), rawIDToken) + if in.expectedError != "" { + Expect(err).To(MatchError(HavePrefix(in.expectedError))) + return + } + Expect(err).ToNot(HaveOccurred()) + + Expect(idToken.Issuer).To(Equal(claims.Issuer)) + Expect(idToken.Audience).To(ConsistOf(claims.Audience)) + Expect(idToken.Subject).To(Equal(claims.Subject)) + }, + Entry("with the default opts and claims", &verifierTableInput{}), + Entry("when the audience is mismatched", &verifierTableInput{ + modifyClaims: func(j *jwt.StandardClaims) { + j.Audience = "OtherClient" + }, + expectedError: "audience from claim aud with value [OtherClient] does not match with any of allowed audiences", + }), + Entry("when the audience is an extra audience", &verifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.ExtraAudiences = []string{"ExtraIssuer"} + }, + modifyClaims: func(j *jwt.StandardClaims) { + j.Audience = "ExtraIssuer" + }, + }), + Entry("when the issuer is mismatched", &verifierTableInput{ + modifyClaims: func(j *jwt.StandardClaims) { + j.Issuer = "OtherIssuer" + }, + expectedError: "failed to verify token: oidc: id token issued by a different provider", + }), + Entry("when the issuer is mismatched with skip issuer verification", &verifierTableInput{ + modifyOpts: func(p *ProviderVerifierOptions) { + p.SkipIssuerVerification = true + }, + modifyClaims: func(j *jwt.StandardClaims) { + j.Issuer = "OtherIssuer" + }, + }), + Entry("when the token has expired", &verifierTableInput{ + modifyClaims: func(j *jwt.StandardClaims) { + j.ExpiresAt = time.Now().Add(-1 * time.Hour).Unix() + }, + expectedError: "failed to verify token: oidc: token is expired", + }), + ) +})