Merge pull request #63213 from filmil/oidc-dist-claims

Automatic merge from submit-queue. If you want to cherry-pick this change to another branch, please follow the instructions <a href="https://github.com/kubernetes/community/blob/master/contributors/devel/cherry-picks.md">here</a>.

Implements OIDC distributed claims.

Next step to enable this feature is to enable claim caching.

A distributed claim allows the OIDC provider to delegate a claim to a
separate URL.  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

Example claim:

```
{
  ... (other normal claims)...
  "_claim_names": {
    "groups": "src1"
  },
  "_claim_sources": {
    "src1": {
      "endpoint": "https://www.example.com",
      "access_token": "f005ba11"
    },
  },
}
```

Example response to a followup request to https://www.example.com is a
JWT-encoded claim token:

```
{
  "iss": "https://www.example.com",
  "aud": "my-client",
  "groups": ["team1", "team2"],
  "exp": 9876543210
}
```

Apart from the indirection, the distributed claim behaves exactly
the same as a standard claim.  For Kubernetes, this means that the
token must be verified using the same approach as for the original OIDC
token.  This requires the presence of "iss", "aud" and "exp" claims in
addition to "groups".

All existing OIDC options (e.g. groups prefix) apply.

Any claim can be made distributed, even though the "groups" claim is
the primary use case.

Allows groups to be a single string due to
https://github.com/kubernetes/kubernetes/issues/33290, even though
OIDC defines "groups" claim to be an array of strings. So, this will
be parsed correctly:

```
{
  "iss": "https://www.example.com",
  "aud": "my-client",
  "groups": "team1",
  "exp": 9876543210
}
```

Expects that distributed claims endpoints return JWT, per OIDC specs.

In case both a standard and a distributed claim with the same name
exist, standard claim wins.  The specs seem undecided about the correct
approach here.

Distributed claims are resolved serially.  This could be parallelized
for performance if needed.

Aggregated claims are silently skipped.  Support could be added if
needed.



**What this PR does / why we need it**: Makes it possible to retrieve many group memberships by offloading to a dedicated backend for groups resolution.

**Which issue(s) this PR fixes** *(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*:
Fixes #62920

**Special notes for your reviewer**:
There are a few TODOs that seem better handled in separate commits.

**Release note**:

```release-note
Lays groundwork for OIDC distributed claims handling in the apiserver authentication token checker.

A distributed claim allows the OIDC provider to delegate a claim to a
separate URL.  Distributed claims are of the form as seen below, and are
defined in the OIDC Connect Core 1.0, section 5.6.2.

For details, see: 
http://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims
```
This commit is contained in:
Kubernetes Submit Queue 2018-05-02 20:41:51 -07:00 committed by GitHub
commit ab9f64afa9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 932 additions and 35 deletions

View File

@ -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",
],

View File

@ -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 {

View File

@ -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{