mirror of https://github.com/k3s-io/k3s
oidc authentication: Required claims support
parent
31d22870b2
commit
dd433b595f
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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{
|
||||||
|
|
Loading…
Reference in New Issue