diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/BUILD b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/BUILD index e62aba93a26..f54e37229dd 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/BUILD +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/BUILD @@ -8,11 +8,13 @@ load( go_test( name = "go_default_test", + size = "small", srcs = ["oidc_test.go"], data = glob(["testdata/**"]), embed = [":go_default_library"], deps = [ "//vendor/github.com/coreos/go-oidc:go_default_library", + "//vendor/github.com/golang/glog:go_default_library", "//vendor/gopkg.in/square/go-jose.v2:go_default_library", "//vendor/k8s.io/apiserver/pkg/authentication/user:go_default_library", ], diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go index 94c5d8b220e..38cd5bd5dba 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go @@ -34,9 +34,11 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "net/http" "net/url" "strings" + "sync" "sync/atomic" "time" @@ -48,6 +50,12 @@ import ( certutil "k8s.io/client-go/util/cert" ) +var ( + // synchronizeTokenIDVerifierForTest should be set to true to force a + // wait until the token ID verifiers are ready. + synchronizeTokenIDVerifierForTest = false +) + type Options struct { // 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 @@ -106,6 +114,70 @@ type Options struct { now func() time.Time } +// initVerifier creates a new ID token verifier for the given configuration and issuer URL. On success, calls setVerifier with the +// resulting verifier. +func initVerifier(ctx context.Context, config *oidc.Config, iss string) (*oidc.IDTokenVerifier, error) { + provider, err := oidc.NewProvider(ctx, iss) + if err != nil { + return nil, fmt.Errorf("init verifier failed: %v", err) + } + return provider.Verifier(config), nil +} + +// asyncIDTokenVerifier is an ID token verifier that allows async initialization +// of the issuer check. Must be passed by reference as it wraps sync.Mutex. +type asyncIDTokenVerifier struct { + m sync.Mutex + + // v is the ID token verifier initialized asynchronously. It remains nil + // up until it is eventually initialized. + // Guarded by m + v *oidc.IDTokenVerifier +} + +// newAsyncIDTokenVerifier creates a new asynchronous token verifier. The +// verifier is available immediately, but may remain uninitialized for some time +// after creation. +func newAsyncIDTokenVerifier(ctx context.Context, c *oidc.Config, iss string) *asyncIDTokenVerifier { + t := &asyncIDTokenVerifier{} + + sync := make(chan struct{}) + // Polls indefinitely in an attempt to initialize the distributed claims + // verifier, or until context canceled. + initFn := func() (done bool, err error) { + glog.V(4).Infof("oidc authenticator: attempting init: iss=%v", iss) + v, err := initVerifier(ctx, c, iss) + if err != nil { + glog.Errorf("oidc authenticator: async token verifier for issuer: %q: %v", iss, err) + return false, nil + } + t.m.Lock() + defer t.m.Unlock() + t.v = v + close(sync) + return true, nil + } + + go func() { + if done, _ := initFn(); !done { + go wait.PollUntil(time.Second*10, initFn, ctx.Done()) + } + }() + + if synchronizeTokenIDVerifierForTest { + <-sync + } + + return t +} + +// verifier returns the underlying ID token verifier, or nil if one is not yet initialized. +func (a *asyncIDTokenVerifier) verifier() *oidc.IDTokenVerifier { + a.m.Lock() + defer a.m.Unlock() + return a.v +} + type Authenticator struct { issuerURL string @@ -120,6 +192,9 @@ type Authenticator struct { verifier atomic.Value cancel context.CancelFunc + + // resolver is used to resolve distributed claims. + resolver *claimResolver } func (a *Authenticator) setVerifier(v *oidc.IDTokenVerifier) { @@ -217,16 +292,6 @@ func newAuthenticator(opts Options, initVerifier func(ctx context.Context, a *Au ctx, cancel := context.WithCancel(context.Background()) ctx = oidc.ClientContext(ctx, client) - authenticator := &Authenticator{ - issuerURL: opts.IssuerURL, - usernameClaim: opts.UsernameClaim, - usernamePrefix: opts.UsernamePrefix, - groupsClaim: opts.GroupsClaim, - groupsPrefix: opts.GroupsPrefix, - requiredClaims: opts.RequiredClaims, - cancel: cancel, - } - now := opts.now if now == nil { now = time.Now @@ -238,43 +303,237 @@ func newAuthenticator(opts Options, initVerifier func(ctx context.Context, a *Au Now: now, } + var resolver *claimResolver + if opts.GroupsClaim != "" { + resolver = newClaimResolver(opts.GroupsClaim, client, verifierConfig) + } + + authenticator := &Authenticator{ + issuerURL: opts.IssuerURL, + usernameClaim: opts.UsernameClaim, + usernamePrefix: opts.UsernamePrefix, + groupsClaim: opts.GroupsClaim, + groupsPrefix: opts.GroupsPrefix, + requiredClaims: opts.RequiredClaims, + cancel: cancel, + resolver: resolver, + } + initVerifier(ctx, authenticator, verifierConfig) return authenticator, nil } -func hasCorrectIssuer(iss, tokenData string) bool { - parts := strings.Split(tokenData, ".") +// untrustedIssuer extracts an untrusted "iss" claim from the given JWT token, +// or returns an error if the token can not be parsed. Since the JWT is not +// verified, the returned issuer should not be trusted. +func untrustedIssuer(token string) (string, error) { + parts := strings.Split(token, ".") if len(parts) != 3 { - return false + return "", fmt.Errorf("malformed token") } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - return false + return "", fmt.Errorf("error decoding token: %v", err) } claims := struct { // WARNING: this JWT is not verified. Do not trust these claims. Issuer string `json:"iss"` }{} if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("while unmarshaling token: %v", err) + } + return claims.Issuer, nil +} + +func hasCorrectIssuer(iss, tokenData string) bool { + uiss, err := untrustedIssuer(tokenData) + if err != nil { return false } - if claims.Issuer != iss { + if uiss != iss { return false } return true } +// endpoint represents an OIDC distributed claims endpoint. +type endpoint struct { + // URL to use to request the distributed claim. This URL is expected to be + // prefixed by one of the known issuer URLs. + URL string `json:"endpoint,omitempty"` + // AccessToken is the bearer token to use for access. If empty, it is + // not used. Access token is optional per the OIDC distributed claims + // specification. + // See: http://openid.net/specs/openid-connect-core-1_0.html#DistributedExample + AccessToken string `json:"access_token,omitempty"` + // JWT is the container for aggregated claims. Not supported at the moment. + // See: http://openid.net/specs/openid-connect-core-1_0.html#AggregatedExample + JWT string `json:"JWT,omitempty"` +} + +// claimResolver expands distributed claims by calling respective claim source +// endpoints. +type claimResolver struct { + // claim is the distributed claim that may be resolved. + claim string + + // client is the to use for resolving distributed claims + client *http.Client + + // config is the OIDC configuration used for resolving distributed claims. + config *oidc.Config + + // verifierPerIssuer contains, for each issuer, the appropriate verifier to use + // for this claim. It is assumed that there will be very few entries in + // this map. + // Guarded by m. + verifierPerIssuer map[string]*asyncIDTokenVerifier + + m sync.Mutex +} + +// newClaimResolver creates a new resolver for distributed claims. +func newClaimResolver(claim string, client *http.Client, config *oidc.Config) *claimResolver { + return &claimResolver{claim: claim, client: client, config: config, verifierPerIssuer: map[string]*asyncIDTokenVerifier{}} +} + +// Verifier returns either the verifier for the specified issuer, or error. +func (r *claimResolver) Verifier(iss string) (*oidc.IDTokenVerifier, error) { + r.m.Lock() + av := r.verifierPerIssuer[iss] + if av == nil { + // This lazy init should normally be very quick. + // TODO: Make this context cancelable. + ctx := oidc.ClientContext(context.Background(), r.client) + av = newAsyncIDTokenVerifier(ctx, r.config, iss) + r.verifierPerIssuer[iss] = av + } + r.m.Unlock() + + v := av.verifier() + if v == nil { + return nil, fmt.Errorf("verifier not initialized for issuer: %q", iss) + } + return v, nil +} + +// expand extracts the distributed claims from claim names and claim sources. +// The extracted claim value is pulled up into the supplied claims. +// +// Distributed claims are of the form as seen below, and are defined in the +// OIDC Connect Core 1.0, section 5.6.2. +// See: https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims +// +// { +// ... (other normal claims)... +// "_claim_names": { +// "groups": "src1" +// }, +// "_claim_sources": { +// "src1": { +// "endpoint": "https://www.example.com", +// "access_token": "f005ba11" +// }, +// }, +// } +func (r *claimResolver) expand(c claims) error { + const ( + // The claim containing a map of endpoint references per claim. + // OIDC Connect Core 1.0, section 5.6.2. + claimNamesKey = "_claim_names" + // The claim containing endpoint specifications. + // OIDC Connect Core 1.0, section 5.6.2. + claimSourcesKey = "_claim_sources" + ) + + _, ok := c[r.claim] + if ok { + // There already is a normal claim, skip resolving. + return nil + } + names, ok := c[claimNamesKey] + if !ok { + // No _claim_names, no keys to look up. + return nil + } + + claimToSource := map[string]string{} + if err := json.Unmarshal([]byte(names), &claimToSource); err != nil { + return fmt.Errorf("oidc: error parsing distributed claim names: %v", err) + } + + rawSources, ok := c[claimSourcesKey] + if !ok { + // Having _claim_names claim, but no _claim_sources is not an expected + // state. + return fmt.Errorf("oidc: no claim sources") + } + + var sources map[string]endpoint + if err := json.Unmarshal([]byte(rawSources), &sources); err != nil { + // The claims sources claim is malformed, this is not an expected state. + return fmt.Errorf("oidc: could not parse claim sources: %v", err) + } + + src, ok := claimToSource[r.claim] + if !ok { + // No distributed claim present. + return nil + } + ep, ok := sources[src] + if !ok { + return fmt.Errorf("id token _claim_names contained a source %s missing in _claims_sources", src) + } + if ep.URL == "" { + // This is maybe an aggregated claim (ep.JWT != ""). + return nil + } + return r.resolve(ep, c) +} + +// resolve requests distributed claims from all endpoints passed in, +// and inserts the lookup results into allClaims. +func (r *claimResolver) resolve(endpoint endpoint, allClaims claims) error { + // TODO: cache resolved claims. + jwt, err := getClaimJWT(r.client, endpoint.URL, endpoint.AccessToken) + if err != nil { + return fmt.Errorf("while getting distributed claim %q: %v", r.claim, err) + } + untrustedIss, err := untrustedIssuer(jwt) + if err != nil { + return fmt.Errorf("getting untrusted issuer from endpoint %v failed for claim %q: %v", endpoint.URL, r.claim, err) + } + v, err := r.Verifier(untrustedIss) + if err != nil { + return fmt.Errorf("verifying untrusted issuer %v failed: %v", untrustedIss, err) + } + t, err := v.Verify(context.Background(), jwt) + if err != nil { + return fmt.Errorf("verify distributed claim token: %v", err) + } + var distClaims claims + if err := t.Claims(&distClaims); err != nil { + return fmt.Errorf("could not parse distributed claims for claim %v: %v", r.claim, err) + } + value, ok := distClaims[r.claim] + if !ok { + return fmt.Errorf("jwt returned by distributed claim endpoint %s did not contain claim: %v", endpoint, r.claim) + } + allClaims[r.claim] = value + return nil +} + func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error) { if !hasCorrectIssuer(a.issuerURL, token) { return nil, false, nil } - ctx := context.Background() verifier, ok := a.idTokenVerifier() if !ok { return nil, false, fmt.Errorf("oidc: authenticator not initialized") } + ctx := context.Background() idToken, err := verifier.Verify(ctx, token) if err != nil { return nil, false, fmt.Errorf("oidc: verify token: %v", err) @@ -284,6 +543,12 @@ func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error) if err := idToken.Claims(&c); err != nil { return nil, false, fmt.Errorf("oidc: parse claims: %v", err) } + if a.resolver != nil { + if err := a.resolver.expand(c); err != nil { + return nil, false, fmt.Errorf("oidc: could not expand distributed claims: %v", err) + } + } + var username string if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil { return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err) @@ -349,6 +614,39 @@ func (a *Authenticator) AuthenticateToken(token string) (user.Info, bool, error) return info, true, nil } +// getClaimJWT gets a distributed claim JWT from url, using the supplied access +// token as bearer token. If the access token is "", the authorization header +// will not be set. +// TODO: Allow passing in JSON hints to the IDP. +func getClaimJWT(client *http.Client, url, accessToken string) (string, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // TODO: Allow passing request body with configurable information. + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("while calling %v: %v", url, err) + } + if accessToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", accessToken)) + } + req = req.WithContext(ctx) + response, err := client.Do(req) + if err != nil { + return "", err + } + // Report non-OK status code as an error. + if response.StatusCode < http.StatusOK || response.StatusCode > http.StatusIMUsed { + return "", fmt.Errorf("error while getting distributed claim JWT: %v", response.Status) + } + defer response.Body.Close() + responseBytes, err := ioutil.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("could not decode distributed claim response") + } + return string(responseBytes), nil +} + type stringOrArray []string func (s *stringOrArray) UnmarshalJSON(b []byte) error { diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go index b74c45ab123..53d849bdb01 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go @@ -17,6 +17,7 @@ limitations under the License. package oidc import ( + "bytes" "context" "crypto" "crypto/x509" @@ -25,12 +26,17 @@ import ( "encoding/pem" "fmt" "io/ioutil" + "net/http" + "net/http/httptest" + "os" "reflect" "strings" "testing" + "text/template" "time" oidc "github.com/coreos/go-oidc" + "github.com/golang/glog" jose "gopkg.in/square/go-jose.v2" "k8s.io/apiserver/pkg/authentication/user" ) @@ -122,22 +128,148 @@ var ( ) type claimsTest struct { - name string - options Options - now time.Time - signingKey *jose.JSONWebKey - pubKeys []*jose.JSONWebKey - claims string - want *user.DefaultInfo - wantSkip bool - wantErr bool - wantInitErr bool + name string + options Options + now time.Time + signingKey *jose.JSONWebKey + pubKeys []*jose.JSONWebKey + claims string + want *user.DefaultInfo + wantSkip bool + wantErr bool + wantInitErr bool + claimToResponseMap map[string]string + openIDConfig string +} + +// Replace formats the contents of v into the provided template. +func replace(tmpl string, v interface{}) string { + t := template.Must(template.New("test").Parse(tmpl)) + buf := bytes.NewBuffer(nil) + t.Execute(buf, &v) + ret := buf.String() + glog.V(4).Infof("Replaced: %v into: %v", tmpl, ret) + return ret +} + +// newClaimServer returns a new test HTTPS server, which is rigged to return +// OIDC responses to requests that resolve distributed claims. signer is the +// signer used for the served JWT tokens. claimToResponseMap is a map of +// responses that the server will return for each claim it is given. +func newClaimServer(t *testing.T, keys jose.JSONWebKeySet, signer jose.Signer, claimToResponseMap map[string]string, openIDConfig *string) *httptest.Server { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + glog.V(5).Infof("request: %+v", *r) + switch r.URL.Path { + case "/.testing/keys": + w.Header().Set("Content-Type", "application/json") + keyBytes, err := json.Marshal(keys) + if err != nil { + t.Fatalf("unexpected error while marshaling keys: %v", err) + } + glog.V(5).Infof("%v: returning: %+v", r.URL, string(keyBytes)) + w.Write(keyBytes) + + case "/.well-known/openid-configuration": + w.Header().Set("Content-Type", "application/json") + glog.V(5).Infof("%v: returning: %+v", r.URL, *openIDConfig) + w.Write([]byte(*openIDConfig)) + // These claims are tested in the unit tests. + case "/groups": + fallthrough + case "/rabbits": + if claimToResponseMap == nil { + t.Errorf("no claims specified in response") + } + claim := r.URL.Path[1:] // "/groups" -> "groups" + expectedAuth := fmt.Sprintf("Bearer %v_token", claim) + auth := r.Header.Get("Authorization") + if auth != expectedAuth { + t.Errorf("bearer token expected: %q, was %q", expectedAuth, auth) + } + jws, err := signer.Sign([]byte(claimToResponseMap[claim])) + if err != nil { + t.Errorf("while signing response token: %v", err) + } + token, err := jws.CompactSerialize() + if err != nil { + t.Errorf("while serializing response token: %v", err) + } + w.Write([]byte(token)) + default: + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "unexpected URL: %v", r.URL) + } + })) + glog.V(4).Infof("Serving OIDC at: %v", ts.URL) + return ts +} + +// writeTempCert writes out the supplied certificate into a temporary file in +// PEM-encoded format. Returns the name of the temporary file used. The caller +// is responsible for cleaning the file up. +func writeTempCert(t *testing.T, cert []byte) string { + tempFile, err := ioutil.TempFile("", "ca.crt") + if err != nil { + t.Fatalf("could not open temp file: %v", err) + } + block := &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert, + } + if err := pem.Encode(tempFile, block); err != nil { + t.Fatalf("could not write to temp file %v: %v", tempFile.Name(), err) + } + tempFile.Close() + return tempFile.Name() +} + +func toKeySet(keys []*jose.JSONWebKey) jose.JSONWebKeySet { + ret := jose.JSONWebKeySet{} + for _, k := range keys { + ret.Keys = append(ret.Keys, *k) + } + return ret } func (c *claimsTest) run(t *testing.T) { + var ( + signer jose.Signer + err error + ) + if c.signingKey != nil { + // Initialize the signer only in the tests that make use of it. We can + // not defer this initialization because the test server uses it too. + 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) + } + } + // The HTTPS server used for requesting distributed groups claims. + ts := newClaimServer(t, toKeySet(c.pubKeys), signer, c.claimToResponseMap, &c.openIDConfig) + defer ts.Close() + + // Make the certificate of the helper server available to the authenticator + // by writing its root CA certificate into a temporary file. + tempFileName := writeTempCert(t, ts.TLS.Certificates[0].Certificate[0]) + defer os.Remove(tempFileName) + c.options.CAFile = tempFileName + + // Allow claims to refer to the serving URL of the test server. For this, + // substitute all references to {{.URL}} in appropriate places. + v := struct{ URL string }{URL: ts.URL} + c.claims = replace(c.claims, &v) + c.openIDConfig = replace(c.openIDConfig, &v) + c.options.IssuerURL = replace(c.options.IssuerURL, &v) + for claim, response := range c.claimToResponseMap { + c.claimToResponseMap[claim] = replace(response, &v) + } + + // Initialize the authenticator. 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. + // 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}, @@ -155,13 +287,6 @@ func (c *claimsTest) run(t *testing.T) { } // Sign and serialize the claims in a JWT. - 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) @@ -200,6 +325,7 @@ func (c *claimsTest) run(t *testing.T) { } func TestToken(t *testing.T) { + synchronizeTokenIDVerifierForTest = true tests := []claimsTest{ { name: "token", @@ -356,6 +482,353 @@ func TestToken(t *testing.T) { Groups: []string{"team1", "team2"}, }, }, + { + name: "groups-distributed", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + }, + }, + { + name: "groups-distributed-malformed-claim-names", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "nonexistent-claim-source" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + wantErr: true, + }, + { + name: "groups-distributed-malformed-names-and-sources", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + wantErr: true, + }, + { + name: "groups-distributed-malformed-distributed-claim", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + // Doesn't contain the "groups" claim as it promises. + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + wantErr: true, + }, + { + name: "groups-distributed-unusual-name", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + UsernameClaim: "username", + GroupsClaim: "rabbits", + 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": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "rabbits": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/rabbits", + "access_token": "rabbits_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + "rabbits": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "rabbits": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1", "team2"}, + }, + }, + { + name: "groups-distributed-wrong-audience", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + // Note mismatching "aud" + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "your-client", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + // "aud" was "your-client", not "my-client" + wantErr: true, + }, + { + name: "groups-distributed-wrong-audience", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + // Note expired timestamp. + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "groups": ["team1", "team2"], + "exp": %d + }`, expired.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + // The distributed token is expired. + wantErr: true, + }, + { + // Specs are unclear about this behavior. We adopt a behavior where + // normal claim wins over a distributed claim by the same name. + name: "groups-distributed-normal-claim-wins", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "groups": "team1", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "groups": ["team2"], + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + want: &user.DefaultInfo{ + Name: "jane", + // "team1" is from the normal "groups" claim. + Groups: []string{"team1"}, + }, + }, { // Groups should be able to be a single string, not just a slice. name: "group-string-claim", @@ -382,6 +855,83 @@ func TestToken(t *testing.T) { Groups: []string{"team1"}, }, }, + { + // Groups should be able to be a single string, not just a slice. + name: "group-string-claim-distributed", + options: Options{ + IssuerURL: "{{.URL}}", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "groups": "team1", + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + want: &user.DefaultInfo{ + Name: "jane", + Groups: []string{"team1"}, + }, + }, + { + name: "group-string-claim-aggregated-not-supported", + options: Options{ + IssuerURL: "https://auth.example.com", + ClientID: "my-client", + 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), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "JWT": "some.jwt.token" + } + }, + "exp": %d + }`, valid.Unix()), + want: &user.DefaultInfo{ + Name: "jane", + }, + }, { // if the groups claim isn't provided, this shouldn't error out name: "no-groups-claim", @@ -661,6 +1211,53 @@ func TestToken(t *testing.T) { Groups: []string{"groups:team1", "groups:team2"}, }, }, + { + name: "groups-prefix-distributed", + options: Options{ + IssuerURL: "{{.URL}}", + 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": "{{.URL}}", + "aud": "my-client", + "username": "jane", + "_claim_names": { + "groups": "src1" + }, + "_claim_sources": { + "src1": { + "endpoint": "{{.URL}}/groups", + "access_token": "groups_token" + } + }, + "exp": %d + }`, valid.Unix()), + claimToResponseMap: map[string]string{ + "groups": fmt.Sprintf(`{ + "iss": "{{.URL}}", + "aud": "my-client", + "groups": ["team1", "team2"], + "exp": %d + }`, valid.Unix()), + }, + openIDConfig: `{ + "issuer": "{{.URL}}", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + want: &user.DefaultInfo{ + Name: "oidc:jane", + Groups: []string{"groups:team1", "groups:team2"}, + }, + }, { name: "invalid-signing-alg", options: Options{