server: CodeToken now does Cross-Client auth

This commit is contained in:
Bobby Rullo 2016-06-02 13:05:18 -07:00
parent 9b4740862c
commit e71c5086ba
8 changed files with 269 additions and 67 deletions

34
scope/scope.go Normal file
View file

@ -0,0 +1,34 @@
package scope
import "strings"
const (
// Scope prefix which indicates initiation of a cross-client authentication flow.
// See https://developers.google.com/identity/protocols/CrossClientAuth
ScopeGoogleCrossClient = "audience:server:client_id:"
)
type Scopes []string
func (s Scopes) OfflineAccess() bool {
return s.HasScope("offline_access")
}
func (s Scopes) HasScope(scope string) bool {
for _, curScope := range s {
if curScope == scope {
return true
}
}
return false
}
func (s Scopes) CrossClientIDs() []string {
clients := []string{}
for _, scope := range s {
if strings.HasPrefix(scope, ScopeGoogleCrossClient) {
clients = append(clients, scope[len(ScopeGoogleCrossClient):])
}
}
return clients
}

View file

@ -1,17 +1,22 @@
package server
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"testing"
"github.com/coreos/go-oidc/oidc"
"github.com/kylelemons/godebug/pretty"
"github.com/coreos/dex/client"
clientmanager "github.com/coreos/dex/client/manager"
"github.com/coreos/dex/connector"
"github.com/coreos/dex/scope"
)
func makeCrossClientTestFixtures() (*testFixtures, error) {
@ -20,7 +25,6 @@ func makeCrossClientTestFixtures() (*testFixtures, error) {
return nil, fmt.Errorf("couldn't make test fixtures: %v", err)
}
creds := map[string]oidc.ClientCredentials{}
for _, cliData := range []struct {
id string
authorized []string
@ -38,24 +42,22 @@ func makeCrossClientTestFixtures() (*testFixtures, error) {
u := url.URL{
Scheme: "https://",
Path: cliData.id,
Host: "auth.example.com",
Host: cliData.id,
}
cliCreds, err := f.clientRepo.New(client.Client{
cliCreds, err := f.clientManager.New(client.Client{
Credentials: oidc.ClientCredentials{
ID: cliData.id,
},
Metadata: oidc.ClientMetadata{
RedirectURIs: []url.URL{u},
},
}, &clientmanager.ClientOptions{
TrustedPeers: cliData.authorized,
})
if err != nil {
return nil, fmt.Errorf("Unexpected error creating clients: %v", err)
}
creds[cliData.id] = *cliCreds
err = f.clientRepo.SetTrustedPeers(cliData.id, cliData.authorized)
if err != nil {
return nil, fmt.Errorf("Unexpected error setting cross-client authorizers: %v", err)
}
f.clientCreds[cliData.id] = *cliCreds
}
return f, nil
}
@ -132,30 +134,30 @@ func TestHandleAuthCrossClient(t *testing.T) {
wantCode int
}{
{
scopes: []string{ScopeGoogleCrossClient + "client_a"},
scopes: []string{scope.ScopeGoogleCrossClient + "client_a"},
clientID: "client_b",
wantCode: http.StatusBadRequest,
},
{
scopes: []string{ScopeGoogleCrossClient + "client_b"},
scopes: []string{scope.ScopeGoogleCrossClient + "client_b"},
clientID: "client_a",
wantCode: http.StatusFound,
},
{
scopes: []string{ScopeGoogleCrossClient + "client_b"},
scopes: []string{scope.ScopeGoogleCrossClient + "client_b"},
clientID: "client_a",
wantCode: http.StatusFound,
},
{
scopes: []string{ScopeGoogleCrossClient + "client_c"},
scopes: []string{scope.ScopeGoogleCrossClient + "client_c"},
clientID: "client_a",
wantCode: http.StatusFound,
},
{
// Two clients that client_a is authorized to mint tokens for.
scopes: []string{
ScopeGoogleCrossClient + "client_c",
ScopeGoogleCrossClient + "client_b",
scope.ScopeGoogleCrossClient + "client_c",
scope.ScopeGoogleCrossClient + "client_b",
},
clientID: "client_a",
wantCode: http.StatusFound,
@ -163,8 +165,8 @@ func TestHandleAuthCrossClient(t *testing.T) {
{
// Two clients that client_a is authorized to mint tokens for.
scopes: []string{
ScopeGoogleCrossClient + "client_c",
ScopeGoogleCrossClient + "client_a",
scope.ScopeGoogleCrossClient + "client_c",
scope.ScopeGoogleCrossClient + "client_a",
},
clientID: "client_b",
wantCode: http.StatusBadRequest,
@ -200,3 +202,129 @@ func TestHandleAuthCrossClient(t *testing.T) {
}
}
func TestServerCodeTokenCrossClient(t *testing.T) {
f, err := makeCrossClientTestFixtures()
if err != nil {
t.Fatalf("Error creating test fixtures: %v", err)
}
sm := f.sessionManager
tests := []struct {
clientID string
offline bool
refreshToken string
crossClients []string
wantErr bool
wantAUD []string
wantAZP string
}{
// First test the non-cross-client cases, make sure they're undisturbed:
{
// No 'offline_access' in scope, should get empty refresh token.
clientID: testClientID,
refreshToken: "",
wantAUD: []string{testClientID},
},
{
// Have 'offline_access' in scope, should get non-empty refresh token.
clientID: testClientID,
offline: true,
refreshToken: fmt.Sprintf("1/%s", base64.URLEncoding.EncodeToString([]byte("refresh-1"))),
wantAUD: []string{testClientID},
},
// Now test cross-client cases:
{
clientID: "client_a",
crossClients: []string{"client_b"},
wantAUD: []string{"client_b"},
wantAZP: "client_a",
},
{
clientID: "client_a",
crossClients: []string{"client_b", "client_a"},
wantAUD: []string{"client_a", "client_b"},
wantAZP: "client_a",
},
}
for i, tt := range tests {
scopes := []string{"openid"}
if tt.offline {
scopes = append(scopes, "offline_access")
}
for _, client := range tt.crossClients {
scopes = append(scopes, scope.ScopeGoogleCrossClient+client)
}
sessionID, err := sm.NewSession("bogus_idpc", tt.clientID, "bogus", url.URL{}, "", false, scopes)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
_, err = sm.AttachRemoteIdentity(sessionID, oidc.Identity{})
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
_, err = sm.AttachUser(sessionID, "ID-1")
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
key, err := sm.NewSessionKey(sessionID)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
jwt, token, err := f.srv.CodeToken(f.clientCreds[tt.clientID], key)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
if jwt == nil {
t.Fatalf("case %d: expect non-nil jwt", i)
}
if token != tt.refreshToken {
t.Errorf("case %d: expect refresh token %q, got %q", i, tt.refreshToken, token)
}
claims, err := jwt.Claims()
if err != nil {
t.Fatalf("case %d: unexpected error getting claims: %v", i, err)
}
var gotAUD []string
if len(tt.wantAUD) < 2 {
aud, _, err := claims.StringClaim("aud")
if err != nil {
t.Fatalf("case %d: unexpected error getting 'aud': %q: raw: %v", i, err, claims["aud"])
}
gotAUD = []string{aud}
} else {
gotAUD, _, err = claims.StringsClaim("aud")
if err != nil {
t.Fatalf("case %d: unexpected error getting 'aud': %v", i, err)
}
}
sort.Strings(gotAUD)
if diff := pretty.Compare(tt.wantAUD, gotAUD); diff != "" {
t.Fatalf("case %d: pretty.Compare(tt.wantAUD, gotAUD): %v", i, diff)
}
gotAZP, _, err := claims.StringClaim("azp")
if err != nil {
if err != nil {
t.Fatalf("case %d: unexpected error getting 'aud': %v", i, err)
}
}
if gotAZP != tt.wantAZP {
t.Errorf("case %d: wantAZP=%v, gotAZP=%v", i, tt.wantAZP, gotAZP)
}
}
}

View file

@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
@ -22,6 +21,7 @@ import (
"github.com/coreos/dex/connector"
phttp "github.com/coreos/dex/pkg/http"
"github.com/coreos/dex/pkg/log"
"github.com/coreos/dex/scope"
)
const (
@ -392,20 +392,18 @@ func handleAuthFunc(srv DexServer, idpcs []connector.Connector, tpl *template.Te
func validateScopes(srv DexServer, clientID string, scopes []string) error {
foundOpenIDScope := false
sort.Strings(scopes)
for i, scope := range scopes {
if i > 0 && scope == scopes[i-1] {
for i, curScope := range scopes {
if i > 0 && curScope == scopes[i-1] {
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
err.Description = fmt.Sprintf(
"Duplicate scopes are not allowed: %q",
scope)
curScope)
return err
}
switch {
case strings.HasPrefix(scope, ScopeGoogleCrossClient):
otherClient := scope[len(ScopeGoogleCrossClient):]
case strings.HasPrefix(curScope, scope.ScopeGoogleCrossClient):
otherClient := curScope[len(scope.ScopeGoogleCrossClient):]
var allowed bool
var err error
if otherClient == clientID {
@ -424,11 +422,11 @@ func validateScopes(srv DexServer, clientID string, scopes []string) error {
clientID, otherClient)
return err
}
case scope == "openid":
case curScope == "openid":
foundOpenIDScope = true
case scope == "profile":
case scope == "email":
case scope == "offline_access":
case curScope == "profile":
case curScope == "email":
case curScope == "offline_access":
// According to the spec, for offline_access scope, the client must
// use a response_type value that would result in an Authorization
// Code. Currently oauth2.ResponseTypeCode is the only supported
@ -439,7 +437,7 @@ func validateScopes(srv DexServer, clientID string, scopes []string) error {
default:
// Reject all other scopes.
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
err.Description = fmt.Sprintf("%q is not a recognized scope", scope)
err.Description = fmt.Sprintf("%q is not a recognized scope", curScope)
return err
}
}

View file

@ -18,6 +18,7 @@ import (
"github.com/coreos/dex/client"
"github.com/coreos/dex/connector"
"github.com/coreos/dex/scope"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/oauth2"
"github.com/coreos/go-oidc/oidc"
@ -346,21 +347,21 @@ func TestValidateScopes(t *testing.T) {
{
// ERR: invalid cross client auth
clientID: "XXX",
scopes: []string{"openid", ScopeGoogleCrossClient + "client_a"},
scopes: []string{"openid", scope.ScopeGoogleCrossClient + "client_a"},
wantErr: true,
},
{
// OK: valid cross client auth (though perverse - a client
// requesting cross-client auth for itself)
clientID: "client_a",
scopes: []string{"openid", ScopeGoogleCrossClient + "client_a"},
scopes: []string{"openid", scope.ScopeGoogleCrossClient + "client_a"},
wantErr: false,
},
{
// OK: valid cross client auth
clientID: "client_a",
scopes: []string{"openid", ScopeGoogleCrossClient + "client_b"},
scopes: []string{"openid", scope.ScopeGoogleCrossClient + "client_b"},
wantErr: false,
},
{
@ -368,8 +369,8 @@ func TestValidateScopes(t *testing.T) {
// ERR: valid cross client auth...but duplicated scope.
clientID: "client_a",
scopes: []string{"openid",
ScopeGoogleCrossClient + "client_b",
ScopeGoogleCrossClient + "client_b",
scope.ScopeGoogleCrossClient + "client_b",
scope.ScopeGoogleCrossClient + "client_b",
},
wantErr: true,
},
@ -378,9 +379,9 @@ func TestValidateScopes(t *testing.T) {
clientID: "client_a",
scopes: []string{
"openid",
ScopeGoogleCrossClient + "client_a",
ScopeGoogleCrossClient + "client_b",
ScopeGoogleCrossClient + "client_c",
scope.ScopeGoogleCrossClient + "client_a",
scope.ScopeGoogleCrossClient + "client_b",
scope.ScopeGoogleCrossClient + "client_c",
},
wantErr: false,
},
@ -388,9 +389,9 @@ func TestValidateScopes(t *testing.T) {
// ERR: valid cross client auth with >1 clients including itself...but no openid!
clientID: "client_a",
scopes: []string{
ScopeGoogleCrossClient + "client_a",
ScopeGoogleCrossClient + "client_b",
ScopeGoogleCrossClient + "client_c",
scope.ScopeGoogleCrossClient + "client_a",
scope.ScopeGoogleCrossClient + "client_b",
scope.ScopeGoogleCrossClient + "client_c",
},
wantErr: true,
},

View file

@ -39,10 +39,6 @@ const (
ResetPasswordTemplateName = "reset-password.html"
APIVersion = "v1"
// Scope prefix which indicates initiation of a cross-client authentication flow.
// See https://developers.google.com/identity/protocols/CrossClientAuth
ScopeGoogleCrossClient = "audience:server:client_id:"
)
type OIDCServer interface {
@ -454,6 +450,36 @@ func (s *Server) CodeToken(creds oidc.ClientCredentials, sessionKey string) (*jo
claims := ses.Claims(s.IssuerURL.String())
user.AddToClaims(claims)
crossClientIDs := ses.Scope.CrossClientIDs()
if len(crossClientIDs) > 0 {
var aud []string
for _, id := range crossClientIDs {
if ses.ClientID == id {
aud = append(aud, id)
continue
}
allowed, err := s.CrossClientAuthAllowed(ses.ClientID, id)
if err != nil {
log.Errorf("Failed to check cross client auth. reqClientID %v; authClient:ID %v; err: %v", ses.ClientID, id, err)
return nil, "", oauth2.NewError(oauth2.ErrorServerError)
}
if !allowed {
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
err.Description = fmt.Sprintf(
"%q is not authorized to perform cross-client requests for %q",
ses.ClientID, id)
return nil, "", err
}
aud = append(aud, id)
}
if len(aud) == 1 {
claims.Add("aud", aud[0])
} else {
claims.Add("aud", aud)
}
claims.Add("azp", ses.ClientID)
}
jwt, err := jose.NewSignedJWT(claims, signer)
if err != nil {
log.Errorf("Failed to generate ID token: %v", err)
@ -538,7 +564,7 @@ func (s *Server) RefreshToken(creds oidc.ClientCredentials, token string) (*jose
}
func (s *Server) CrossClientAuthAllowed(requestingClientID, authorizingClientID string) (bool, error) {
alloweds, err := s.ClientRepo.GetTrustedPeers(authorizingClientID)
alloweds, err := s.ClientRepo.GetTrustedPeers(nil, authorizingClientID)
if err != nil {
return false, err
}

View file

@ -9,16 +9,17 @@ import (
"testing"
"time"
"github.com/coreos/dex/client"
"github.com/coreos/dex/db"
"github.com/coreos/dex/refresh/refreshtest"
"github.com/coreos/dex/session/manager"
"github.com/coreos/dex/user"
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/key"
"github.com/coreos/go-oidc/oauth2"
"github.com/coreos/go-oidc/oidc"
"github.com/kylelemons/godebug/pretty"
"github.com/coreos/dex/client"
"github.com/coreos/dex/db"
"github.com/coreos/dex/refresh/refreshtest"
"github.com/coreos/dex/session/manager"
"github.com/coreos/dex/user"
)
var validRedirURL = url.URL{
@ -266,6 +267,12 @@ func TestServerLoginDisabledUser(t *testing.T) {
}
func TestServerCodeToken(t *testing.T) {
f, err := makeTestFixtures()
if err != nil {
t.Fatalf("Error creating test fixtures: %v", err)
}
sm := f.sessionManager
tests := []struct {
scope []string
refreshToken string
@ -277,21 +284,14 @@ func TestServerCodeToken(t *testing.T) {
},
// Have 'offline_access' in scope, should get non-empty refresh token.
{
// NOTE(ericchiang): This test assumes that the database ID of the first
// refresh token will be "1".
// NOTE(ericchiang): This test assumes that the database ID of the
// first refresh token will be "1".
scope: []string{"openid", "offline_access"},
refreshToken: fmt.Sprintf("1/%s", base64.URLEncoding.EncodeToString([]byte("refresh-1"))),
},
}
for i, tt := range tests {
f, err := makeTestFixtures()
if err != nil {
t.Fatalf("error making test fixtures: %v", err)
}
f.srv.RefreshTokenRepo = refreshtest.NewTestRefreshTokenRepo()
sm := f.sessionManager
sessionID, err := sm.NewSession("bogus_idpc", testClientID, "bogus", url.URL{}, "", false, tt.scope)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
@ -311,11 +311,9 @@ func TestServerCodeToken(t *testing.T) {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
jwt, token, err := f.srv.CodeToken(
oidc.ClientCredentials{
ID: testClientID,
Secret: clientTestSecret,
}, key)
jwt, token, err := f.srv.CodeToken(oidc.ClientCredentials{
ID: testClientID,
Secret: clientTestSecret}, key)
if err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}

View file

@ -14,6 +14,7 @@ import (
"github.com/coreos/dex/connector"
"github.com/coreos/dex/db"
"github.com/coreos/dex/email"
"github.com/coreos/dex/refresh/refreshtest"
sessionmanager "github.com/coreos/dex/session/manager"
"github.com/coreos/dex/user"
useremail "github.com/coreos/dex/user/email"
@ -83,6 +84,11 @@ var (
}
testPrivKey, _ = key.GeneratePrivateKey()
testClientCreds = oidc.ClientCredentials{
ID: testClientID,
Secret: base64.URLEncoding.EncodeToString([]byte("secret")),
}
)
type testFixtures struct {
@ -93,6 +99,7 @@ type testFixtures struct {
redirectURL url.URL
clientRepo client.ClientRepo
clientManager *clientmanager.ClientManager
clientCreds map[string]oidc.ClientCredentials
}
type testFixtureOptions struct {
@ -150,6 +157,8 @@ func makeTestFixturesWithOptions(options testFixtureOptions) (*testFixtures, err
sessionManager := sessionmanager.NewSessionManager(db.NewSessionRepo(db.NewMemDB()), db.NewSessionKeyRepo(db.NewMemDB()))
sessionManager.GenerateCode = sequentialGenerateCodeFunc()
refreshTokenRepo := refreshtest.NewTestRefreshTokenRepo()
emailer, err := email.NewTemplatizedEmailerFromGlobs(
emailTemplatesLocation+"/*.txt",
emailTemplatesLocation+"/*.html",
@ -210,6 +219,7 @@ func makeTestFixturesWithOptions(options testFixtureOptions) (*testFixtures, err
UserManager: userManager,
ClientManager: clientManager,
KeyManager: km,
RefreshTokenRepo: refreshTokenRepo,
}
err = setTemplates(srv, tpl)
@ -243,5 +253,8 @@ func makeTestFixturesWithOptions(options testFixtureOptions) (*testFixtures, err
emailer: emailer,
clientRepo: clientRepo,
clientManager: clientManager,
clientCreds: map[string]oidc.ClientCredentials{
testClientID: testClientCreds,
},
}, nil
}

View file

@ -6,6 +6,8 @@ import (
"github.com/coreos/go-oidc/jose"
"github.com/coreos/go-oidc/oidc"
"github.com/coreos/dex/scope"
)
const (
@ -46,11 +48,13 @@ type Session struct {
// Regsiter indicates that this session is a registration flow.
Register bool
// Nonce is optionally provided in the initial authorization request, and propogated in such cases to the generated claims.
// Nonce is optionally provided in the initial authorization request, and
// propogated in such cases to the generated claims.
Nonce string
// Scope is the 'scope' field in the authentication request. Example scopes are 'openid', 'email', 'offline', etc.
Scope []string
// Scope is the 'scope' field in the authentication request. Example scopes
// are 'openid', 'email', 'offline', etc.
Scope scope.Scopes
}
// Claims returns a new set of Claims for the current session.