Add ProviderVerifier to providers/oidc

This commit is contained in:
Joel Speed 2022-02-16 10:15:36 +00:00 committed by Joel Speed
parent 3bb9621f5d
commit e3678aaaff
No known key found for this signature in database
GPG Key ID: 6E80578D6751DEFB
2 changed files with 328 additions and 0 deletions

View File

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

View File

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