server: /auth accepts, validates X-client scopes

This commit is contained in:
Bobby Rullo 2016-05-31 11:58:12 -07:00
parent e6e04be297
commit 9b4740862c
4 changed files with 402 additions and 26 deletions

202
server/cross_client_test.go Normal file
View file

@ -0,0 +1,202 @@
package server
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/coreos/go-oidc/oidc"
"github.com/coreos/dex/client"
"github.com/coreos/dex/connector"
)
func makeCrossClientTestFixtures() (*testFixtures, error) {
f, err := makeTestFixtures()
if err != nil {
return nil, fmt.Errorf("couldn't make test fixtures: %v", err)
}
creds := map[string]oidc.ClientCredentials{}
for _, cliData := range []struct {
id string
authorized []string
}{
{
id: "client_a",
}, {
id: "client_b",
authorized: []string{"client_a"},
}, {
id: "client_c",
authorized: []string{"client_a", "client_b"},
},
} {
u := url.URL{
Scheme: "https://",
Path: cliData.id,
Host: "auth.example.com",
}
cliCreds, err := f.clientRepo.New(client.Client{
Credentials: oidc.ClientCredentials{
ID: cliData.id,
},
Metadata: oidc.ClientMetadata{
RedirectURIs: []url.URL{u},
},
})
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)
}
}
return f, nil
}
func TestServerCrossClientAuthAllowed(t *testing.T) {
f, err := makeCrossClientTestFixtures()
if err != nil {
t.Fatalf("couldn't make test fixtures: %v", err)
}
tests := []struct {
reqClient string
authClient string
wantAuthorized bool
wantErr bool
}{
{
reqClient: "client_b",
authClient: "client_a",
wantAuthorized: false,
wantErr: false,
},
{
reqClient: "client_a",
authClient: "client_b",
wantAuthorized: true,
wantErr: false,
},
{
reqClient: "client_a",
authClient: "client_c",
wantAuthorized: true,
wantErr: false,
},
{
reqClient: "client_c",
authClient: "client_b",
wantAuthorized: false,
wantErr: false,
},
{
reqClient: "client_c",
authClient: "nope",
wantErr: false,
},
}
for i, tt := range tests {
got, err := f.srv.CrossClientAuthAllowed(tt.reqClient, tt.authClient)
if tt.wantErr {
if err == nil {
t.Errorf("case %d: want non-nil err", i)
}
continue
}
if err != nil {
t.Errorf("case %d: unexpected err %v: ", i, err)
}
if got != tt.wantAuthorized {
t.Errorf("case %d: want=%v, got=%v", i, tt.wantAuthorized, got)
}
}
}
func TestHandleAuthCrossClient(t *testing.T) {
f, err := makeCrossClientTestFixtures()
if err != nil {
t.Fatalf("couldn't make test fixtures: %v", err)
}
tests := []struct {
scopes []string
clientID string
wantCode int
}{
{
scopes: []string{ScopeGoogleCrossClient + "client_a"},
clientID: "client_b",
wantCode: http.StatusBadRequest,
},
{
scopes: []string{ScopeGoogleCrossClient + "client_b"},
clientID: "client_a",
wantCode: http.StatusFound,
},
{
scopes: []string{ScopeGoogleCrossClient + "client_b"},
clientID: "client_a",
wantCode: http.StatusFound,
},
{
scopes: []string{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",
},
clientID: "client_a",
wantCode: http.StatusFound,
},
{
// Two clients that client_a is authorized to mint tokens for.
scopes: []string{
ScopeGoogleCrossClient + "client_c",
ScopeGoogleCrossClient + "client_a",
},
clientID: "client_b",
wantCode: http.StatusBadRequest,
},
}
idpcs := []connector.Connector{
&fakeConnector{loginURL: "http://fake.example.com"},
}
for i, tt := range tests {
hdlr := handleAuthFunc(f.srv, idpcs, nil, true)
w := httptest.NewRecorder()
query := url.Values{
"response_type": []string{"code"},
"client_id": []string{tt.clientID},
"connector_id": []string{"fake"},
"scope": []string{strings.Join(append([]string{"openid"}, tt.scopes...), " ")},
}
u := fmt.Sprintf("http://server.example.com?%s", query.Encode())
req, err := http.NewRequest("GET", u, nil)
if err != nil {
t.Errorf("case %d: unable to form HTTP request: %v", i, err)
continue
}
hdlr.ServeHTTP(w, req)
if tt.wantCode != w.Code {
t.Errorf("case %d: HTTP code mismatch: want=%d got=%d", i, tt.wantCode, w.Code)
continue
}
}
}

View file

