Skip to content

Commit

Permalink
plugin/pkg/auth/authenticator/token/oidc: get groups from custom claim
Browse files Browse the repository at this point in the history
  • Loading branch information
Eric Chiang committed Feb 12, 2016
1 parent bd67b8a commit 92d37d5
Show file tree
Hide file tree
Showing 8 changed files with 80 additions and 31 deletions.
2 changes: 2 additions & 0 deletions cmd/kube-apiserver/app/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type APIServer struct {
OIDCClientID string
OIDCIssuerURL string
OIDCUsernameClaim string
OIDCGroupsClaim string
RuntimeConfig util.ConfigurationMap
SSHKeyfile string
SSHUser string
Expand Down Expand Up @@ -165,6 +166,7 @@ func (s *APIServer) AddFlags(fs *pflag.FlagSet) {
fs.StringVar(&s.OIDCUsernameClaim, "oidc-username-claim", "sub", ""+
"The OpenID claim to use as the user name. Note that claims other than the default ('sub') is not "+
"guaranteed to be unique and immutable. This flag is experimental, please see the authentication documentation for further details.")
fs.StringVar(&s.OIDCGroupsClaim, "oidc-groups-claim", "", "If provided, the name of a custom OpenID Connect claim for specifying user groups. The claim value is expected to be an array of strings. This flag is experimental, please see the authentication documentation for further details.")
fs.StringVar(&s.ServiceAccountKeyFile, "service-account-key-file", s.ServiceAccountKeyFile, "File containing PEM-encoded x509 RSA private or public key, used to verify ServiceAccount tokens. If unspecified, --tls-private-key-file is used.")
fs.BoolVar(&s.ServiceAccountLookup, "service-account-lookup", s.ServiceAccountLookup, "If true, validate ServiceAccount tokens exist in etcd as part of authentication.")
fs.StringVar(&s.KeystoneURL, "experimental-keystone-url", s.KeystoneURL, "If passed, activates the keystone authentication plugin")
Expand Down
1 change: 1 addition & 0 deletions cmd/kube-apiserver/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ func Run(s *options.APIServer) error {
OIDCClientID: s.OIDCClientID,
OIDCCAFile: s.OIDCCAFile,
OIDCUsernameClaim: s.OIDCUsernameClaim,
OIDCGroupsClaim: s.OIDCGroupsClaim,
ServiceAccountKeyFile: s.ServiceAccountKeyFile,
ServiceAccountLookup: s.ServiceAccountLookup,
ServiceAccountTokenGetter: serviceAccountGetter,
Expand Down
2 changes: 2 additions & 0 deletions docs/admin/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ to the OpenID provider.
- `--oidc-username-claim` (optional, experimental) specifies which OpenID claim to use as the user name. By default, `sub`
will be used, which should be unique and immutable under the issuer's domain. Cluster administrator can
choose other claims such as `email` to use as the user name, but the uniqueness and immutability is not guaranteed.
- `--oidc-groups-claim` (optional, experimental) the name of a custom OpenID Connect claim for specifying user groups. The claim
value is expected to be an array of strings.

Please note that this flag is still experimental until we settle more on how to handle the mapping of the OpenID user to the Kubernetes user. Thus further changes are possible.

Expand Down
3 changes: 2 additions & 1 deletion docs/admin/kube-apiserver.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ kube-apiserver
--min-request-timeout=1800: An optional field indicating the minimum number of seconds a handler must keep a request open before timing it out. Currently only honored by the watch request handler, which picks a randomized value above this number as the connection timeout, to spread out load.
--oidc-ca-file="": If set, the OpenID server's certificate will be verified by one of the authorities in the oidc-ca-file, otherwise the host's root CA set will be used
--oidc-client-id="": The client ID for the OpenID Connect client, must be set if oidc-issuer-url is set
--oidc-groups-claim="": If provided, the name of a custom OpenID Connect claim for specifying user groups. The claim value is expected to be an array of strings. This flag is experimental, please see the authentication documentation for further details.
--oidc-issuer-url="": The URL of the OpenID issuer, only HTTPS scheme will be accepted. If set, it will be used to verify the OIDC JSON Web Token (JWT)
--oidc-username-claim="sub": The OpenID claim to use as the user name. Note that claims other than the default ('sub') is not guaranteed to be unique and immutable. This flag is experimental, please see the authentication documentation for further details.
--profiling[=true]: Enable profiling via web interface host:port/debug/pprof/
Expand All @@ -109,7 +110,7 @@ kube-apiserver
--watch-cache-sizes=[]: List of watch cache sizes for every resource (pods, nodes, etc.), comma separated. The individual override format: resource#size, where size is a number. It takes effect when watch-cache is enabled.
```

###### Auto generated by spf13/cobra on 5-Feb-2016
###### Auto generated by spf13/cobra on 10-Feb-2016


<!-- BEGIN MUNGE: GENERATED_ANALYTICS -->
Expand Down
1 change: 1 addition & 0 deletions hack/verify-flags/known-flags.txt
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ num-nodes
oidc-ca-file
oidc-client-id
oidc-issuer-url
oidc-groups-claim
oidc-username-claim
only-idl
oom-score-adj
Expand Down
7 changes: 4 additions & 3 deletions pkg/apiserver/authenticator/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type AuthenticatorConfig struct {
OIDCClientID string
OIDCCAFile string
OIDCUsernameClaim string
OIDCGroupsClaim string
ServiceAccountKeyFile string
ServiceAccountLookup bool
ServiceAccountTokenGetter serviceaccount.ServiceAccountTokenGetter
Expand Down Expand Up @@ -76,7 +77,7 @@ func New(config AuthenticatorConfig) (authenticator.Request, error) {
}

if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 {
oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim)
oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim, config.OIDCGroupsClaim)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -136,8 +137,8 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Request,
}

// newAuthenticatorFromOIDCIssuerURL returns an authenticator.Request or an error.
func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim string) (authenticator.Request, error) {
tokenAuthenticator, err := oidc.New(issuerURL, clientID, caFile, usernameClaim)
func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, groupsClaim string) (authenticator.Request, error) {
tokenAuthenticator, err := oidc.New(issuerURL, clientID, caFile, usernameClaim, groupsClaim)
if err != nil {
return nil, err
}
Expand Down
21 changes: 17 additions & 4 deletions plugin/pkg/auth/authenticator/token/oidc/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ type OIDCAuthenticator struct {
clientConfig oidc.ClientConfig
client *oidc.Client
usernameClaim string
groupsClaim string
stopSyncProvider chan struct{}
}

// New creates a new OpenID Connect client with the given issuerURL and clientID.
// NOTE(yifan): For now we assume the server provides the "jwks_uri" so we don't
// need to manager the key sets by ourselves.
func New(issuerURL, clientID, caFile, usernameClaim string) (*OIDCAuthenticator, error) {
func New(issuerURL, clientID, caFile, usernameClaim, groupsClaim string) (*OIDCAuthenticator, error) {
var cfg oidc.ProviderConfig
var err error
var roots *x509.CertPool
Expand Down Expand Up @@ -117,7 +118,7 @@ func New(issuerURL, clientID, caFile, usernameClaim string) (*OIDCAuthenticator,
// and maximum threshold.
stop := client.SyncProviderConfig(issuerURL)

return &OIDCAuthenticator{ccfg, client, usernameClaim, stop}, nil
return &OIDCAuthenticator{ccfg, client, usernameClaim, groupsClaim, stop}, nil
}

// AuthenticateToken decodes and verifies a JWT using the OIDC client, if the verification succeeds,
Expand Down Expand Up @@ -155,8 +156,20 @@ func (a *OIDCAuthenticator) AuthenticateToken(value string) (user.Info, bool, er
username = fmt.Sprintf("%s#%s", a.clientConfig.ProviderConfig.Issuer, claim)
}

// TODO(yifan): Add UID and Group, also populate the issuer to upper layer.
return &user.DefaultInfo{Name: username}, true, nil
// TODO(yifan): Add UID, also populate the issuer to upper layer.
info := &user.DefaultInfo{Name: username}

if a.groupsClaim != "" {
groups, found, err := claims.StringsClaim(a.groupsClaim)
if err != nil {
// Custom claim is present, but isn't an array of strings.
return nil, false, fmt.Errorf("custom group claim contains invalid object: %v", err)
}
if found {
info.Groups = groups
}
}
return info, true, nil
}

// Close closes the OIDC authenticator, this will close the provider sync goroutine.
Expand Down
74 changes: 51 additions & 23 deletions plugin/pkg/auth/authenticator/token/oidc/oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,13 @@ func (op *oidcProvider) handleKeys(w http.ResponseWriter, req *http.Request) {
w.Write(b)
}

func (op *oidcProvider) generateToken(t *testing.T, iss, sub, aud string, usernameClaim, value string, iat, exp time.Time) string {
func (op *oidcProvider) generateToken(t *testing.T, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups []string, iat, exp time.Time) string {
signer := op.privKey.Signer()
claims := oidc.NewClaims(iss, sub, aud, iat, exp)
claims.Add(usernameClaim, value)
if groups != nil && groupsClaim != "" {
claims.Add(groupsClaim, groups)
}

jwt, err := jose.NewSignedJWT(claims, signer)
if err != nil {
Expand All @@ -112,16 +115,16 @@ func (op *oidcProvider) generateToken(t *testing.T, iss, sub, aud string, userna
return jwt.Encode()
}

func (op *oidcProvider) generateGoodToken(t *testing.T, iss, sub, aud string, usernameClaim, value string) string {
return op.generateToken(t, iss, sub, aud, usernameClaim, value, time.Now(), time.Now().Add(time.Hour))
func (op *oidcProvider) generateGoodToken(t *testing.T, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups []string) string {
return op.generateToken(t, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now(), time.Now().Add(time.Hour))
}

func (op *oidcProvider) generateMalformedToken(t *testing.T, iss, sub, aud string, usernameClaim, value string) string {
return op.generateToken(t, iss, sub, aud, usernameClaim, value, time.Now(), time.Now().Add(time.Hour)) + "randombits"
func (op *oidcProvider) generateMalformedToken(t *testing.T, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups []string) string {
return op.generateToken(t, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now(), time.Now().Add(time.Hour)) + "randombits"
}

func (op *oidcProvider) generateExpiredToken(t *testing.T, iss, sub, aud string, usernameClaim, value string) string {
return op.generateToken(t, iss, sub, aud, usernameClaim, value, time.Now().Add(-2*time.Hour), time.Now().Add(-1*time.Hour))
func (op *oidcProvider) generateExpiredToken(t *testing.T, iss, sub, aud string, usernameClaim, value, groupsClaim string, groups []string) string {
return op.generateToken(t, iss, sub, aud, usernameClaim, value, groupsClaim, groups, time.Now().Add(-2*time.Hour), time.Now().Add(-1*time.Hour))
}

// generateSelfSignedCert generates a self-signed cert/key pairs and writes to the certPath/keyPath.
Expand Down Expand Up @@ -192,7 +195,7 @@ func TestOIDCDiscoveryTimeout(t *testing.T) {
retryBackoff = time.Second
expectErr := fmt.Errorf("failed to fetch provider config after 3 retries")

_, err := New("https://foo/bar", "client-foo", "", "sub")
_, err := New("https://foo/bar", "client-foo", "", "sub", "")
if !reflect.DeepEqual(err, expectErr) {
t.Errorf("Expecting %v, but got %v", expectErr, err)
}
Expand Down Expand Up @@ -224,7 +227,7 @@ func TestOIDCDiscoveryNoKeyEndpoint(t *testing.T) {
Issuer: srv.URL,
}

_, err = New(srv.URL, "client-foo", cert, "sub")
_, err = New(srv.URL, "client-foo", cert, "sub", "")
if !reflect.DeepEqual(err, expectErr) {
t.Errorf("Expecting %v, but got %v", expectErr, err)
}
Expand All @@ -247,7 +250,7 @@ func TestOIDCDiscoverySecureConnection(t *testing.T) {

expectErr := fmt.Errorf("'oidc-issuer-url' (%q) has invalid scheme (%q), require 'https'", srv.URL, "http")

_, err := New(srv.URL, "client-foo", "", "sub")
_, err := New(srv.URL, "client-foo", "", "sub", "")
if !reflect.DeepEqual(err, expectErr) {
t.Errorf("Expecting %v, but got %v", expectErr, err)
}
Expand Down Expand Up @@ -282,7 +285,7 @@ func TestOIDCDiscoverySecureConnection(t *testing.T) {
}

// Create a client using cert2, should fail.
_, err = New(tlsSrv.URL, "client-foo", cert2, "sub")
_, err = New(tlsSrv.URL, "client-foo", cert2, "sub", "")
if err == nil {
t.Fatalf("Expecting error, but got nothing")
}
Expand Down Expand Up @@ -317,61 +320,86 @@ func TestOIDCAuthentication(t *testing.T) {
}

tests := []struct {
userClaim string
token string
userInfo user.Info
verified bool
err string
userClaim string
groupsClaim string
token string
userInfo user.Info
verified bool
err string
}{
{
"sub",
op.generateGoodToken(t, srv.URL, "client-foo", "client-foo", "sub", "user-foo"),
"",
op.generateGoodToken(t, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil),
&user.DefaultInfo{Name: fmt.Sprintf("%s#%s", srv.URL, "user-foo")},
true,
"",
},
{
// Use user defined claim (email here).
"email",
op.generateGoodToken(t, srv.URL, "client-foo", "client-foo", "email", "foo@example.com"),
"",
op.generateGoodToken(t, 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",
"",
op.generateGoodToken(t, 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",
op.generateGoodToken(t, 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,
"",
},
{
"sub",
op.generateMalformedToken(t, srv.URL, "client-foo", "client-foo", "sub", "user-foo"),
"",
op.generateMalformedToken(t, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil),
nil,
false,
"malformed JWS, unable to decode signature",
},
{
// Invalid 'aud'.
"sub",
op.generateGoodToken(t, srv.URL, "client-foo", "client-bar", "sub", "user-foo"),
"",
op.generateGoodToken(t, 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",
op.generateGoodToken(t, "http://foo-bar.com", "client-foo", "client-foo", "sub", "user-foo"),
"",
op.generateGoodToken(t, "http://foo-bar.com", "client-foo", "client-foo", "sub", "user-foo", "", nil),
nil,
false,
"oidc: JWT claims invalid: invalid claim value: 'iss'.",
},
{
"sub",
op.generateExpiredToken(t, srv.URL, "client-foo", "client-foo", "sub", "user-foo"),
"",
op.generateExpiredToken(t, srv.URL, "client-foo", "client-foo", "sub", "user-foo", "", nil),
nil,
false,
"oidc: JWT claims invalid: token is expired",
},
}

for i, tt := range tests {
client, err := New(srv.URL, "client-foo", cert, tt.userClaim)
client, err := New(srv.URL, "client-foo", cert, tt.userClaim, tt.groupsClaim)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
Expand Down

0 comments on commit 92d37d5

Please sign in to comment.