oidc authentication: Required claims support

pull/8/head
rithu john 2018-04-03 10:54:09 -07:00
parent 31d22870b2
commit dd433b595f
6 changed files with 174 additions and 14 deletions

View File

@ -59,6 +59,7 @@ type AuthenticatorConfig struct {
OIDCGroupsClaim string OIDCGroupsClaim string
OIDCGroupsPrefix string OIDCGroupsPrefix string
OIDCSigningAlgs []string OIDCSigningAlgs []string
OIDCRequiredClaims map[string]string
ServiceAccountKeyFiles []string ServiceAccountKeyFiles []string
ServiceAccountLookup bool ServiceAccountLookup bool
ServiceAccountIssuer string ServiceAccountIssuer string
@ -159,7 +160,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, config.OIDCSigningAlgs) oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim, config.OIDCUsernamePrefix, config.OIDCGroupsClaim, config.OIDCGroupsPrefix, config.OIDCSigningAlgs, config.OIDCRequiredClaims)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -241,7 +242,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, signingAlgs []string) (authenticator.Token, error) { func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, usernamePrefix, groupsClaim, groupsPrefix string, signingAlgs []string, requiredClaims map[string]string) (authenticator.Token, error) {
const noUsernamePrefix = "-" const noUsernamePrefix = "-"
if usernamePrefix == "" && usernameClaim != "email" { if usernamePrefix == "" && usernameClaim != "email" {
@ -266,6 +267,7 @@ func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClai
GroupsClaim: groupsClaim, GroupsClaim: groupsClaim,
GroupsPrefix: groupsPrefix, GroupsPrefix: groupsPrefix,
SupportedSigningAlgs: signingAlgs, SupportedSigningAlgs: signingAlgs,
RequiredClaims: requiredClaims,
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -28,6 +28,7 @@ import (
"k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/sets"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
genericoptions "k8s.io/apiserver/pkg/server/options" genericoptions "k8s.io/apiserver/pkg/server/options"
"k8s.io/apiserver/pkg/util/flag"
"k8s.io/kubernetes/pkg/kubeapiserver/authenticator" "k8s.io/kubernetes/pkg/kubeapiserver/authenticator"
authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes" authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes"
) )
@ -64,6 +65,7 @@ type OIDCAuthenticationOptions struct {
GroupsClaim string GroupsClaim string
GroupsPrefix string GroupsPrefix string
SigningAlgs []string SigningAlgs []string
RequiredClaims map[string]string
} }
type PasswordFileAuthenticationOptions struct { type PasswordFileAuthenticationOptions struct {
@ -223,6 +225,11 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) {
"Comma-separated list of allowed JOSE asymmetric signing algorithms. JWTs with a "+ "Comma-separated list of allowed JOSE asymmetric signing algorithms. JWTs with a "+
"'alg' header value not in this list will be rejected. "+ "'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.") "Values are defined by RFC 7518 https://tools.ietf.org/html/rfc7518#section-3.1.")
fs.Var(flag.NewMapStringStringNoSplit(&s.OIDC.RequiredClaims), "oidc-required-claim", ""+
"A key=value pair that describes a required claim in the ID Token. "+
"If set, the claim is verified to be present in the ID Token with a matching value. "+
"Repeat this flag to specify multiple claims.")
} }
if s.PasswordFile != nil { if s.PasswordFile != nil {
@ -298,6 +305,7 @@ func (s *BuiltInAuthenticationOptions) ToAuthenticationConfig() authenticator.Au
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 ret.OIDCSigningAlgs = s.OIDC.SigningAlgs
ret.OIDCRequiredClaims = s.OIDC.RequiredClaims
} }
if s.PasswordFile != nil { if s.PasswordFile != nil {

View File

@ -23,11 +23,14 @@ import (
) )
// MapStringString can be set from the command line with the format `--flag "string=string"`. // MapStringString can be set from the command line with the format `--flag "string=string"`.
// Multiple comma-separated key-value pairs in a single invocation are supported. For example: `--flag "a=foo,b=bar"`. // Multiple flag invocations are supported. For example: `--flag "a=foo" --flag "b=bar"`. If this is desired
// Multiple flag invocations are supported. For example: `--flag "a=foo" --flag "b=bar"`. // to be the only type invocation `NoSplit` should be set to true.
// Multiple comma-separated key-value pairs in a single invocation are supported if `NoSplit`
// is set to false. For example: `--flag "a=foo,b=bar"`.
type MapStringString struct { type MapStringString struct {
Map *map[string]string Map *map[string]string
initialized bool initialized bool
NoSplit bool
} }
// NewMapStringString takes a pointer to a map[string]string and returns the // NewMapStringString takes a pointer to a map[string]string and returns the
@ -36,6 +39,15 @@ func NewMapStringString(m *map[string]string) *MapStringString {
return &MapStringString{Map: m} return &MapStringString{Map: m}
} }
// NewMapStringString takes a pointer to a map[string]string and sets `NoSplit`
// value to `true` and returns the MapStringString flag parsing shim for that map
func NewMapStringStringNoSplit(m *map[string]string) *MapStringString {
return &MapStringString{
Map: m,
NoSplit: true,
}
}
// String implements github.com/spf13/pflag.Value // String implements github.com/spf13/pflag.Value
func (m *MapStringString) String() string { func (m *MapStringString) String() string {
pairs := []string{} pairs := []string{}
@ -56,6 +68,9 @@ func (m *MapStringString) Set(value string) error {
*m.Map = make(map[string]string) *m.Map = make(map[string]string)
m.initialized = true m.initialized = true
} }
// account for comma-separated key-value pairs in a single invocation
if !m.NoSplit {
for _, s := range strings.Split(value, ",") { for _, s := range strings.Split(value, ",") {
if len(s) == 0 { if len(s) == 0 {
continue continue
@ -71,6 +86,18 @@ func (m *MapStringString) Set(value string) error {
return nil return nil
} }
// account for only one key-value pair in a single invocation
arr := strings.SplitN(value, "=", 2)
if len(arr) != 2 {
return fmt.Errorf("malformed pair, expect string=string")
}
k := strings.TrimSpace(arr[0])
v := strings.TrimSpace(arr[1])
(*m.Map)[k] = v
return nil
}
// Type implements github.com/spf13/pflag.Value // Type implements github.com/spf13/pflag.Value
func (*MapStringString) Type() string { func (*MapStringString) Type() string {
return "mapStringString" return "mapStringString"

View File

@ -58,6 +58,7 @@ func TestSetMapStringString(t *testing.T) {
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{}, Map: &map[string]string{},
NoSplit: false,
}, ""}, }, ""},
// make sure we still allocate for "initialized" maps where Map was initially set to a nil map // make sure we still allocate for "initialized" maps where Map was initially set to a nil map
{"allocates map if currently nil", []string{""}, {"allocates map if currently nil", []string{""},
@ -65,6 +66,7 @@ func TestSetMapStringString(t *testing.T) {
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{}, Map: &map[string]string{},
NoSplit: false,
}, ""}, }, ""},
// for most cases, we just reuse nilMap, which should be allocated by Set, and is reset before each test case // for most cases, we just reuse nilMap, which should be allocated by Set, and is reset before each test case
{"empty", []string{""}, {"empty", []string{""},
@ -72,36 +74,56 @@ func TestSetMapStringString(t *testing.T) {
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{}, Map: &map[string]string{},
NoSplit: false,
}, ""}, }, ""},
{"one key", []string{"one=foo"}, {"one key", []string{"one=foo"},
NewMapStringString(&nilMap), NewMapStringString(&nilMap),
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{"one": "foo"}, Map: &map[string]string{"one": "foo"},
NoSplit: false,
}, ""}, }, ""},
{"two keys", []string{"one=foo,two=bar"}, {"two keys", []string{"one=foo,two=bar"},
NewMapStringString(&nilMap), NewMapStringString(&nilMap),
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{"one": "foo", "two": "bar"}, Map: &map[string]string{"one": "foo", "two": "bar"},
NoSplit: false,
}, ""},
{"one key, multi flag invocation only", []string{"one=foo,bar"},
NewMapStringStringNoSplit(&nilMap),
&MapStringString{
initialized: true,
Map: &map[string]string{"one": "foo,bar"},
NoSplit: true,
}, ""},
{"two keys, multi flag invocation only", []string{"one=foo,bar", "two=foo,bar"},
NewMapStringStringNoSplit(&nilMap),
&MapStringString{
initialized: true,
Map: &map[string]string{"one": "foo,bar", "two": "foo,bar"},
NoSplit: true,
}, ""}, }, ""},
{"two keys, multiple Set invocations", []string{"one=foo", "two=bar"}, {"two keys, multiple Set invocations", []string{"one=foo", "two=bar"},
NewMapStringString(&nilMap), NewMapStringString(&nilMap),
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{"one": "foo", "two": "bar"}, Map: &map[string]string{"one": "foo", "two": "bar"},
NoSplit: false,
}, ""}, }, ""},
{"two keys with space", []string{"one=foo, two=bar"}, {"two keys with space", []string{"one=foo, two=bar"},
NewMapStringString(&nilMap), NewMapStringString(&nilMap),
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{"one": "foo", "two": "bar"}, Map: &map[string]string{"one": "foo", "two": "bar"},
NoSplit: false,
}, ""}, }, ""},
{"empty key", []string{"=foo"}, {"empty key", []string{"=foo"},
NewMapStringString(&nilMap), NewMapStringString(&nilMap),
&MapStringString{ &MapStringString{
initialized: true, initialized: true,
Map: &map[string]string{"": "foo"}, Map: &map[string]string{"": "foo"},
NoSplit: false,
}, ""}, }, ""},
{"missing value", []string{"one"}, {"missing value", []string{"one"},
NewMapStringString(&nilMap), NewMapStringString(&nilMap),

View File

@ -98,6 +98,10 @@ type Options struct {
// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
SupportedSigningAlgs []string SupportedSigningAlgs []string
// RequiredClaims, if specified, causes the OIDCAuthenticator to verify that all the
// required claims key value pairs are present in the ID Token.
RequiredClaims map[string]string
// now is used for testing. It defaults to time.Now. // now is used for testing. It defaults to time.Now.
now func() time.Time now func() time.Time
} }
@ -109,6 +113,7 @@ type Authenticator struct {
usernamePrefix string usernamePrefix string
groupsClaim string groupsClaim string
groupsPrefix string groupsPrefix string
requiredClaims map[string]string
// Contains an *oidc.IDTokenVerifier. Do not access directly use the // Contains an *oidc.IDTokenVerifier. Do not access directly use the
// idTokenVerifier method. // idTokenVerifier method.
@ -218,6 +223,7 @@ func newAuthenticator(opts Options, initVerifier func(ctx context.Context, a *Au
usernamePrefix: opts.UsernamePrefix, usernamePrefix: opts.UsernamePrefix,
groupsClaim: opts.GroupsClaim, groupsClaim: opts.GroupsClaim,
groupsPrefix: opts.GroupsPrefix, groupsPrefix: opts.GroupsPrefix,
requiredClaims: opts.RequiredClaims,
cancel: cancel, cancel: cancel,
} }
@ -323,6 +329,23 @@ func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error)
info.Groups[i] = a.groupsPrefix + group info.Groups[i] = a.groupsPrefix + group
} }
} }
// check to ensure all required claims are present in the ID token and have matching values.
for claim, value := range a.requiredClaims {
if !c.hasClaim(claim) {
return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim)
}
// NOTE: Only string values are supported as valid required claim values.
var claimValue string
if err := c.unmarshalClaim(claim, &claimValue); err != nil {
return nil, false, fmt.Errorf("oidc: parse claim %s: %v", claim, err)
}
if claimValue != value {
return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value)
}
}
return info, true, nil return info, true, nil
} }

View File

@ -428,6 +428,84 @@ func TestToken(t *testing.T) {
}`, valid.Unix()), }`, valid.Unix()),
wantErr: true, wantErr: true,
}, },
{
name: "required-claim",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
GroupsClaim: "groups",
RequiredClaims: map[string]string{
"hd": "example.com",
"sub": "test",
},
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",
"hd": "example.com",
"sub": "test",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
},
},
{
name: "no-required-claim",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
GroupsClaim: "groups",
RequiredClaims: map[string]string{
"hd": "example.com",
},
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()),
wantErr: true,
},
{
name: "invalid-required-claim",
options: Options{
IssuerURL: "https://auth.example.com",
ClientID: "my-client",
UsernameClaim: "username",
GroupsClaim: "groups",
RequiredClaims: map[string]string{
"hd": "example.com",
},
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",
"hd": "example.org",
"exp": %d
}`, valid.Unix()),
wantErr: true,
},
{ {
name: "invalid-signature", name: "invalid-signature",
options: Options{ options: Options{