@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
@ -263,7 +264,7 @@ func renderLoginPage(w http.ResponseWriter, r *http.Request, srv OIDCServer, idp
execTemplate(w, tpl, td)
}
func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.Template, registrationEnabled bool) http.HandlerFunc {
func handleAuthFunc(srv DexServer, idpcs []connector.Connector, tpl *template.Template, registrationEnabled bool) http.HandlerFunc {
idx := makeConnectorMap(idpcs)
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
@ -341,30 +342,9 @@ func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.T
}
// Check scopes.
var scopes []string
foundOpenIDScope := false
for _, scope := range acr.Scope {
switch scope {
case "openid":
foundOpenIDScope = true
scopes = append(scopes, scope)
case "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 response type,
// and it's been checked above, so we don't need to check it again here.
//
// TODO(yifan): Verify that 'consent' should be in 'prompt'.
scopes = append(scopes, scope)
default:
// Pass all other scopes.
scopes = append(scopes, scope)
}
}
if !foundOpenIDScope {
log.Errorf("Invalid auth request: missing 'openid' in 'scope'")
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
if scopeErr := validateScopes(srv, acr.ClientID, acr.Scope); scopeErr != nil {
log.Error(scopeErr)
writeAuthError(w, scopeErr, acr.State)
return
}
@ -410,6 +390,69 @@ func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.T
}
}
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] {
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
err.Description = fmt.Sprintf(
"Duplicate scopes are not allowed: %q",
scope)
return err
}
switch {
case strings.HasPrefix(scope, ScopeGoogleCrossClient):
otherClient := scope[len(ScopeGoogleCrossClient):]
var allowed bool
var err error
if otherClient == clientID {
allowed = true
} else {
allowed, err = srv.CrossClientAuthAllowed(clientID, otherClient)
if err != nil {
return err
}
}
if !allowed {
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
err.Description = fmt.Sprintf(
"%q is not authorized to perform cross-client requests for %q",
clientID, otherClient)
return err
}
case scope == "openid":
foundOpenIDScope = true
case scope == "profile":
case scope == "email":
case scope == "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
// response type, and it's been checked above, so we don't need to
// check it again here.
//
// TODO(yifan): Verify that 'consent' should be in 'prompt'.
default:
// Reject all other scopes.
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
err.Description = fmt.Sprintf("%q is not a recognized scope", scope)
return err
}
}
if !foundOpenIDScope {
log.Errorf("Invalid auth request: missing 'openid' in 'scope'")
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
err.Description = "Invalid auth request: missing 'openid' in 'scope'"
return err
}
return nil
}
func handleTokenFunc(srv OIDCServer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {

View file

@ -308,8 +308,110 @@ func TestHandleAuthFuncResponsesMultipleRedirectURLs(t *testing.T) {
}
}
func TestHandleTokenFunc(t *testing.T) {
func TestValidateScopes(t *testing.T) {
f, err := makeCrossClientTestFixtures()
if err != nil {
t.Fatalf("couldn't make test fixtures: %v", err)
}
tests := []struct {
clientID string
scopes []string
wantErr bool
}{
{
// ERR: no openid scope
clientID: "XXX",
scopes: []string{},
wantErr: true,
},
{
// OK: minimum scopes
clientID: "XXX",
scopes: []string{"openid"},
wantErr: false,
},
{
// OK: offline_access
clientID: "XXX",
scopes: []string{"openid", "offline_access"},
wantErr: false,
},
{
// ERR: unknown scope
clientID: "XXX",
scopes: []string{"openid", "wat"},
wantErr: true,
},
{
// ERR: invalid cross client auth
clientID: "XXX",
scopes: []string{"openid", 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"},
wantErr: false,
},
{
// OK: valid cross client auth
clientID: "client_a",
scopes: []string{"openid", ScopeGoogleCrossClient + "client_b"},
wantErr: false,
},
{
// ERR: valid cross client auth...but duplicated scope.
clientID: "client_a",
scopes: []string{"openid",
ScopeGoogleCrossClient + "client_b",
ScopeGoogleCrossClient + "client_b",
},
wantErr: true,
},
{
// OK: valid cross client auth with >1 clients including itself
clientID: "client_a",
scopes: []string{
"openid",
ScopeGoogleCrossClient + "client_a",
ScopeGoogleCrossClient + "client_b",
ScopeGoogleCrossClient + "client_c",
},
wantErr: false,
},
{
// 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",
},
wantErr: true,
},
}
for i, tt := range tests {
err := validateScopes(f.srv, tt.clientID, tt.scopes)
if tt.wantErr {
if err == nil {
t.Errorf("case %d: want non-nil err", i)
}
continue
}
if err != nil {
t.Errorf("case %d: unexpected err: %v", i, err)
}
}
}
func TestHandleTokenFunc(t *testing.T) {
fx, err := makeTestFixtures()
if err != nil {
t.Fatalf("could not run test fixtures: %v", err)

View file

@ -39,21 +39,37 @@ 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 {
ClientMetadata(string) (*oidc.ClientMetadata, error)
NewSession(connectorID, clientID, clientState string, redirectURL url.URL, nonce string, register bool, scope []string) (string, error)
Login(oidc.Identity, string) (string, error)
// CodeToken exchanges a code for an ID token and a refresh token string on success.
CodeToken(creds oidc.ClientCredentials, sessionKey string) (*jose.JWT, string, error)
ClientCredsToken(creds oidc.ClientCredentials) (*jose.JWT, error)
// RefreshToken takes a previously generated refresh token and returns a new ID token
// if the token is valid.
RefreshToken(creds oidc.ClientCredentials, token string) (*jose.JWT, error)
KillSession(string) error
}
// DexServer is an OIDCServer that also has dex-specific features.
type DexServer interface {
OIDCServer
// CrossClientAuthAllowed
CrossClientAuthAllowed(requestingClientID, authorizingClientID string) (bool, error)
}
type JWTVerifierFactory func(clientID string) oidc.JWTVerifier
type Server struct {
@ -521,6 +537,19 @@ func (s *Server) RefreshToken(creds oidc.ClientCredentials, token string) (*jose
return jwt, nil
}
func (s *Server) CrossClientAuthAllowed(requestingClientID, authorizingClientID string) (bool, error) {
alloweds, err := s.ClientRepo.GetTrustedPeers(authorizingClientID)
if err != nil {
return false, err
}
for _, allowed := range alloweds {
if requestingClientID == allowed {
return true, nil
}
}
return false, nil
}
func (s *Server) JWTVerifierFactory() JWTVerifierFactory {
noop := func() error { return nil }