oidc authentication: switch to v2 of coreos/go-oidc

parent 9586cd06
...@@ -210,6 +210,7 @@ func TestAddFlags(t *testing.T) { ...@@ -210,6 +210,7 @@ func TestAddFlags(t *testing.T) {
BootstrapToken: &kubeoptions.BootstrapTokenAuthenticationOptions{}, BootstrapToken: &kubeoptions.BootstrapTokenAuthenticationOptions{},
OIDC: &kubeoptions.OIDCAuthenticationOptions{ OIDC: &kubeoptions.OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"},
}, },
PasswordFile: &kubeoptions.PasswordFileAuthenticationOptions{}, PasswordFile: &kubeoptions.PasswordFileAuthenticationOptions{},
RequestHeader: &apiserveroptions.RequestHeaderAuthenticationOptions{}, RequestHeader: &apiserveroptions.RequestHeaderAuthenticationOptions{},
......
...@@ -58,6 +58,7 @@ type AuthenticatorConfig struct { ...@@ -58,6 +58,7 @@ type AuthenticatorConfig struct {
OIDCUsernamePrefix string OIDCUsernamePrefix string
OIDCGroupsClaim string OIDCGroupsClaim string
OIDCGroupsPrefix string OIDCGroupsPrefix string
OIDCSigningAlgs []string
ServiceAccountKeyFiles []string ServiceAccountKeyFiles []string
ServiceAccountLookup bool ServiceAccountLookup bool
WebhookTokenAuthnConfigFile string WebhookTokenAuthnConfigFile string
...@@ -143,7 +144,7 @@ func (config AuthenticatorConfig) New() (authenticator.Request, *spec.SecurityDe ...@@ -143,7 +144,7 @@ func (config AuthenticatorConfig) New() (authenticator.Request, *spec.SecurityDe
// simply returns an error, the OpenID Connect plugin may query the provider to // simply returns an error, the OpenID Connect plugin may query the provider to
// update the keys, causing performance hits. // update the keys, causing performance hits.
if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 { if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 {
oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim, config.OIDCUsernamePrefix, config.OIDCGroupsClaim, config.OIDCGroupsPrefix) oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim, config.OIDCUsernamePrefix, config.OIDCGroupsClaim, config.OIDCGroupsPrefix, config.OIDCSigningAlgs)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
...@@ -235,7 +236,7 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Token, e ...@@ -235,7 +236,7 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Token, e
} }
// newAuthenticatorFromOIDCIssuerURL returns an authenticator.Token or an error. // newAuthenticatorFromOIDCIssuerURL returns an authenticator.Token or an error.
func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, usernamePrefix, groupsClaim, groupsPrefix string) (authenticator.Token, error) { func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, usernamePrefix, groupsClaim, groupsPrefix string, signingAlgs []string) (authenticator.Token, error) {
const noUsernamePrefix = "-" const noUsernamePrefix = "-"
if usernamePrefix == "" && usernameClaim != "email" { if usernamePrefix == "" && usernameClaim != "email" {
...@@ -251,14 +252,15 @@ func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClai ...@@ -251,14 +252,15 @@ func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClai
usernamePrefix = "" usernamePrefix = ""
} }
tokenAuthenticator, err := oidc.New(oidc.OIDCOptions{ tokenAuthenticator, err := oidc.New(oidc.Options{
IssuerURL: issuerURL, IssuerURL: issuerURL,
ClientID: clientID, ClientID: clientID,
CAFile: caFile, CAFile: caFile,
UsernameClaim: usernameClaim, UsernameClaim: usernameClaim,
UsernamePrefix: usernamePrefix, UsernamePrefix: usernamePrefix,
GroupsClaim: groupsClaim, GroupsClaim: groupsClaim,
GroupsPrefix: groupsPrefix, GroupsPrefix: groupsPrefix,
SupportedSigningAlgs: signingAlgs,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
......
...@@ -61,6 +61,7 @@ type OIDCAuthenticationOptions struct { ...@@ -61,6 +61,7 @@ type OIDCAuthenticationOptions struct {
UsernamePrefix string UsernamePrefix string
GroupsClaim string GroupsClaim string
GroupsPrefix string GroupsPrefix string
SigningAlgs []string
} }
type PasswordFileAuthenticationOptions struct { type PasswordFileAuthenticationOptions struct {
...@@ -208,6 +209,10 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) { ...@@ -208,6 +209,10 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) {
"If provided, all groups will be prefixed with this value to prevent conflicts with "+ "If provided, all groups will be prefixed with this value to prevent conflicts with "+
"other authentication strategies.") "other authentication strategies.")
fs.StringSliceVar(&s.OIDC.SigningAlgs, "oidc-signing-algs", []string{"RS256"}, ""+
"Comma-separated list of allowed JOSE asymmetric signing algorithms. JWTs with a "+
"'alg' header value not in this list will be rejected. "+
"Values are defined by RFC 7518 https://tools.ietf.org/html/rfc7518#section-3.1.")
} }
if s.PasswordFile != nil { if s.PasswordFile != nil {
...@@ -272,6 +277,7 @@ func (s *BuiltInAuthenticationOptions) ToAuthenticationConfig() authenticator.Au ...@@ -272,6 +277,7 @@ func (s *BuiltInAuthenticationOptions) ToAuthenticationConfig() authenticator.Au
ret.OIDCIssuerURL = s.OIDC.IssuerURL ret.OIDCIssuerURL = s.OIDC.IssuerURL
ret.OIDCUsernameClaim = s.OIDC.UsernameClaim ret.OIDCUsernameClaim = s.OIDC.UsernameClaim
ret.OIDCUsernamePrefix = s.OIDC.UsernamePrefix ret.OIDCUsernamePrefix = s.OIDC.UsernamePrefix
ret.OIDCSigningAlgs = s.OIDC.SigningAlgs
} }
if s.PasswordFile != nil { if s.PasswordFile != nil {
......
...@@ -17,7 +17,7 @@ limitations under the License. ...@@ -17,7 +17,7 @@ limitations under the License.
/* /*
oidc implements the authenticator.Token interface using the OpenID Connect protocol. oidc implements the authenticator.Token interface using the OpenID Connect protocol.
config := oidc.OIDCOptions{ config := oidc.Options{
IssuerURL: "https://accounts.google.com", IssuerURL: "https://accounts.google.com",
ClientID: os.Getenv("GOOGLE_CLIENT_ID"), ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
UsernameClaim: "email", UsernameClaim: "email",
...@@ -27,25 +27,28 @@ oidc implements the authenticator.Token interface using the OpenID Connect proto ...@@ -27,25 +27,28 @@ oidc implements the authenticator.Token interface using the OpenID Connect proto
package oidc package oidc
import ( import (
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"sync" "strings"
"sync/atomic" "sync/atomic"
"time"
"github.com/coreos/go-oidc/jose" oidc "github.com/coreos/go-oidc"
"github.com/coreos/go-oidc/oidc"
"github.com/golang/glog" "github.com/golang/glog"
"k8s.io/apimachinery/pkg/util/net" "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
certutil "k8s.io/client-go/util/cert" certutil "k8s.io/client-go/util/cert"
) )
type OIDCOptions struct { type Options struct {
// IssuerURL is the URL the provider signs ID Tokens as. This will be the "iss" // IssuerURL is the URL the provider signs ID Tokens as. This will be the "iss"
// field of all tokens produced by the provider and is used for configuration // field of all tokens produced by the provider and is used for configuration
// discovery. // discovery.
...@@ -83,30 +86,85 @@ type OIDCOptions struct { ...@@ -83,30 +86,85 @@ type OIDCOptions struct {
// GroupsPrefix, if specified, causes claims mapping to group names to be prefixed with the // GroupsPrefix, if specified, causes claims mapping to group names to be prefixed with the
// value. A value "oidc:" would result in groups like "oidc:engineering" and "oidc:marketing". // value. A value "oidc:" would result in groups like "oidc:engineering" and "oidc:marketing".
GroupsPrefix string GroupsPrefix string
// SupportedSigningAlgs sets the accepted set of JOSE signing algorithms that
// can be used by the provider to sign tokens.
//
// https://tools.ietf.org/html/rfc7518#section-3.1
//
// This value defaults to RS256, the value recommended by the OpenID Connect
// spec:
//
// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
SupportedSigningAlgs []string
// now is used for testing. It defaults to time.Now.
now func() time.Time
} }
type OIDCAuthenticator struct { type Authenticator struct {
issuerURL string issuerURL string
trustedClientID string
usernameClaim string usernameClaim string
usernamePrefix string usernamePrefix string
groupsClaim string groupsClaim string
groupsPrefix string groupsPrefix string
httpClient *http.Client // Contains an *oidc.IDTokenVerifier. Do not access directly use the
// idTokenVerifier method.
verifier atomic.Value
// Contains an *oidc.Client. Do not access directly. Use client() method. cancel context.CancelFunc
oidcClient atomic.Value }
// Guards the close method and is used to lock during initialization and closing. func (a *Authenticator) setVerifier(v *oidc.IDTokenVerifier) {
mu sync.Mutex a.verifier.Store(v)
close func() // May be nil
} }
// New creates a token authenticator which validates OpenID Connect ID Tokens. func (a *Authenticator) idTokenVerifier() (*oidc.IDTokenVerifier, bool) {
func New(opts OIDCOptions) (*OIDCAuthenticator, error) { if v := a.verifier.Load(); v != nil {
return v.(*oidc.IDTokenVerifier), true
}
return nil, false
}
func (a *Authenticator) Close() {
a.cancel()
}
func New(opts Options) (*Authenticator, error) {
return newAuthenticator(opts, func(ctx context.Context, a *Authenticator, config *oidc.Config) {
// Asynchronously attempt to initialize the authenticator. This enables
// self-hosted providers, providers that run on top of Kubernetes itself.
go wait.PollUntil(time.Second*10, func() (done bool, err error) {
provider, err := oidc.NewProvider(ctx, a.issuerURL)
if err != nil {
glog.Errorf("oidc authenticator: initializing plugin: %v", err)
return false, nil
}
verifier := provider.Verifier(config)
a.setVerifier(verifier)
return true, nil
}, ctx.Done())
})
}
// whitelist of signing algorithms to ensure users don't mistakenly pass something
// goofy.
var allowedSigningAlgs = map[string]bool{
oidc.RS256: true,
oidc.RS384: true,
oidc.RS512: true,
oidc.ES256: true,
oidc.ES384: true,
oidc.ES512: true,
oidc.PS256: true,
oidc.PS384: true,
oidc.PS512: true,
}
func newAuthenticator(opts Options, initVerifier func(ctx context.Context, a *Authenticator, config *oidc.Config)) (*Authenticator, error) {
url, err := url.Parse(opts.IssuerURL) url, err := url.Parse(opts.IssuerURL)
if err != nil { if err != nil {
return nil, err return nil, err
...@@ -120,6 +178,18 @@ func New(opts OIDCOptions) (*OIDCAuthenticator, error) { ...@@ -120,6 +178,18 @@ func New(opts OIDCOptions) (*OIDCAuthenticator, error) {
return nil, errors.New("no username claim provided") return nil, errors.New("no username claim provided")
} }
supportedSigningAlgs := opts.SupportedSigningAlgs
if len(supportedSigningAlgs) == 0 {
// RS256 is the default recommended by OpenID Connect and an 'alg' value
// providers are required to implement.
supportedSigningAlgs = []string{oidc.RS256}
}
for _, alg := range supportedSigningAlgs {
if !allowedSigningAlgs[alg] {
return nil, fmt.Errorf("oidc: unsupported signing alg: %q", alg)
}
}
var roots *x509.CertPool var roots *x509.CertPool
if opts.CAFile != "" { if opts.CAFile != "" {
roots, err = certutil.NewPool(opts.CAFile) roots, err = certutil.NewPool(opts.CAFile)
...@@ -137,137 +207,91 @@ func New(opts OIDCOptions) (*OIDCAuthenticator, error) { ...@@ -137,137 +207,91 @@ func New(opts OIDCOptions) (*OIDCAuthenticator, error) {
TLSClientConfig: &tls.Config{RootCAs: roots}, TLSClientConfig: &tls.Config{RootCAs: roots},
}) })
authenticator := &OIDCAuthenticator{ client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
issuerURL: opts.IssuerURL,
trustedClientID: opts.ClientID,
usernameClaim: opts.UsernameClaim,
usernamePrefix: opts.UsernamePrefix,
groupsClaim: opts.GroupsClaim,
groupsPrefix: opts.GroupsPrefix,
httpClient: &http.Client{Transport: tr},
}
// Attempt to initialize the authenticator asynchronously.
//
// Ignore errors instead of returning it since the OpenID Connect provider might not be
// available yet, for instance if it's running on the cluster and needs the API server
// to come up first. Errors will be logged within the client() method.
go func() {
defer runtime.HandleCrash()
authenticator.client()
}()
return authenticator, nil
}
// Close stops all goroutines used by the authenticator. ctx, cancel := context.WithCancel(context.Background())
func (a *OIDCAuthenticator) Close() { ctx = oidc.ClientContext(ctx, client)
a.mu.Lock()
defer a.mu.Unlock()
if a.close != nil { authenticator := &Authenticator{
a.close() issuerURL: opts.IssuerURL,
usernameClaim: opts.UsernameClaim,
usernamePrefix: opts.UsernamePrefix,
groupsClaim: opts.GroupsClaim,
groupsPrefix: opts.GroupsPrefix,
cancel: cancel,
} }
return
}
func (a *OIDCAuthenticator) client() (*oidc.Client, error) { now := opts.now
// Fast check to see if client has already been initialized. if now == nil {
if client := a.oidcClient.Load(); client != nil { now = time.Now
return client.(*oidc.Client), nil
} }
// Acquire lock, then recheck initialization. verifierConfig := &oidc.Config{
a.mu.Lock() ClientID: opts.ClientID,
defer a.mu.Unlock() SupportedSigningAlgs: supportedSigningAlgs,
if client := a.oidcClient.Load(); client != nil { Now: now,
return client.(*oidc.Client), nil
} }
// Try to initialize client. initVerifier(ctx, authenticator, verifierConfig)
providerConfig, err := oidc.FetchProviderConfig(a.httpClient, a.issuerURL) return authenticator, nil
if err != nil { }
glog.Errorf("oidc authenticator: failed to fetch provider discovery data: %v", err)
return nil, fmt.Errorf("fetch provider config: %v", err)
}
clientConfig := oidc.ClientConfig{ func hasCorrectIssuer(iss, tokenData string) bool {
HTTPClient: a.httpClient, parts := strings.Split(tokenData, ".")
Credentials: oidc.ClientCredentials{ID: a.trustedClientID}, if len(parts) != 3 {
ProviderConfig: providerConfig, return false
} }
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
client, err := oidc.NewClient(clientConfig)
if err != nil { if err != nil {
glog.Errorf("oidc authenticator: failed to create client: %v", err) return false
return nil, fmt.Errorf("create client: %v", err)
} }
claims := struct {
// SyncProviderConfig will start a goroutine to periodically synchronize the provider config. // WARNING: this JWT is not verified. Do not trust these claims.
// The synchronization interval is set by the expiration length of the config, and has a minimum Issuer string `json:"iss"`
// and maximum threshold. }{}
stop := client.SyncProviderConfig(a.issuerURL) if err := json.Unmarshal(payload, &claims); err != nil {
a.oidcClient.Store(client) return false
a.close = func() {
// This assumes the stop is an unbuffered channel.
// So instead of closing the channel, we send am empty struct here.
// This guarantees that when this function returns, there is no flying requests,
// because a send to an unbuffered channel happens after the receive from the channel.
stop <- struct{}{}
} }
return client, nil if claims.Issuer != iss {
return false
}
return true
} }
// AuthenticateToken decodes and verifies an ID Token using the OIDC client, if the verification succeeds, func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error) {
// then it will extract the user info from the JWT claims. if !hasCorrectIssuer(a.issuerURL, token) {
func (a *OIDCAuthenticator) AuthenticateToken(value string) (user.Info, bool, error) { return nil, false, nil
jwt, err := jose.ParseJWT(value)
if err != nil {
return nil, false, err
} }
client, err := a.client() ctx := context.Background()
if err != nil { verifier, ok := a.idTokenVerifier()
return nil, false, err if !ok {
} return nil, false, fmt.Errorf("oidc: authenticator not initialized")
if err := client.VerifyJWT(jwt); err != nil {
return nil, false, err
} }
claims, err := jwt.Claims()
idToken, err := verifier.Verify(ctx, token)
if err != nil { if err != nil {
return nil, false, err return nil, false, fmt.Errorf("oidc: verify token: %v", err)
} }
return a.parseTokenClaims(claims)
}
// parseTokenClaims maps a set of claims to a user. It performs basic validation such as var c claims
// ensuring the email is verified. if err := idToken.Claims(&c); err != nil {
func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, bool, error) { return nil, false, fmt.Errorf("oidc: parse claims: %v", err)
username, ok, err := claims.StringClaim(a.usernameClaim)
if err != nil {
return nil, false, err
} }
if !ok { var username string
return nil, false, fmt.Errorf("cannot find %q in JWT claims", a.usernameClaim) if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil {
return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err)
} }
if a.usernameClaim == "email" { if a.usernameClaim == "email" {
verified, ok := claims["email_verified"] // Check the email_verified claim to ensure the email is valid.
if !ok { // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
return nil, false, errors.New("'email_verified' claim not present") var emailVerified bool
} if err := c.unmarshalClaim("email_verified", &emailVerified); err != nil {
return nil, false, fmt.Errorf("oidc: parse 'email_verified' claim: %v", err)
emailVerified, ok := verified.(bool)
if !ok {
// OpenID Connect spec defines 'email_verified' as a boolean. For now, be a pain and error if
// it's a different type. If there are enough misbehaving providers we can relax this latter.
//
// See: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
return nil, false, fmt.Errorf("malformed claim 'email_verified', expected boolean got %T", verified)
} }
if !emailVerified { if !emailVerified {
return nil, false, errors.New("email not verified") return nil, false, fmt.Errorf("oidc: email not verified")
} }
} }
...@@ -275,21 +299,18 @@ func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, boo ...@@ -275,21 +299,18 @@ func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, boo
username = a.usernamePrefix + username username = a.usernamePrefix + username
} }
// TODO(yifan): Add UID, also populate the issuer to upper layer.
info := &user.DefaultInfo{Name: username} info := &user.DefaultInfo{Name: username}
if a.groupsClaim != "" { if a.groupsClaim != "" {
groups, found, err := claims.StringsClaim(a.groupsClaim) if _, ok := c[a.groupsClaim]; ok {
if err != nil { // Some admins want to use string claims like "role" as the group value.
// Groups type is present but is not an array of strings, try to decode as a string. // Allow the group claim to be a single string instead of an array.
group, _, err := claims.StringClaim(a.groupsClaim) //
if err != nil { // See: https://github.com/kubernetes/kubernetes/issues/33290
// Custom claim is present, but isn't an array of strings or a string. var groups stringOrArray
return nil, false, fmt.Errorf("custom group claim contains invalid type: %T", claims[a.groupsClaim]) if err := c.unmarshalClaim(a.groupsClaim, &groups); err != nil {
return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.groupsClaim, err)
} }
info.Groups = []string{group} info.Groups = []string(groups)
} else if found {
info.Groups = groups
} }
} }
...@@ -298,6 +319,31 @@ func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, boo ...@@ -298,6 +319,31 @@ func (a *OIDCAuthenticator) parseTokenClaims(claims jose.Claims) (user.Info, boo
info.Groups[i] = a.groupsPrefix + group info.Groups[i] = a.groupsPrefix + group
} }
} }
return info, true, nil return info, true, nil
} }
type stringOrArray []string
func (s *stringOrArray) UnmarshalJSON(b []byte) error {
var a []string
if err := json.Unmarshal(b, &a); err == nil {
*s = a
return nil
}
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = []string{str}
return nil
}
type claims map[string]json.RawMessage
func (c claims) unmarshalClaim(name string, v interface{}) error {
val, ok := c[name]
if !ok {
return fmt.Errorf("claim not present")
}
return json.Unmarshal([]byte(val), v)
}
...@@ -17,518 +17,754 @@ limitations under the License. ...@@ -17,518 +17,754 @@ limitations under the License.
package oidc package oidc
import ( import (
"os" "context"
"path" "crypto"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"reflect" "reflect"
"sort"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/coreos/go-oidc/jose" oidc "github.com/coreos/go-oidc"
"github.com/coreos/go-oidc/oidc" jose "gopkg.in/square/go-jose.v2"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
oidctesting "k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/testing"
) )
func generateToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}, iat, exp time.Time, emailVerified bool) string { // utilities for loading JOSE keys.
claims := oidc.NewClaims(iss, sub, aud, iat, exp)
claims.Add(usernameClaim, value)
if groups != nil && groupsClaim != "" {
claims.Add(groupsClaim, groups)
}
claims.Add("email_verified", emailVerified)
signer := op.PrivKey.Signer() func loadRSAKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey {
jwt, err := jose.NewSignedJWT(claims, signer) return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) {
if err != nil { key, err := x509.ParsePKCS1PrivateKey(b)
t.Fatalf("Cannot generate token: %v", err) if err != nil {
return "" return nil, err
} }
return jwt.Encode() return key.Public(), nil
})
} }
func generateTokenWithUnverifiedEmail(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, email string) string { func loadRSAPrivKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey {
return generateToken(t, op, iss, sub, aud, "email", email, "", nil, time.Now(), time.Now().Add(time.Hour), false) return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) {
return x509.ParsePKCS1PrivateKey(b)
})
} }
func generateGoodToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}) string { func loadECDSAKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey {
return generateToken(t, op, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now(), time.Now().Add(time.Hour), true) return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) {
key, err := x509.ParseECPrivateKey(b)
if err != nil {
return nil, err
}
return key.Public(), nil
})
} }
func generateMalformedToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}) string { func loadECDSAPrivKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm) *jose.JSONWebKey {
return generateToken(t, op, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now(), time.Now().Add(time.Hour), true) + "randombits" return loadKey(t, filepath, alg, func(b []byte) (interface{}, error) {
return x509.ParseECPrivateKey(b)
})
} }
func generateExpiredToken(t *testing.T, op *oidctesting.OIDCProvider, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups interface{}) string { func loadKey(t *testing.T, filepath string, alg jose.SignatureAlgorithm, unmarshal func([]byte) (interface{}, error)) *jose.JSONWebKey {
return generateToken(t, op, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now().Add(-2*time.Hour), time.Now().Add(-1*time.Hour), true) data, err := ioutil.ReadFile(filepath)
if err != nil {
t.Fatalf("load file: %v", err)
}
block, _ := pem.Decode(data)
if block == nil {
t.Fatalf("file contained no PEM encoded data: %s", filepath)
}
priv, err := unmarshal(block.Bytes)
if err != nil {
t.Fatalf("unmarshal key: %v", err)
}
key := &jose.JSONWebKey{Key: priv, Use: "sig", Algorithm: string(alg)}
thumbprint, err := key.Thumbprint(crypto.SHA256)
if err != nil {
t.Fatalf("computing thumbprint: %v", err)
}
key.KeyID = hex.EncodeToString(thumbprint)
return key
} }
func TestTLSConfig(t *testing.T) { // staticKeySet implements oidc.KeySet.
// Verify the cert/key pair works. type staticKeySet struct {
cert1 := path.Join(os.TempDir(), "oidc-cert-1") keys []*jose.JSONWebKey
key1 := path.Join(os.TempDir(), "oidc-key-1") }
cert2 := path.Join(os.TempDir(), "oidc-cert-2")
key2 := path.Join(os.TempDir(), "oidc-key-2")
defer os.Remove(cert1)
defer os.Remove(key1)
defer os.Remove(cert2)
defer os.Remove(key2)
oidctesting.GenerateSelfSignedCert(t, "127.0.0.1", cert1, key1)
oidctesting.GenerateSelfSignedCert(t, "127.0.0.1", cert2, key2)
tests := []struct {
testCase string
serverCertFile string
serverKeyFile string
trustedCertFile string
wantErr bool func (s *staticKeySet) VerifySignature(ctx context.Context, jwt string) (payload []byte, err error) {
}{ jws, err := jose.ParseSigned(jwt)
{ if err != nil {
testCase: "provider using untrusted custom cert", return nil, err
serverCertFile: cert1,
serverKeyFile: key1,
wantErr: true,
},
{
testCase: "provider using untrusted cert",
serverCertFile: cert1,
serverKeyFile: key1,
trustedCertFile: cert2,
wantErr: true,
},
{
testCase: "provider using trusted cert",
serverCertFile: cert1,
serverKeyFile: key1,
trustedCertFile: cert1,
wantErr: false,
},
} }
if len(jws.Signatures) == 0 {
return nil, fmt.Errorf("jwt contained no signatures")
}
kid := jws.Signatures[0].Header.KeyID
for _, tc := range tests { for _, key := range s.keys {
func() { if key.KeyID == kid {
op := oidctesting.NewOIDCProvider(t, "") return jws.Verify(key)
srv, err := op.ServeTLSWithKeyPair(tc.serverCertFile, tc.serverKeyFile) }
if err != nil {
t.Errorf("%s: %v", tc.testCase, err)
return
}
defer srv.Close()
issuer := srv.URL
clientID := "client-foo"
options := OIDCOptions{
IssuerURL: srv.URL,
ClientID: clientID,
CAFile: tc.trustedCertFile,
UsernameClaim: "email",
GroupsClaim: "groups",
}
authenticator, err := New(options)
if err != nil {
t.Errorf("%s: failed to initialize authenticator: %v", tc.testCase, err)
return
}
defer authenticator.Close()
email := "user-1@example.com"
groups := []string{"group1", "group2"}
sort.Strings(groups)
token := generateGoodToken(t, op, issuer, "user-1", clientID, "email", email, "groups", groups)
// Because this authenticator behaves differently for subsequent requests, run these
// tests multiple times (but expect the same result).
for i := 1; i < 4; i++ {
user, ok, err := authenticator.AuthenticateToken(token)
if err != nil {
if !tc.wantErr {
t.Errorf("%s (req #%d): failed to authenticate token: %v", tc.testCase, i, err)
}
continue
}
if tc.wantErr {
t.Errorf("%s (req #%d): expected error authenticating", tc.testCase, i)
continue
}
if !ok {
t.Errorf("%s (req #%d): did not get user or error", tc.testCase, i)
continue
}
if gotUsername := user.GetName(); email != gotUsername {
t.Errorf("%s (req #%d): GetName() expected=%q got %q", tc.testCase, i, email, gotUsername)
}
gotGroups := user.GetGroups()
sort.Strings(gotGroups)
if !reflect.DeepEqual(gotGroups, groups) {
t.Errorf("%s (req #%d): GetGroups() expected=%q got %q", tc.testCase, i, groups, gotGroups)
}
}
}()
} }
return nil, fmt.Errorf("no keys matches jwk keyid")
} }
func TestOIDCAuthentication(t *testing.T) { var (
cert := path.Join(os.TempDir(), "oidc-cert") expired, _ = time.Parse(time.RFC3339Nano, "2009-11-10T22:00:00Z")
key := path.Join(os.TempDir(), "oidc-key") now, _ = time.Parse(time.RFC3339Nano, "2009-11-10T23:00:00Z")
valid, _ = time.Parse(time.RFC3339Nano, "2009-11-11T00:00:00Z")
)
defer os.Remove(cert) type claimsTest struct {
defer os.Remove(key) name string
options Options
now time.Time
signingKey *jose.JSONWebKey
pubKeys []*jose.JSONWebKey
claims string
want *user.DefaultInfo
wantSkip bool
wantErr bool
wantInitErr bool
}
oidctesting.GenerateSelfSignedCert(t, "127.0.0.1", cert, key) func (c *claimsTest) run(t *testing.T) {
a, err := newAuthenticator(c.options, func(ctx context.Context, a *Authenticator, config *oidc.Config) {
// Set the verifier to use the public key set instead of reading
// from a remote.
a.setVerifier(oidc.NewVerifier(
c.options.IssuerURL,
&staticKeySet{keys: c.pubKeys},
config,
))
})
if err != nil {
if !c.wantInitErr {
t.Fatalf("initialize authenticator: %v", err)
}
return
}
if c.wantInitErr {
t.Fatalf("wanted initialization error")
}
// Ensure all tests pass when the issuer is not at a base URL. // Sign and serialize the claims in a JWT.
for _, path := range []string{"", "/path/with/trailing/slash/"} { signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.SignatureAlgorithm(c.signingKey.Algorithm),
Key: c.signingKey,
}, nil)
if err != nil {
t.Fatalf("initialize signer: %v", err)
}
jws, err := signer.Sign([]byte(c.claims))
if err != nil {
t.Fatalf("sign claims: %v", err)
}
token, err := jws.CompactSerialize()
if err != nil {
t.Fatalf("serialize token: %v", err)
}
// Create a TLS server and a client. got, ok, err := a.AuthenticateToken(token)
op := oidctesting.NewOIDCProvider(t, path) if err != nil {
srv, err := op.ServeTLSWithKeyPair(cert, key) if !c.wantErr {
if err != nil { t.Fatalf("authenticate token: %v", err)
t.Fatalf("Cannot start server: %v", err)
}
defer srv.Close()
tests := []struct {
userClaim string
groupsClaim string
token string
userInfo user.Info
verified bool
err string
}{
{
"sub",
"",
generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil),
&user.DefaultInfo{Name: "user-foo"},
true,
"",
},
{
// Use user defined claim (email here).
"email",
"",
generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "", nil),
&user.DefaultInfo{Name: "foo@example.com"},
true,
"",
},
{
// Use user defined claim (email here).
"email",
"",
generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", []string{"group1", "group2"}),
&user.DefaultInfo{Name: "foo@example.com"},
true,
"",
},
{
// Use user defined claim (email here).
"email",
"groups",
generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", []string{"group1", "group2"}),
&user.DefaultInfo{Name: "foo@example.com", Groups: []string{"group1", "group2"}},
true,
"",
},
{
// Group claim is a string rather than an array. Map that string to a single group.
"email",
"groups",
generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", "group1"),
&user.DefaultInfo{Name: "foo@example.com", Groups: []string{"group1"}},
true,
"",
},
{
// Group claim is not a string or array of strings. Throw out this as invalid.
"email",
"groups",
generateGoodToken(t, op, srv.URL, "client-foo", "client-foo", "email", "foo@example.com", "groups", 1),
nil,
false,
"custom group claim contains invalid type: float64",
},
{
// Email not verified
"email",
"",
generateTokenWithUnverifiedEmail(t, op, srv.URL, "client-foo", "client-foo", "foo@example.com"),
nil,
false,
"email not verified",
},
{
"sub",
"",
generateMalformedToken(t, op, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil),
nil,
false,
"oidc: unable to verify JWT signature: no matching keys",
},
{
// Invalid 'aud'.
"sub",
"",
generateGoodToken(t, op, srv.URL, "client-foo", "client-bar", "sub", "user-foo", "", nil),
nil,
false,
"oidc: JWT claims invalid: invalid claims, 'aud' claim and 'client_id' do not match",
},
{
// Invalid issuer.
"sub",
"",
generateGoodToken(t, op, "http://foo-bar.com", "client-foo", "client-foo", "sub", "user-foo", "", nil),
nil,
false,
"oidc: JWT claims invalid: invalid claim value: 'iss'.",
},
{
"sub",
"",
generateExpiredToken(t, op, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil),
nil,
false,
"oidc: JWT claims invalid: token is expired",
},
} }
return
}
for i, tt := range tests { if c.wantErr {
client, err := New(OIDCOptions{srv.URL, "client-foo", cert, tt.userClaim, "", tt.groupsClaim, ""}) t.Fatalf("expected error authenticating token")
if err != nil { }
t.Errorf("Unexpected error: %v", err) if !ok {
continue if !c.wantSkip {
} // We don't have any cases where we return (nil, false, nil)
t.Fatalf("no error but token not authenticated")
user, result, err := client.AuthenticateToken(tt.token)
if tt.err != "" {
if !strings.HasPrefix(err.Error(), tt.err) {
t.Errorf("#%d: Expecting: %v..., but got: %v", i, tt.err, err)
}
} else {
if err != nil {
t.Errorf("#%d: Unexpected error: %v", i, err)
}
}
if !reflect.DeepEqual(tt.verified, result) {
t.Errorf("#%d: Expecting: %v, but got: %v", i, tt.verified, result)
}
if !reflect.DeepEqual(tt.userInfo, user) {
t.Errorf("#%d: Expecting: %v, but got: %v", i, tt.userInfo, user)
}
client.Close()
} }
return
}
if c.wantSkip {
t.Fatalf("expected authenticator to skip token")
}
gotUser := got.(*user.DefaultInfo)
if !reflect.DeepEqual(gotUser, c.want) {
t.Fatalf("wanted user=%#v, got=%#v", c.want, gotUser)
} }
} }
func TestParseTokenClaims(t *testing.T) { func TestToken(t *testing.T) {
tests := []struct { tests := []claimsTest{
name string
// Note this is missing a lot of configuration options because
// parseTokenClaim doesn't handle:
//
// - 'iss' claim matching issuer URL
// - 'exp' claim having not expired
// - 'sub' claim matching a trusted client id
//
// That logic has coverage in other tests.
issuerURL string
usernameClaim string
usernamePrefix string
groupsClaim string
groupsPrefix string
claims jose.Claims
wantUser *user.DefaultInfo
wantErr bool
}{
{ {
name: "email username", name: "token",
issuerURL: "https://foo.com/", options: Options{
usernameClaim: "email", IssuerURL: "https://auth.example.com",
claims: jose.Claims{ ClientID: "my-client",
"email": "jane.doe@example.com", UsernameClaim: "username",
"email_verified": true, now: func() time.Time { return now },
}, },
wantUser: &user.DefaultInfo{ signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
Name: "jane.doe@example.com", pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
}, },
}, },
{ {
name: "no email_verified claim", name: "no-username",
issuerURL: "https://foo.com/", options: Options{
usernameClaim: "email", IssuerURL: "https://auth.example.com",
claims: jose.Claims{ ClientID: "my-client",
"email": "jane.doe@example.com", UsernameClaim: "username",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
}, },
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"exp": %d
}`, valid.Unix()),
wantErr: true, wantErr: true,
}, },
{ {
name: "email unverified", name: "email",
issuerURL: "https://foo.com/", options: Options{
usernameClaim: "email", IssuerURL: "https://auth.example.com",
claims: jose.Claims{ ClientID: "my-client",
"email": "jane.doe@example.com", UsernameClaim: "email",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"email": "jane@example.com",
"email_verified": true,
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane@example.com",
},
},
{
name: "email-not-verified",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "email",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"email": "jane@example.com",
"email_verified": false, "email_verified": false,
"exp": %d
}`, valid.Unix()),
wantErr: true,
},
{
// If "email_verified" isn't present, assume false
name: "no-email-verified-claim",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "email",
now: func() time.Time { return now },
}, },
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"email": "jane@example.com",
"exp": %d
}`, valid.Unix()),
wantErr: true, wantErr: true,
}, },
{ {
name: "non-email user claim", name: "groups",
issuerURL: "https://foo.com/", options: Options{
usernameClaim: "name", IssuerURL: "https://auth.example.com",
claims: jose.Claims{ ClientID: "my-client",
"name": "janedoe", UsernameClaim: "username",
GroupsClaim: "groups",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
}, },
wantUser: &user.DefaultInfo{ claims: fmt.Sprintf(`{
Name: "janedoe", "iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": ["team1", "team2"],
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
Groups: []string{"team1", "team2"},
}, },
}, },
{ {
name: "groups claim", // Groups should be able to be a single string, not just a slice.
issuerURL: "https://foo.com/", name: "group-string-claim",
usernameClaim: "name", options: Options{
groupsClaim: "groups", IssuerURL: "https://auth.example.com",
claims: jose.Claims{ ClientID: "my-client",
"name": "janedoe", UsernameClaim: "username",
"groups": []string{"foo", "bar"}, GroupsClaim: "groups",
}, now: func() time.Time { return now },
wantUser: &user.DefaultInfo{ },
Name: "janedoe", signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
Groups: []string{"foo", "bar"}, pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": "team1",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
Groups: []string{"team1"},
}, },
}, },
{ {
name: "groups claim string", // if the groups claim isn't provided, this shouldn't error out
issuerURL: "https://foo.com/", name: "no-groups-claim",
usernameClaim: "name", options: Options{
groupsClaim: "groups", IssuerURL: "https://auth.example.com",
claims: jose.Claims{ ClientID: "my-client",
"name": "janedoe", UsernameClaim: "username",
"groups": "foo", GroupsClaim: "groups",
}, now: func() time.Time { return now },
wantUser: &user.DefaultInfo{ },
Name: "janedoe", signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
Groups: []string{"foo"}, pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
}, },
}, },
{ {
name: "username prefix", name: "invalid-groups-claim",
issuerURL: "https://foo.com/", options: Options{
usernameClaim: "name", IssuerURL: "https://auth.example.com",
groupsClaim: "groups", ClientID: "my-client",
usernamePrefix: "oidc:", UsernameClaim: "username",
claims: jose.Claims{ GroupsClaim: "groups",
"name": "janedoe", now: func() time.Time { return now },
"groups": []string{"foo", "bar"}, },
}, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
wantUser: &user.DefaultInfo{ pubKeys: []*jose.JSONWebKey{
Name: "oidc:janedoe", loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
Groups: []string{"foo", "bar"},
}, },
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": 42,
"exp": %d
}`, valid.Unix()),
wantErr: true,
}, },
{ {
name: "username prefix with email", name: "invalid-signature",
issuerURL: "https://foo.com/", options: Options{
usernameClaim: "email", IssuerURL: "https://auth.example.com",
groupsClaim: "groups", ClientID: "my-client",
usernamePrefix: "oidc:", UsernameClaim: "username",
claims: jose.Claims{ now: func() time.Time { return now },
"email": "jane.doe@example.com", },
"email_verified": true, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
"groups": []string{"foo", "bar"}, pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_2.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
wantErr: true,
},
{
name: "expired",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, expired.Unix()),
wantErr: true,
},
{
name: "invalid-aud",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "not-my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
wantErr: true,
},
{
// ID tokens may contain multiple audiences:
// https://openid.net/specs/openid-connect-core-1_0.html#IDToken
name: "multiple-audiences",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": ["not-my-client", "my-client"],
"azp": "not-my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
},
},
{
name: "invalid-issuer",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
wantSkip: true,
},
{
name: "username-prefix",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
UsernamePrefix: "oidc:",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "oidc:jane",
},
},
{
name: "groups-prefix",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
UsernamePrefix: "oidc:",
GroupsClaim: "groups",
GroupsPrefix: "groups:",
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"groups": ["team1", "team2"],
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "oidc:jane",
Groups: []string{"groups:team1", "groups:team2"},
},
},
{
name: "invalid-signing-alg",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
now: func() time.Time { return now },
},
// Correct key but invalid signature algorithm "PS256"
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.PS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
wantErr: true,
},
{
name: "ps256",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
SupportedSigningAlgs: []string{"PS256"},
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.PS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.PS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
},
},
{
name: "es512",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
SupportedSigningAlgs: []string{"ES512"},
now: func() time.Time { return now },
},
signingKey: loadECDSAPrivKey(t, "testdata/ecdsa_2.pem", jose.ES512),
pubKeys: []*jose.JSONWebKey{
loadECDSAKey(t, "testdata/ecdsa_1.pem", jose.ES512),
loadECDSAKey(t, "testdata/ecdsa_2.pem", jose.ES512),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": "my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
},
},
{
name: "not-https",
options: Options{
IssuerURL: "http://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
now: func() time.Time { return now },
},
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
wantInitErr: true,
},
{
name: "no-username-claim",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
now: func() time.Time { return now },
},
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
wantInitErr: true,
},
{
name: "invalid-sig-alg",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
SupportedSigningAlgs: []string{"HS256"},
now: func() time.Time { return now },
},
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
}, },
wantUser: &user.DefaultInfo{ wantInitErr: true,
Name: "oidc:jane.doe@example.com", },
Groups: []string{"foo", "bar"}, }
for _, test := range tests {
t.Run(test.name, test.run)
}
}
func TestUnmarshalClaimError(t *testing.T) {
// Ensure error strings returned by unmarshaling claims don't include the claim.
const token = "96bb299a-02e9-11e8-8673-54ee7553240e"
payload := fmt.Sprintf(`{
"token": "%s"
}`, token)
var c claims
if err := json.Unmarshal([]byte(payload), &c); err != nil {
t.Fatal(err)
}
var n int
err := c.unmarshalClaim("token", &n)
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), token) {
t.Fatalf("unmarshal error included token")
}
}
func TestUnmarshalClaim(t *testing.T) {
tests := []struct {
name string
claims string
do func(claims) (interface{}, error)
want interface{}
wantErr bool
}{
{
name: "string claim",
claims: `{"aud":"foo"}`,
do: func(c claims) (interface{}, error) {
var s string
err := c.unmarshalClaim("aud", &s)
return s, err
},
want: "foo",
},
{
name: "mismatched types",
claims: `{"aud":"foo"}`,
do: func(c claims) (interface{}, error) {
var n int
err := c.unmarshalClaim("aud", &n)
return n, err
}, },
wantErr: true,
}, },
{ {
name: "groups prefix", name: "bool claim",
issuerURL: "https://foo.com/", claims: `{"email":"foo@coreos.com","email_verified":true}`,
usernameClaim: "name", do: func(c claims) (interface{}, error) {
groupsClaim: "groups", var verified bool
groupsPrefix: "oidc:", err := c.unmarshalClaim("email_verified", &verified)
claims: jose.Claims{ return verified, err
"name": "janedoe",
"groups": []string{"foo", "bar"},
},
wantUser: &user.DefaultInfo{
Name: "janedoe",
Groups: []string{"oidc:foo", "oidc:bar"},
}, },
want: true,
}, },
{ {
name: "username and groups prefix", name: "strings claim",
issuerURL: "https://foo.com/", claims: `{"groups":["a","b","c"]}`,
usernameClaim: "name", do: func(c claims) (interface{}, error) {
groupsClaim: "groups", var groups []string
usernamePrefix: "oidc-user:", err := c.unmarshalClaim("groups", &groups)
groupsPrefix: "oidc:", return groups, err
claims: jose.Claims{
"name": "janedoe",
"groups": []string{"foo", "bar"},
},
wantUser: &user.DefaultInfo{
Name: "oidc-user:janedoe",
Groups: []string{"oidc:foo", "oidc:bar"},
}, },
want: []string{"a", "b", "c"},
}, },
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
o := OIDCAuthenticator{ var c claims
issuerURL: test.issuerURL, if err := json.Unmarshal([]byte(test.claims), &c); err != nil {
usernameClaim: test.usernameClaim, t.Fatal(err)
usernamePrefix: test.usernamePrefix,
groupsClaim: test.groupsClaim,
groupsPrefix: test.groupsPrefix,
} }
u, ok, err := o.parseTokenClaims(test.claims) got, err := test.do(c)
if err != nil { if err != nil {
if !test.wantErr { if test.wantErr {
t.Errorf("failed to authenticate user: %v", err) return
} }
return t.Fatalf("unexpected error: %v", err)
} }
if test.wantErr { if test.wantErr {
t.Fatalf("expected authentication to fail") t.Fatalf("expected error")
} }
if !ok { if !reflect.DeepEqual(got, test.want) {
// We don't have any cases today when the claims can return t.Errorf("wanted=%#v, got=%#v", test.want, got)
// no error with an unauthenticated signal.
//
// In the future we might.
t.Fatalf("user wasn't authenticated")
}
got := &user.DefaultInfo{
Name: u.GetName(),
UID: u.GetUID(),
Groups: u.GetGroups(),
Extra: u.GetExtra(),
}
if !reflect.DeepEqual(got, test.wantUser) {
t.Errorf("wanted user=%#v, got=%#v", test.wantUser, got)
} }
}) })
} }
......
#!/bin/bash -e
# Copyright 2018 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
rm *.pem
for N in `seq 1 3`; do
ssh-keygen -t rsa -b 2048 -f rsa_$N.pem -N ''
done
for N in `seq 1 3`; do
ssh-keygen -t ecdsa -b 521 -f ecdsa_$N.pem -N ''
done
rm *.pub
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment