Merge pull request #1473 from alindeman/add-user-endpoint

Add UserInfo endpoint
This commit is contained in:
Stephan Renatus 2019-07-02 09:26:26 +02:00 committed by GitHub
commit 8b4dbb9fe7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 551 additions and 377 deletions

2
go.mod
View file

@ -6,7 +6,7 @@ require (
github.com/boltdb/bolt v1.3.1 // indirect
github.com/cockroachdb/cmux v0.0.0-20170110192607-30d10be49292 // indirect
github.com/coreos/etcd v3.2.9+incompatible
github.com/coreos/go-oidc v0.0.0-20170307191026-be73733bb8cc
github.com/coreos/go-oidc v2.0.0+incompatible
github.com/coreos/go-semver v0.2.0 // indirect
github.com/coreos/go-systemd v0.0.0-20181031085051-9002847aa142 // indirect
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect

4
go.sum
View file

@ -8,8 +8,8 @@ github.com/cockroachdb/cmux v0.0.0-20170110192607-30d10be49292 h1:dzj1/xcivGjNPw
github.com/cockroachdb/cmux v0.0.0-20170110192607-30d10be49292/go.mod h1:qRiX68mZX1lGBkTWyp3CLcenw9I94W2dLeRvMzcn9N4=
github.com/coreos/etcd v3.2.9+incompatible h1:3TbjfK5+aSRLTU/KgBC1xlgA2dn2ddYQngRqX6HFwlQ=
github.com/coreos/etcd v3.2.9+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-oidc v0.0.0-20170307191026-be73733bb8cc h1:9yuvA19Q5WFkLwJcMDoYm8m89ilzqZ5zEHqdvU+Zbds=
github.com/coreos/go-oidc v0.0.0-20170307191026-be73733bb8cc/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-oidc v2.0.0+incompatible h1:+RStIopZ8wooMx+Vs5Bt8zMXxV1ABl5LbakNExNmZIg=
github.com/coreos/go-oidc v2.0.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-semver v0.2.0 h1:3Jm3tLmsgAYcjC+4Up7hJrFBPr+n7rAqYeSw/SZazuY=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20181031085051-9002847aa142 h1:3jFq2xL4ZajGK4aZY8jz+DAF0FHjI51BXjjSwCzS1Dk=

View file

@ -14,6 +14,7 @@ import (
"sync"
"time"
oidc "github.com/coreos/go-oidc"
"github.com/gorilla/mux"
jose "gopkg.in/square/go-jose.v2"
@ -151,6 +152,7 @@ type discovery struct {
Auth string `json:"authorization_endpoint"`
Token string `json:"token_endpoint"`
Keys string `json:"jwks_uri"`
UserInfo string `json:"userinfo_endpoint"`
ResponseTypes []string `json:"response_types_supported"`
Subjects []string `json:"subject_types_supported"`
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
@ -165,6 +167,7 @@ func (s *Server) discoveryHandler() (http.HandlerFunc, error) {
Auth: s.absURL("/auth"),
Token: s.absURL("/token"),
Keys: s.absURL("/keys"),
UserInfo: s.absURL("/userinfo"),
Subjects: []string{"public"},
IDTokenAlgs: []string{string(jose.RS256)},
Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
@ -559,7 +562,8 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe
idToken string
idTokenExpiry time.Time
accessToken = storage.NewID()
// Access token
accessToken string
)
for _, responseType := range authReq.ResponseTypes {
@ -595,6 +599,14 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe
case responseTypeIDToken:
implicitOrHybrid = true
var err error
accessToken, err = s.newAccessToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, authReq.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create new access token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}
idToken, idTokenExpiry, err = s.newIDToken(authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, accessToken, authReq.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create ID token: %v", err)
@ -716,7 +728,13 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
return
}
accessToken := storage.NewID()
accessToken, err := s.newAccessToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create new access token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}
idToken, expiry, err := s.newIDToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create ID token: %v", err)
@ -965,7 +983,13 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie
Groups: ident.Groups,
}
accessToken := storage.NewID()
accessToken, err := s.newAccessToken(client.ID, claims, scopes, refresh.Nonce, refresh.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create new access token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}
idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, refresh.Nonce, accessToken, refresh.ConnectorID)
if err != nil {
s.logger.Errorf("failed to create ID token: %v", err)
@ -1026,10 +1050,35 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie
s.writeAccessToken(w, idToken, accessToken, rawNewToken, expiry)
}
func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
const prefix = "Bearer "
auth := r.Header.Get("authorization")
if len(auth) < len(prefix) || !strings.EqualFold(prefix, auth[:len(prefix)]) {
w.Header().Set("WWW-Authenticate", "Bearer")
s.tokenErrHelper(w, errAccessDenied, "Invalid bearer token.", http.StatusUnauthorized)
return
}
rawIDToken := auth[len(prefix):]
verifier := oidc.NewVerifier(s.issuerURL.String(), &storageKeySet{s.storage}, &oidc.Config{SkipClientIDCheck: true})
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
s.tokenErrHelper(w, errAccessDenied, err.Error(), http.StatusForbidden)
return
}
var claims json.RawMessage
if err := idToken.Claims(&claims); err != nil {
s.tokenErrHelper(w, errServerError, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(claims)
}
func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, accessToken, refreshToken string, expiry time.Time) {
// TODO(ericchiang): figure out an access token story and support the user info
// endpoint. For now use a random value so no one depends on the access_token
// holding a specific structure.
resp := struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`

View file

@ -1,6 +1,7 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
@ -265,6 +266,11 @@ type federatedIDClaims struct {
UserID string `json:"user_id,omitempty"`
}
func (s *Server) newAccessToken(clientID string, claims storage.Claims, scopes []string, nonce, connID string) (accessToken string, err error) {
idToken, _, err := s.newIDToken(clientID, claims, scopes, nonce, storage.NewID(), connID)
return idToken, err
}
func (s *Server) newIDToken(clientID string, claims storage.Claims, scopes []string, nonce, accessToken, connID string) (idToken string, expiry time.Time, err error) {
keys, err := s.storage.GetKeys()
if err != nil {
@ -561,3 +567,41 @@ func validateRedirectURI(client storage.Client, redirectURI string) bool {
host, _, err := net.SplitHostPort(u.Host)
return err == nil && host == "localhost"
}
// storageKeySet implements the oidc.KeySet interface backed by Dex storage
type storageKeySet struct {
storage.Storage
}
func (s *storageKeySet) VerifySignature(_ context.Context, jwt string) (payload []byte, err error) {
jws, err := jose.ParseSigned(jwt)
if err != nil {
return nil, err
}
keyID := ""
for _, sig := range jws.Signatures {
keyID = sig.Header.KeyID
break
}
skeys, err := s.Storage.GetKeys()
if err != nil {
return nil, err
}
keys := []*jose.JSONWebKey{skeys.SigningKeyPub}
for _, vk := range skeys.VerificationKeys {
keys = append(keys, vk.PublicKey)
}
for _, key := range keys {
if keyID == "" || key.KeyID == keyID {
if payload, err := jws.Verify(key); err == nil {
return payload, nil
}
}
}
return nil, errors.New("failed to verify id token signature")
}

View file

@ -2,6 +2,8 @@ package server
import (
"context"
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"net/url"
@ -11,6 +13,7 @@ import (
jose "gopkg.in/square/go-jose.v2"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
)
func TestParseAuthorizationRequest(t *testing.T) {
@ -259,3 +262,87 @@ func TestValidRedirectURI(t *testing.T) {
}
}
}
func TestStorageKeySet(t *testing.T) {
s := memory.New(logger)
if err := s.UpdateKeys(func(keys storage.Keys) (storage.Keys, error) {
keys.SigningKey = &jose.JSONWebKey{
Key: testKey,
KeyID: "testkey",
Algorithm: "RS256",
Use: "sig",
}
keys.SigningKeyPub = &jose.JSONWebKey{
Key: testKey.Public(),
KeyID: "testkey",
Algorithm: "RS256",
Use: "sig",
}
return keys, nil
}); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
tokenGenerator func() (jwt string, err error)
wantErr bool
}{
{
name: "valid token",
tokenGenerator: func() (string, error) {
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testKey}, nil)
if err != nil {
return "", err
}
jws, err := signer.Sign([]byte("payload"))
if err != nil {
return "", err
}
return jws.CompactSerialize()
},
wantErr: false,
},
{
name: "token signed by different key",
tokenGenerator: func() (string, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, nil)
if err != nil {
return "", err
}
jws, err := signer.Sign([]byte("payload"))
if err != nil {
return "", err
}
return jws.CompactSerialize()
},
wantErr: true,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
jwt, err := tc.tokenGenerator()
if err != nil {
t.Fatal(err)
}
keySet := &storageKeySet{s}
_, err = keySet.VerifySignature(context.Background(), jwt)
if (err != nil && !tc.wantErr) || (err == nil && tc.wantErr) {
t.Fatalf("wantErr = %v, but got err = %v", tc.wantErr, err)
}
})
}
}

View file

@ -270,6 +270,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
// TODO(ericchiang): rate limit certain paths based on IP.
handleWithCORS("/token", s.handleToken)
handleWithCORS("/keys", s.handlePublicKeys)
handleWithCORS("/userinfo", s.handleUserInfo)
handleFunc("/auth", s.handleAuthorization)
handleFunc("/auth/{connector}", s.handleConnectorLogin)
r.HandleFunc(path.Join(issuerURL.Path, "/callback"), func(w http.ResponseWriter, r *http.Request) {

View file

@ -16,7 +16,6 @@ import (
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
@ -148,6 +147,7 @@ func TestDiscovery(t *testing.T) {
"authorization_endpoint",
"token_endpoint",
"jwks_uri",
"userinfo_endpoint",
}
for _, field := range required {
if _, ok := got[field]; !ok {
@ -201,6 +201,19 @@ func TestOAuth2CodeFlow(t *testing.T) {
return nil
},
},
{
name: "fetch userinfo",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
ui, err := p.UserInfo(ctx, config.TokenSource(ctx, token))
if err != nil {
return fmt.Errorf("failed to fetch userinfo: %v", err)
}
if conn.Identity.Email != ui.Email {
return fmt.Errorf("expected email to be %v, got %v", conn.Identity.Email, ui.Email)
}
return nil
},
},
{
name: "verify id token and oauth2 token expiry",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
@ -541,23 +554,6 @@ func TestOAuth2CodeFlow(t *testing.T) {
}
}
type nonceSource struct {
nonce string
once sync.Once
}
func (n *nonceSource) ClaimNonce(nonce string) error {
if n.nonce != nonce {
return errors.New("invalid nonce")
}
ok := false
n.once.Do(func() { ok = true })
if !ok {
return errors.New("invalid nonce")
}
return nil
}
func TestOAuth2ImplicitFlow(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -623,11 +619,8 @@ func TestOAuth2ImplicitFlow(t *testing.T) {
t.Fatalf("failed to create client: %v", err)
}
src := &nonceSource{nonce: nonce}
idTokenVerifier := p.Verifier(&oidc.Config{
ClientID: client.ID,
ClaimNonce: src.ClaimNonce,
})
oauth2Config = &oauth2.Config{
@ -646,13 +639,17 @@ func TestOAuth2ImplicitFlow(t *testing.T) {
if err != nil {
return fmt.Errorf("failed to parse fragment: %v", err)
}
idToken := v.Get("id_token")
if idToken == "" {
rawIDToken := v.Get("id_token")
if rawIDToken == "" {
return errors.New("no id_token in fragment")
}
if _, err := idTokenVerifier.Verify(ctx, idToken); err != nil {
idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
if err != nil {
return fmt.Errorf("failed to verify id_token: %v", err)
}
if idToken.Nonce != nonce {
return fmt.Errorf("failed to verify id_token: nonce was %v, but want %v", idToken.Nonce, nonce)
}
return nil
}

View file

@ -1,3 +1,2 @@
Bobby Rullo <bobby.rullo@coreos.com> (@bobbyrullo)
Ed Rooth <ed.rooth@coreos.com> (@sym3tri)
Eric Chiang <eric.chiang@coreos.com> (@ericchiang)
Eric Chiang <echiang@redhat.com> (@ericchiang)
Rithu Leena John <rjohn@redhat.com> (@rithujohn191)

View file

@ -38,7 +38,7 @@ func handleRedirect(w http.ResponseWriter, r *http.Request) {
The on responses, the provider can be used to verify ID Tokens.
```go
var verifier = provider.Verifier()
var verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
func handleOAuth2Callback(w http.ResponseWriter, r *http.Request) {
// Verify state and errors.

61
vendor/github.com/coreos/go-oidc/code-of-conduct.md generated vendored Normal file
View file

@ -0,0 +1,61 @@
## CoreOS Community Code of Conduct
### Contributor Code of Conduct
As contributors and maintainers of this project, and in the interest of
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating
documentation, submitting pull requests or patches, and other activities.
We are committed to making participation in this project a harassment-free
experience for everyone, regardless of level of experience, gender, gender
identity and expression, sexual orientation, disability, personal appearance,
body size, race, ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery
* Personal attacks
* Trolling or insulting/derogatory comments
* Public or private harassment
* Publishing others' private information, such as physical or electronic addresses, without explicit permission
* Other unethical or unprofessional conduct.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct. By adopting this Code of Conduct,
project maintainers commit themselves to fairly and consistently applying these
principles to every aspect of managing this project. Project maintainers who do
not follow or enforce the Code of Conduct may be permanently removed from the
project team.
This code of conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting a project maintainer, Brandon Philips
<brandon.philips@coreos.com>, and/or Rithu John <rithu.john@coreos.com>.
This Code of Conduct is adapted from the Contributor Covenant
(http://contributor-covenant.org), version 1.2.0, available at
http://contributor-covenant.org/version/1/2/0/
### CoreOS Events Code of Conduct
CoreOS events are working conferences intended for professional networking and
collaboration in the CoreOS community. Attendees are expected to behave
according to professional standards and in accordance with their employers
policies on appropriate workplace behavior.
While at CoreOS events or related social networking opportunities, attendees
should not engage in discriminatory or offensive speech or actions including
but not limited to gender, sexuality, race, age, disability, or religion.
Speakers should be especially aware of these concerns.
CoreOS does not condone any statements by speakers contrary to these standards.
CoreOS reserves the right to deny entrance and/or eject from an event (without
refund) any individual found to be engaging in discriminatory or offensive
speech or actions.
Please bring any concerns to the immediate attention of designated on-site
staff, Brandon Philips <brandon.philips@coreos.com>, and/or Rithu John <rithu.john@coreos.com>.

View file

@ -1,150 +0,0 @@
// +build ignore
// This file is used to generate keys for tests.
package main
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"text/template"
jose "gopkg.in/square/go-jose.v2"
)
type key struct {
name string
new func() (crypto.Signer, error)
}
var keys = []key{
{
"ECDSA_256", func() (crypto.Signer, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
},
},
{
"ECDSA_384", func() (crypto.Signer, error) {
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
},
},
{
"ECDSA_521", func() (crypto.Signer, error) {
return ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
},
},
{
"RSA_1024", func() (crypto.Signer, error) {
return rsa.GenerateKey(rand.Reader, 1024)
},
},
{
"RSA_2048", func() (crypto.Signer, error) {
return rsa.GenerateKey(rand.Reader, 2048)
},
},
{
"RSA_4096", func() (crypto.Signer, error) {
return rsa.GenerateKey(rand.Reader, 4096)
},
},
}
func newJWK(k key, prefix, ident string) (privBytes, pubBytes []byte, err error) {
priv, err := k.new()
if err != nil {
return nil, nil, fmt.Errorf("generate %s: %v", k.name, err)
}
pub := priv.Public()
privKey := &jose.JSONWebKey{Key: priv}
thumbprint, err := privKey.Thumbprint(crypto.SHA256)
if err != nil {
return nil, nil, fmt.Errorf("computing thumbprint: %v", err)
}
keyID := hex.EncodeToString(thumbprint)
privKey.KeyID = keyID
pubKey := &jose.JSONWebKey{Key: pub, KeyID: keyID}
privBytes, err = json.MarshalIndent(privKey, prefix, ident)
if err != nil {
return
}
pubBytes, err = json.MarshalIndent(pubKey, prefix, ident)
return
}
type keyData struct {
Name string
Priv string
Pub string
}
var tmpl = template.Must(template.New("").Parse(`// +build !golint
// This file contains statically created JWKs for tests created by gen.go
package oidc
import (
"encoding/json"
jose "gopkg.in/square/go-jose.v2"
)
func mustLoadJWK(s string) jose.JSONWebKey {
var jwk jose.JSONWebKey
if err := json.Unmarshal([]byte(s), &jwk); err != nil {
panic(err)
}
return jwk
}
var (
{{- range $i, $key := .Keys }}
testKey{{ $key.Name }} = mustLoadJWK(` + "`" + `{{ $key.Pub }}` + "`" + `)
testKey{{ $key.Name }}_Priv = mustLoadJWK(` + "`" + `{{ $key.Priv }}` + "`" + `)
{{ end -}}
)
`))
func main() {
var tmplData struct {
Keys []keyData
}
for _, k := range keys {
for i := 0; i < 4; i++ {
log.Printf("generating %s", k.name)
priv, pub, err := newJWK(k, "\t", "\t")
if err != nil {
log.Fatal(err)
}
name := fmt.Sprintf("%s_%d", k.name, i)
tmplData.Keys = append(tmplData.Keys, keyData{
Name: name,
Priv: string(priv),
Pub: string(pub),
})
}
}
buff := new(bytes.Buffer)
if err := tmpl.Execute(buff, tmplData); err != nil {
log.Fatalf("excuting template: %v", err)
}
if err := ioutil.WriteFile("jose_test.go", buff.Bytes(), 0644); err != nil {
log.Fatal(err)
}
}

View file

@ -2,7 +2,7 @@ package oidc
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
@ -23,6 +23,20 @@ import (
// updated.
const keysExpiryDelta = 30 * time.Second
// NewRemoteKeySet returns a KeySet that can validate JSON web tokens by using HTTP
// GETs to fetch JSON web token sets hosted at a remote URL. This is automatically
// used by NewProvider using the URLs returned by OpenID Connect discovery, but is
// exposed for providers that don't support discovery or to prevent round trips to the
// discovery URL.
//
// The returned KeySet is a long lived verifier that caches keys based on cache-control
// headers. Reuse a common remote key set instead of creating new ones as needed.
//
// The behavior of the returned KeySet is undefined once the context is canceled.
func NewRemoteKeySet(ctx context.Context, jwksURL string) KeySet {
return newRemoteKeySet(ctx, jwksURL, time.Now)
}
func newRemoteKeySet(ctx context.Context, jwksURL string, now func() time.Time) *remoteKeySet {
if now == nil {
now = time.Now
@ -38,147 +52,168 @@ type remoteKeySet struct {
// guard all other fields
mu sync.Mutex
// inflightCtx suppresses parallel execution of updateKeys and allows
// inflight suppresses parallel execution of updateKeys and allows
// multiple goroutines to wait for its result.
// Its Err() method returns any errors encountered during updateKeys.
//
// If nil, there is no inflight updateKeys request.
inflightCtx *inflight
inflight *inflight
// A set of cached keys and their expiry.
cachedKeys []jose.JSONWebKey
expiry time.Time
}
// inflight is used to wait on some in-flight request from multiple goroutines
// inflight is used to wait on some in-flight request from multiple goroutines.
type inflight struct {
done chan struct{}
doneCh chan struct{}
keys []jose.JSONWebKey
err error
}
// Done returns a channel that is closed when the inflight request finishes.
func (i *inflight) Done() <-chan struct{} {
return i.done
func newInflight() *inflight {
return &inflight{doneCh: make(chan struct{})}
}
// Err returns any error encountered during request execution. May be nil.
func (i *inflight) Err() error {
return i.err
// wait returns a channel that multiple goroutines can receive on. Once it returns
// a value, the inflight request is done and result() can be inspected.
func (i *inflight) wait() <-chan struct{} {
return i.doneCh
}
// Cancel signals completion of the inflight request with error err.
// Must be called only once for particular inflight instance.
func (i *inflight) Cancel(err error) {
// done can only be called by a single goroutine. It records the result of the
// inflight request and signals other goroutines that the result is safe to
// inspect.
func (i *inflight) done(keys []jose.JSONWebKey, err error) {
i.keys = keys
i.err = err
close(i.done)
close(i.doneCh)
}
func (r *remoteKeySet) keysWithIDFromCache(keyIDs []string) ([]jose.JSONWebKey, bool) {
r.mu.Lock()
keys, expiry := r.cachedKeys, r.expiry
r.mu.Unlock()
// result cannot be called until the wait() channel has returned a value.
func (i *inflight) result() ([]jose.JSONWebKey, error) {
return i.keys, i.err
}
// Have the keys expired?
if expiry.Add(keysExpiryDelta).Before(r.now()) {
return nil, false
func (r *remoteKeySet) VerifySignature(ctx context.Context, jwt string) ([]byte, error) {
jws, err := jose.ParseSigned(jwt)
if err != nil {
return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
}
return r.verify(ctx, jws)
}
func (r *remoteKeySet) verify(ctx context.Context, jws *jose.JSONWebSignature) ([]byte, error) {
// We don't support JWTs signed with multiple signatures.
keyID := ""
for _, sig := range jws.Signatures {
keyID = sig.Header.KeyID
break
}
var signingKeys []jose.JSONWebKey
keys, expiry := r.keysFromCache()
// Don't check expiry yet. This optimizes for when the provider is unavailable.
for _, key := range keys {
if contains(keyIDs, key.KeyID) {
signingKeys = append(signingKeys, key)
if keyID == "" || key.KeyID == keyID {
if payload, err := jws.Verify(&key); err == nil {
return payload, nil
}
}
}
if len(signingKeys) == 0 {
// Are the keys about to expire?
if r.now().Add(keysExpiryDelta).After(expiry) {
return nil, false
}
if !r.now().Add(keysExpiryDelta).After(expiry) {
// Keys haven't expired, don't refresh.
return nil, errors.New("failed to verify id token signature")
}
return signingKeys, true
keys, err := r.keysFromRemote(ctx)
if err != nil {
return nil, fmt.Errorf("fetching keys %v", err)
}
for _, key := range keys {
if keyID == "" || key.KeyID == keyID {
if payload, err := jws.Verify(&key); err == nil {
return payload, nil
}
}
}
return nil, errors.New("failed to verify id token signature")
}
func (r *remoteKeySet) keysWithID(ctx context.Context, keyIDs []string) ([]jose.JSONWebKey, error) {
keys, ok := r.keysWithIDFromCache(keyIDs)
if ok {
return keys, nil
}
var inflightCtx *inflight
func() {
func (r *remoteKeySet) keysFromCache() (keys []jose.JSONWebKey, expiry time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
return r.cachedKeys, r.expiry
}
// keysFromRemote syncs the key set from the remote set, records the values in the
// cache, and returns the key set.
func (r *remoteKeySet) keysFromRemote(ctx context.Context) ([]jose.JSONWebKey, error) {
// Need to lock to inspect the inflight request field.
r.mu.Lock()
// If there's not a current inflight request, create one.
if r.inflightCtx == nil {
inflightCtx := &inflight{make(chan struct{}), nil}
r.inflightCtx = inflightCtx
if r.inflight == nil {
r.inflight = newInflight()
// This goroutine has exclusive ownership over the current inflight
// request. It releases the resource by nil'ing the inflight field
// once the goroutine is done.
go func() {
// TODO(ericchiang): Upstream Kubernetes request that we recover every time
// we spawn a goroutine, because panics in a goroutine will bring down the
// entire program. There's no way to recover from another goroutine's panic.
//
// Most users actually want to let the panic propagate and bring down the
// program because it implies some unrecoverable state.
//
// Add a context key to allow the recover behavior.
//
// See: https://github.com/coreos/go-oidc/issues/89
// Sync keys and finish inflight when that's done.
keys, expiry, err := r.updateKeys()
// Sync keys and close inflightCtx when that's done.
// Use the remoteKeySet's context instead of the requests context
// because a re-sync is unique to the keys set and will span multiple
// requests.
inflightCtx.Cancel(r.updateKeys(r.ctx))
r.inflight.done(keys, err)
// Lock to update the keys and indicate that there is no longer an
// inflight request.
r.mu.Lock()
defer r.mu.Unlock()
r.inflightCtx = nil
}()
if err == nil {
r.cachedKeys = keys
r.expiry = expiry
}
inflightCtx = r.inflightCtx
// Free inflight so a different request can run.
r.inflight = nil
}()
}
inflight := r.inflight
r.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-inflightCtx.Done():
if err := inflightCtx.Err(); err != nil {
return nil, err
case <-inflight.wait():
return inflight.result()
}
}
// Since we've just updated keys, we don't care about the cache miss.
keys, _ = r.keysWithIDFromCache(keyIDs)
return keys, nil
}
func (r *remoteKeySet) updateKeys(ctx context.Context) error {
func (r *remoteKeySet) updateKeys() ([]jose.JSONWebKey, time.Time, error) {
req, err := http.NewRequest("GET", r.jwksURL, nil)
if err != nil {
return fmt.Errorf("oidc: can't create request: %v", err)
return nil, time.Time{}, fmt.Errorf("oidc: can't create request: %v", err)
}
resp, err := doRequest(ctx, req)
resp, err := doRequest(r.ctx, req)
if err != nil {
return fmt.Errorf("oidc: get keys failed %v", err)
return nil, time.Time{}, fmt.Errorf("oidc: get keys failed %v", err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("oidc: read response body: %v", err)
return nil, time.Time{}, fmt.Errorf("unable to read response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("oidc: get keys failed: %s %s", resp.Status, body)
return nil, time.Time{}, fmt.Errorf("oidc: get keys failed: %s %s", resp.Status, body)
}
var keySet jose.JSONWebKeySet
if err := json.Unmarshal(body, &keySet); err != nil {
return fmt.Errorf("oidc: failed to decode keys: %v %s", err, body)
err = unmarshalResp(resp, body, &keySet)
if err != nil {
return nil, time.Time{}, fmt.Errorf("oidc: failed to decode keys: %v %s", err, body)
}
// If the server doesn't provide cache control headers, assume the
@ -189,11 +224,5 @@ func (r *remoteKeySet) updateKeys(ctx context.Context) error {
if err == nil && e.After(expiry) {
expiry = e
}
r.mu.Lock()
defer r.mu.Unlock()
r.cachedKeys = keySet.Keys
r.expiry = expiry
return nil
return keySet.Keys, expiry, nil
}

View file

@ -3,10 +3,15 @@ package oidc
import (
"context"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"hash"
"io/ioutil"
"mime"
"net/http"
"strings"
"time"
@ -30,6 +35,11 @@ const (
ScopeOfflineAccess = "offline_access"
)
var (
errNoAtHash = errors.New("id token did not have an access token hash")
errInvalidAtHash = errors.New("access token hash does not match value in ID token")
)
// ClientContext returns a new Context that carries the provided HTTP client.
//
// This method sets the same context key used by the golang.org/x/oauth2 package,
@ -63,7 +73,7 @@ type Provider struct {
// Raw claims returned by the server.
rawClaims []byte
remoteKeySet *remoteKeySet
remoteKeySet KeySet
}
type cachedKeys struct {
@ -93,18 +103,23 @@ func NewProvider(ctx context.Context, issuer string) (*Provider, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, fmt.Errorf("unable to read response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", resp.Status, body)
}
defer resp.Body.Close()
var p providerJSON
if err := json.Unmarshal(body, &p); err != nil {
err = unmarshalResp(resp, body, &p)
if err != nil {
return nil, fmt.Errorf("oidc: failed to decode provider discovery object: %v", err)
}
if p.Issuer != issuer {
return nil, fmt.Errorf("oidc: issuer did not match the issuer returned by provider, expected %q got %q", issuer, p.Issuer)
}
@ -114,7 +129,7 @@ func NewProvider(ctx context.Context, issuer string) (*Provider, error) {
tokenURL: p.TokenURL,
userInfoURL: p.UserInfoURL,
rawClaims: body,
remoteKeySet: newRemoteKeySet(ctx, p.JWKSURL, time.Now),
remoteKeySet: NewRemoteKeySet(ctx, p.JWKSURL),
}, nil
}
@ -232,9 +247,18 @@ type IDToken struct {
// Initial nonce provided during the authentication redirect.
//
// If present, this package ensures this is a valid nonce.
// This package does NOT provided verification on the value of this field
// and it's the user's responsibility to ensure it contains a valid value.
Nonce string
// at_hash claim, if set in the ID token. Callers can verify an access token
// that corresponds to the ID token using the VerifyAccessToken method.
AccessTokenHash string
// signature algorithm used for ID token, needed to compute a verification hash of an
// access token
sigAlgorithm string
// Raw payload of the id_token.
claims []byte
}
@ -260,6 +284,34 @@ func (i *IDToken) Claims(v interface{}) error {
return json.Unmarshal(i.claims, v)
}
// VerifyAccessToken verifies that the hash of the access token that corresponds to the iD token
// matches the hash in the id token. It returns an error if the hashes don't match.
// It is the caller's responsibility to ensure that the optional access token hash is present for the ID token
// before calling this method. See https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken
func (i *IDToken) VerifyAccessToken(accessToken string) error {
if i.AccessTokenHash == "" {
return errNoAtHash
}
var h hash.Hash
switch i.sigAlgorithm {
case RS256, ES256, PS256:
h = sha256.New()
case RS384, ES384, PS384:
h = sha512.New384()
case RS512, ES512, PS512:
h = sha512.New()
default:
return fmt.Errorf("oidc: unsupported signing algorithm %q", i.sigAlgorithm)
}
h.Write([]byte(accessToken)) // hash documents that Write will never return an error
sum := h.Sum(nil)[:h.Size()/2]
actual := base64.RawURLEncoding.EncodeToString(sum)
if actual != i.AccessTokenHash {
return errInvalidAtHash
}
return nil
}
type idToken struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
@ -267,6 +319,7 @@ type idToken struct {
Expiry jsonTime `json:"exp"`
IssuedAt jsonTime `json:"iat"`
Nonce string `json:"nonce"`
AtHash string `json:"at_hash"`
}
type audience []string
@ -285,13 +338,6 @@ func (a *audience) UnmarshalJSON(b []byte) error {
return nil
}
func (a audience) MarshalJSON() ([]byte, error) {
if len(a) == 1 {
return json.Marshal(a[0])
}
return json.Marshal([]string(a))
}
type jsonTime time.Time
func (j *jsonTime) UnmarshalJSON(b []byte) error {
@ -314,6 +360,15 @@ func (j *jsonTime) UnmarshalJSON(b []byte) error {
return nil
}
func (j jsonTime) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Time(j).Unix())
func unmarshalResp(r *http.Response, body []byte, v interface{}) error {
err := json.Unmarshal(body, &v)
if err == nil {
return nil
}
ct := r.Header.Get("Content-Type")
mediaType, _, parseErr := mime.ParseMediaType(ct)
if parseErr == nil && mediaType == "application/json" {
return fmt.Errorf("got Content-Type = application/json, but could not unmarshal as JSON: %v", err)
}
return fmt.Errorf("expected Content-Type = application/json, got %q: %v", ct, err)
}

View file

@ -11,5 +11,6 @@ LINTABLE=$( go list -tags=golint -f '
go test -v -i -race github.com/coreos/go-oidc/...
go test -v -race github.com/coreos/go-oidc/...
golint $LINTABLE
golint -set_exit_status $LINTABLE
go vet github.com/coreos/go-oidc/...
go build -v ./example/...

View file

@ -19,13 +19,54 @@ const (
issuerGoogleAccountsNoScheme = "accounts.google.com"
)
// KeySet is a set of publc JSON Web Keys that can be used to validate the signature
// of JSON web tokens. This is expected to be backed by a remote key set through
// provider metadata discovery or an in-memory set of keys delivered out-of-band.
type KeySet interface {
// VerifySignature parses the JSON web token, verifies the signature, and returns
// the raw payload. Header and claim fields are validated by other parts of the
// package. For example, the KeySet does not need to check values such as signature
// algorithm, issuer, and audience since the IDTokenVerifier validates these values
// independently.
//
// If VerifySignature makes HTTP requests to verify the token, it's expected to
// use any HTTP client associated with the context through ClientContext.
VerifySignature(ctx context.Context, jwt string) (payload []byte, err error)
}
// IDTokenVerifier provides verification for ID Tokens.
type IDTokenVerifier struct {
keySet *remoteKeySet
keySet KeySet
config *Config
issuer string
}
// NewVerifier returns a verifier manually constructed from a key set and issuer URL.
//
// It's easier to use provider discovery to construct an IDTokenVerifier than creating
// one directly. This method is intended to be used with provider that don't support
// metadata discovery, or avoiding round trips when the key set URL is already known.
//
// This constructor can be used to create a verifier directly using the issuer URL and
// JSON Web Key Set URL without using discovery:
//
// keySet := oidc.NewRemoteKeySet(ctx, "https://www.googleapis.com/oauth2/v3/certs")
// verifier := oidc.NewVerifier("https://accounts.google.com", keySet, config)
//
// Since KeySet is an interface, this constructor can also be used to supply custom
// public key sources. For example, if a user wanted to supply public keys out-of-band
// and hold them statically in-memory:
//
// // Custom KeySet implementation.
// keySet := newStatisKeySet(publicKeys...)
//
// // Verifier uses the custom KeySet implementation.
// verifier := oidc.NewVerifier("https://auth.example.com", keySet, config)
//
func NewVerifier(issuerURL string, keySet KeySet, config *Config) *IDTokenVerifier {
return &IDTokenVerifier{keySet: keySet, config: config, issuer: issuerURL}
}
// Config is the configuration for an IDTokenVerifier.
type Config struct {
// Expected audience of the token. For a majority of the cases this is expected to be
@ -34,12 +75,6 @@ type Config struct {
//
// If not provided, users must explicitly set SkipClientIDCheck.
ClientID string
// Method to verify the ID Token nonce. If a nonce is present and this method
// is nil, users must explicitly set SkipNonceCheck.
//
// If the ID Token nonce is empty, for example if the client didn't provide a nonce in
// the initial redirect, this may be nil.
ClaimNonce func(nonce string) error
// If specified, only this set of algorithms may be used to sign the JWT.
//
// Since many providers only support RS256, SupportedSigningAlgs defaults to this value.
@ -49,8 +84,6 @@ type Config struct {
SkipClientIDCheck bool
// If true, token expiry is not checked.
SkipExpiryCheck bool
// If true, nonce claim is not checked. Must be true if ClaimNonce field is empty.
SkipNonceCheck bool
// Time function to check Token expiry. Defaults to time.Now
Now func() time.Time
@ -61,21 +94,7 @@ type Config struct {
// The returned IDTokenVerifier is tied to the Provider's context and its behavior is
// undefined once the Provider's context is canceled.
func (p *Provider) Verifier(config *Config) *IDTokenVerifier {
return newVerifier(p.remoteKeySet, config, p.issuer)
}
func newVerifier(keySet *remoteKeySet, config *Config, issuer string) *IDTokenVerifier {
// If SupportedSigningAlgs is empty defaults to only support RS256.
if len(config.SupportedSigningAlgs) == 0 {
config.SupportedSigningAlgs = []string{RS256}
}
return &IDTokenVerifier{
keySet: keySet,
config: config,
issuer: issuer,
}
return NewVerifier(p.issuer, p.remoteKeySet, config)
}
func parseJWT(p string) ([]byte, error) {
@ -102,6 +121,8 @@ func contains(sli []string, ele string) bool {
// Verify parses a raw ID Token, verifies it's been signed by the provider, preforms
// any additional checks depending on the Config, and returns the payload.
//
// Verify does NOT do nonce validation, which is the callers responsibility.
//
// See: https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
//
// oauth2Token, err := oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
@ -120,7 +141,7 @@ func contains(sli []string, ele string) bool {
func (v *IDTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*IDToken, error) {
jws, err := jose.ParseSigned(rawIDToken)
if err != nil {
return nil, fmt.Errorf("oidc: mallformed jwt: %v", err)
return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
}
// Throw out tokens with invalid claims before trying to verify the token. This lets
@ -141,6 +162,7 @@ func (v *IDTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*IDTok
Expiry: time.Time(token.Expiry),
IssuedAt: time.Time(token.IssuedAt),
Nonce: token.Nonce,
AccessTokenHash: token.AtHash,
claims: payload,
}
@ -165,7 +187,7 @@ func (v *IDTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*IDTok
return nil, fmt.Errorf("oidc: expected audience %q got %q", v.config.ClientID, t.Audience)
}
} else {
return nil, fmt.Errorf("oidc: Invalid configuration. ClientID must be provided or SkipClientIDCheck must be set.")
return nil, fmt.Errorf("oidc: invalid configuration, clientID must be provided or SkipClientIDCheck must be set")
}
}
@ -181,37 +203,29 @@ func (v *IDTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*IDTok
}
}
// If a set of required algorithms has been provided, ensure that the signatures use those.
var keyIDs, gotAlgs []string
for _, sig := range jws.Signatures {
if len(v.config.SupportedSigningAlgs) == 0 || contains(v.config.SupportedSigningAlgs, sig.Header.Algorithm) {
keyIDs = append(keyIDs, sig.Header.KeyID)
} else {
gotAlgs = append(gotAlgs, sig.Header.Algorithm)
}
}
if len(keyIDs) == 0 {
return nil, fmt.Errorf("oidc: no signatures use a supported algorithm, expected %q got %q", v.config.SupportedSigningAlgs, gotAlgs)
switch len(jws.Signatures) {
case 0:
return nil, fmt.Errorf("oidc: id token not signed")
case 1:
default:
return nil, fmt.Errorf("oidc: multiple signatures on id token not supported")
}
// Get keys from the remote key set. This may trigger a re-sync.
keys, err := v.keySet.keysWithID(ctx, keyIDs)
sig := jws.Signatures[0]
supportedSigAlgs := v.config.SupportedSigningAlgs
if len(supportedSigAlgs) == 0 {
supportedSigAlgs = []string{RS256}
}
if !contains(supportedSigAlgs, sig.Header.Algorithm) {
return nil, fmt.Errorf("oidc: id token signed with unsupported algorithm, expected %q got %q", supportedSigAlgs, sig.Header.Algorithm)
}
t.sigAlgorithm = sig.Header.Algorithm
gotPayload, err := v.keySet.VerifySignature(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("oidc: get keys for id token: %v", err)
}
if len(keys) == 0 {
return nil, fmt.Errorf("oidc: no keys match signature ID(s) %q", keyIDs)
}
// Try to use a key to validate the signature.
var gotPayload []byte
for _, key := range keys {
if p, err := jws.Verify(&key); err == nil {
gotPayload = p
}
}
if len(gotPayload) == 0 {
return nil, fmt.Errorf("oidc: failed to verify id token")
return nil, fmt.Errorf("failed to verify signature: %v", err)
}
// Ensure that the payload returned by the square actually matches the payload parsed earlier.
@ -219,19 +233,6 @@ func (v *IDTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*IDTok
return nil, errors.New("oidc: internal error, payload parsed did not match previous payload")
}
// Check the nonce after we've verified the token. We don't want to allow unverified
// payloads to trigger a nonce lookup.
// If SkipNonceCheck is not set ClaimNonce cannot be Nil.
if !v.config.SkipNonceCheck && t.Nonce != "" {
if v.config.ClaimNonce != nil {
if err := v.config.ClaimNonce(t.Nonce); err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("oidc: Invalid configuration. ClaimNonce must be provided or SkipNonceCheck must be set.")
}
}
return t, nil
}

2
vendor/modules.txt vendored
View file

@ -11,7 +11,7 @@ github.com/coreos/etcd/etcdserver/api/v3rpc/rpctypes
github.com/coreos/etcd/etcdserver/etcdserverpb
github.com/coreos/etcd/mvcc/mvccpb
github.com/coreos/etcd/pkg/tlsutil
# github.com/coreos/go-oidc v0.0.0-20170307191026-be73733bb8cc
# github.com/coreos/go-oidc v2.0.0+incompatible
github.com/coreos/go-oidc
# github.com/felixge/httpsnoop v1.0.0
github.com/felixge/httpsnoop