forked from mystiq/dex
Merge pull request #531 from ericchiang/user-api-accept-bearer-tokens-with-multiple-audiences
user api: accept bearer tokens with multiple audiences
This commit is contained in:
commit
0e94e76255
3 changed files with 148 additions and 41 deletions
|
@ -2,7 +2,6 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -262,18 +261,32 @@ func (s *UserMgmtServer) getCreds(r *http.Request, requiresAdmin bool) (api.Cred
|
||||||
return api.Creds{}, api.ErrorUnauthorized
|
return api.Creds{}, api.ErrorUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID, ok, err := claims.StringClaim("aud")
|
// The "aud" claim is allowed to be both a list of clients or a single client. Check for both cases.
|
||||||
if err != nil {
|
clientIDs, ok, err := claims.StringsClaim("aud")
|
||||||
log.Errorf("userMgmtServer: GetCreds err: %q", err)
|
if err != nil || !ok {
|
||||||
return api.Creds{}, err
|
clientID, ok, err := claims.StringClaim("aud")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("userMgmtServer: GetCreds failed to parse 'aud' claim: %q", err)
|
||||||
|
return api.Creds{}, api.ErrorUnauthorized
|
||||||
|
}
|
||||||
|
if !ok || clientID == "" {
|
||||||
|
return api.Creds{}, api.ErrorUnauthorized
|
||||||
|
}
|
||||||
|
clientIDs = []string{clientID}
|
||||||
}
|
}
|
||||||
if !ok || clientID == "" {
|
if len(clientIDs) == 0 {
|
||||||
return api.Creds{}, errors.New("no aud(client ID) claim")
|
log.Errorf("userMgmtServer: GetCreds err: no client in audience")
|
||||||
|
return api.Creds{}, api.ErrorUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
verifier := s.jwtvFactory(clientID)
|
// Verify that the JWT is signed by this server, has the correct issuer, hasn't expired, etc.
|
||||||
|
// While we don't actualy care which client the token was issued for (we'll check that later),
|
||||||
|
// go-oidc doesn't provide any methods which don't require passing a client ID.
|
||||||
|
//
|
||||||
|
// TODO(ericchiang): Add a verifier to go-oidc that doesn't require a client ID.
|
||||||
|
verifier := s.jwtvFactory(clientIDs[0])
|
||||||
if err := verifier.Verify(jwt); err != nil {
|
if err := verifier.Verify(jwt); err != nil {
|
||||||
log.Errorf("userMgmtServer: GetCreds err: %q", err)
|
log.Errorf("userMgmtServer: GetCreds err: failed to verify token %q", err)
|
||||||
return api.Creds{}, api.ErrorUnauthorized
|
return api.Creds{}, api.ErrorUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -295,18 +308,32 @@ func (s *UserMgmtServer) getCreds(r *http.Request, requiresAdmin bool) (api.Cred
|
||||||
return api.Creds{}, err
|
return api.Creds{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
isAdmin, err := s.cm.IsDexAdmin(clientID)
|
i := 0
|
||||||
if err != nil {
|
for _, clientID := range clientIDs {
|
||||||
log.Errorf("userMgmtServer: GetCreds err: %q", err)
|
// Make sure the client actually exists.
|
||||||
return api.Creds{}, err
|
isAdmin, err := s.cm.IsDexAdmin(clientID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("userMgmtServer: GetCreds err: failed to get client %v", err)
|
||||||
|
return api.Creds{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the endpoint requires an admin client, filter out clients which are not admins.
|
||||||
|
if requiresAdmin && !isAdmin {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIDs[i] = clientID
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
if requiresAdmin && !isAdmin {
|
|
||||||
|
clientIDs = clientIDs[:i]
|
||||||
|
if len(clientIDs) == 0 {
|
||||||
return api.Creds{}, api.ErrorForbidden
|
return api.Creds{}, api.ErrorForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.Creds{
|
return api.Creds{
|
||||||
ClientID: clientID,
|
ClientIDs: clientIDs,
|
||||||
User: usr,
|
User: usr,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -98,8 +98,9 @@ type Emailer interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Creds struct {
|
type Creds struct {
|
||||||
ClientID string
|
// IDTokens can be issued for multiple clients.
|
||||||
User user.User
|
ClientIDs []string
|
||||||
|
User user.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(ericchiang): Don't pass a dbMap. See #385.
|
// TODO(ericchiang): Don't pass a dbMap. See #385.
|
||||||
|
@ -144,6 +145,22 @@ func (u *UsersAPI) DisableUser(creds Creds, userID string, disable bool) (schema
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validRedirectURL finds the first client for which the redirect URL is valid. If found it returns the client_id of the client.
|
||||||
|
func validRedirectURL(clientManager *clientmanager.ClientManager, redirectURL url.URL, clientIDs []string) (string, error) {
|
||||||
|
// Find the first client with a valid redirectURL.
|
||||||
|
for _, clientID := range clientIDs {
|
||||||
|
metadata, err := clientManager.Metadata(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return "", mapError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.ValidRedirectURL(&redirectURL, metadata.RedirectURIs); err == nil {
|
||||||
|
return clientID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ErrorInvalidRedirectURL
|
||||||
|
}
|
||||||
|
|
||||||
func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (schema.UserCreateResponse, error) {
|
func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (schema.UserCreateResponse, error) {
|
||||||
log.Infof("userAPI: CreateUser")
|
log.Infof("userAPI: CreateUser")
|
||||||
if !u.Authorize(creds) {
|
if !u.Authorize(creds) {
|
||||||
|
@ -155,14 +172,9 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s
|
||||||
return schema.UserCreateResponse{}, mapError(err)
|
return schema.UserCreateResponse{}, mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata, err := u.clientManager.Metadata(creds.ClientID)
|
clientID, err := validRedirectURL(u.clientManager, redirURL, creds.ClientIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return schema.UserCreateResponse{}, mapError(err)
|
return schema.UserCreateResponse{}, err
|
||||||
}
|
|
||||||
|
|
||||||
validRedirURL, err := client.ValidRedirectURL(&redirURL, metadata.RedirectURIs)
|
|
||||||
if err != nil {
|
|
||||||
return schema.UserCreateResponse{}, ErrorInvalidRedirectURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := u.userManager.CreateUser(schemaUserToUser(usr), user.Password(hash), u.localConnectorID)
|
id, err := u.userManager.CreateUser(schemaUserToUser(usr), user.Password(hash), u.localConnectorID)
|
||||||
|
@ -177,7 +189,7 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s
|
||||||
|
|
||||||
usr = userToSchemaUser(userUser)
|
usr = userToSchemaUser(userUser)
|
||||||
|
|
||||||
url, err := u.emailer.SendInviteEmail(usr.Email, validRedirURL, creds.ClientID)
|
url, err := u.emailer.SendInviteEmail(usr.Email, redirURL, clientID)
|
||||||
|
|
||||||
// An email is sent only if we don't get a link and there's no error.
|
// An email is sent only if we don't get a link and there's no error.
|
||||||
emailSent := err == nil && url == nil
|
emailSent := err == nil && url == nil
|
||||||
|
@ -200,14 +212,9 @@ func (u *UsersAPI) ResendEmailInvitation(creds Creds, userID string, redirURL ur
|
||||||
return schema.ResendEmailInvitationResponse{}, ErrorUnauthorized
|
return schema.ResendEmailInvitationResponse{}, ErrorUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata, err := u.clientManager.Metadata(creds.ClientID)
|
clientID, err := validRedirectURL(u.clientManager, redirURL, creds.ClientIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return schema.ResendEmailInvitationResponse{}, mapError(err)
|
return schema.ResendEmailInvitationResponse{}, err
|
||||||
}
|
|
||||||
|
|
||||||
validRedirURL, err := client.ValidRedirectURL(&redirURL, metadata.RedirectURIs)
|
|
||||||
if err != nil {
|
|
||||||
return schema.ResendEmailInvitationResponse{}, ErrorInvalidRedirectURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve user to check if it's already created
|
// Retrieve user to check if it's already created
|
||||||
|
@ -221,7 +228,7 @@ func (u *UsersAPI) ResendEmailInvitation(creds Creds, userID string, redirURL ur
|
||||||
return schema.ResendEmailInvitationResponse{}, ErrorVerifiedEmail
|
return schema.ResendEmailInvitationResponse{}, ErrorVerifiedEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
url, err := u.emailer.SendInviteEmail(userUser.Email, validRedirURL, creds.ClientID)
|
url, err := u.emailer.SendInviteEmail(userUser.Email, redirURL, clientID)
|
||||||
|
|
||||||
// An email is sent only if we don't get a link and there's no error.
|
// An email is sent only if we don't get a link and there's no error.
|
||||||
emailSent := err == nil && url == nil
|
emailSent := err == nil && url == nil
|
||||||
|
|
|
@ -51,15 +51,16 @@ func (t *testEmailer) sendEmail(email string, redirectURL url.URL, clientID stri
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
clock = clockwork.NewFakeClock()
|
clock = clockwork.NewFakeClock()
|
||||||
goodClientID = "client.example.com"
|
goodClientID = "client.example.com"
|
||||||
|
nonAdminClientID = "user.example.com"
|
||||||
|
|
||||||
goodCreds = Creds{
|
goodCreds = Creds{
|
||||||
User: user.User{
|
User: user.User{
|
||||||
ID: "ID-1",
|
ID: "ID-1",
|
||||||
Admin: true,
|
Admin: true,
|
||||||
},
|
},
|
||||||
ClientID: goodClientID,
|
ClientIDs: []string{goodClientID},
|
||||||
}
|
}
|
||||||
|
|
||||||
badCreds = Creds{
|
badCreds = Creds{
|
||||||
|
@ -68,13 +69,21 @@ var (
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
credsWithMultipleAudiences = Creds{
|
||||||
|
User: user.User{
|
||||||
|
ID: "ID-1",
|
||||||
|
Admin: true,
|
||||||
|
},
|
||||||
|
ClientIDs: []string{nonAdminClientID, goodClientID},
|
||||||
|
}
|
||||||
|
|
||||||
disabledCreds = Creds{
|
disabledCreds = Creds{
|
||||||
User: user.User{
|
User: user.User{
|
||||||
ID: "ID-1",
|
ID: "ID-1",
|
||||||
Admin: true,
|
Admin: true,
|
||||||
Disabled: true,
|
Disabled: true,
|
||||||
},
|
},
|
||||||
ClientID: goodClientID,
|
ClientIDs: []string{goodClientID},
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPasswordURL = url.URL{
|
resetPasswordURL = url.URL{
|
||||||
|
@ -87,6 +96,11 @@ var (
|
||||||
Host: goodClientID,
|
Host: goodClientID,
|
||||||
Path: "/callback",
|
Path: "/callback",
|
||||||
}
|
}
|
||||||
|
validRedirURL2 = url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: nonAdminClientID,
|
||||||
|
Path: "/callback",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func makeTestFixtures() (*UsersAPI, *testEmailer) {
|
func makeTestFixtures() (*UsersAPI, *testEmailer) {
|
||||||
|
@ -169,6 +183,17 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
ci2 := client.Client{
|
||||||
|
Credentials: oidc.ClientCredentials{
|
||||||
|
ID: nonAdminClientID,
|
||||||
|
Secret: base64.URLEncoding.EncodeToString([]byte("anothersecret")),
|
||||||
|
},
|
||||||
|
Metadata: oidc.ClientMetadata{
|
||||||
|
RedirectURIs: []url.URL{
|
||||||
|
validRedirURL2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
clientIDGenerator := func(hostport string) (string, error) {
|
clientIDGenerator := func(hostport string) (string, error) {
|
||||||
return hostport, nil
|
return hostport, nil
|
||||||
|
@ -176,7 +201,7 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) {
|
||||||
secGen := func() ([]byte, error) {
|
secGen := func() ([]byte, error) {
|
||||||
return []byte("secret"), nil
|
return []byte("secret"), nil
|
||||||
}
|
}
|
||||||
clientRepo, err := db.NewClientRepoFromClients(dbMap, []client.LoadableClient{{Client: ci}})
|
clientRepo, err := db.NewClientRepoFromClients(dbMap, []client.LoadableClient{{Client: ci}, {Client: ci2}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Failed to create client manager: " + err.Error())
|
panic("Failed to create client manager: " + err.Error())
|
||||||
}
|
}
|
||||||
|
@ -223,6 +248,10 @@ func TestGetUser(t *testing.T) {
|
||||||
id: "NO_ID",
|
id: "NO_ID",
|
||||||
wantErr: ErrorResourceNotFound,
|
wantErr: ErrorResourceNotFound,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
creds: credsWithMultipleAudiences,
|
||||||
|
id: "ID-1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, tt := range tests {
|
for i, tt := range tests {
|
||||||
|
@ -318,6 +347,7 @@ func TestCreateUser(t *testing.T) {
|
||||||
cantEmail bool
|
cantEmail bool
|
||||||
|
|
||||||
wantResponse schema.UserCreateResponse
|
wantResponse schema.UserCreateResponse
|
||||||
|
wantClientID string
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
@ -340,6 +370,29 @@ func TestCreateUser(t *testing.T) {
|
||||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
wantClientID: goodClientID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
creds: credsWithMultipleAudiences,
|
||||||
|
usr: schema.User{
|
||||||
|
Email: "newuser01@example.com",
|
||||||
|
DisplayName: "New User",
|
||||||
|
EmailVerified: true,
|
||||||
|
Admin: false,
|
||||||
|
},
|
||||||
|
redirURL: validRedirURL,
|
||||||
|
|
||||||
|
wantResponse: schema.UserCreateResponse{
|
||||||
|
EmailSent: true,
|
||||||
|
User: &schema.User{
|
||||||
|
Email: "newuser01@example.com",
|
||||||
|
DisplayName: "New User",
|
||||||
|
EmailVerified: true,
|
||||||
|
Admin: false,
|
||||||
|
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantClientID: goodClientID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
creds: goodCreds,
|
creds: goodCreds,
|
||||||
|
@ -362,6 +415,7 @@ func TestCreateUser(t *testing.T) {
|
||||||
},
|
},
|
||||||
ResetPasswordLink: resetPasswordURL.String(),
|
ResetPasswordLink: resetPasswordURL.String(),
|
||||||
},
|
},
|
||||||
|
wantClientID: goodClientID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
creds: goodCreds,
|
creds: goodCreds,
|
||||||
|
@ -397,6 +451,7 @@ func TestCreateUser(t *testing.T) {
|
||||||
if tt.wantErr != nil {
|
if tt.wantErr != nil {
|
||||||
if err != tt.wantErr {
|
if err != tt.wantErr {
|
||||||
t.Errorf("case %d: want=%q, got=%q", i, tt.wantErr, err)
|
t.Errorf("case %d: want=%q, got=%q", i, tt.wantErr, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
tok := ""
|
tok := ""
|
||||||
|
@ -420,11 +475,13 @@ func TestCreateUser(t *testing.T) {
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("case %d: want nil err, got: %q ", i, err)
|
t.Errorf("case %d: want nil err, got: %q ", i, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
newID := response.User.Id
|
newID := response.User.Id
|
||||||
if newID == "" {
|
if newID == "" {
|
||||||
t.Errorf("case %d: expected non-empty newID", i)
|
t.Errorf("case %d: expected non-empty newID", i)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
tt.wantResponse.User.Id = newID
|
tt.wantResponse.User.Id = newID
|
||||||
|
@ -436,7 +493,7 @@ func TestCreateUser(t *testing.T) {
|
||||||
wantEmalier := testEmailer{
|
wantEmalier := testEmailer{
|
||||||
cantEmail: tt.cantEmail,
|
cantEmail: tt.cantEmail,
|
||||||
lastEmail: tt.usr.Email,
|
lastEmail: tt.usr.Email,
|
||||||
lastClientID: tt.creds.ClientID,
|
lastClientID: tt.wantClientID,
|
||||||
lastRedirectURL: tt.redirURL,
|
lastRedirectURL: tt.redirURL,
|
||||||
lastWasInvite: true,
|
lastWasInvite: true,
|
||||||
}
|
}
|
||||||
|
@ -497,6 +554,7 @@ func TestResendEmailInvitation(t *testing.T) {
|
||||||
|
|
||||||
wantResponse schema.ResendEmailInvitationResponse
|
wantResponse schema.ResendEmailInvitationResponse
|
||||||
wantErr error
|
wantErr error
|
||||||
|
wantClientID string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
creds: goodCreds,
|
creds: goodCreds,
|
||||||
|
@ -507,6 +565,7 @@ func TestResendEmailInvitation(t *testing.T) {
|
||||||
wantResponse: schema.ResendEmailInvitationResponse{
|
wantResponse: schema.ResendEmailInvitationResponse{
|
||||||
EmailSent: true,
|
EmailSent: true,
|
||||||
},
|
},
|
||||||
|
wantClientID: goodClientID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
creds: goodCreds,
|
creds: goodCreds,
|
||||||
|
@ -519,6 +578,20 @@ func TestResendEmailInvitation(t *testing.T) {
|
||||||
EmailSent: false,
|
EmailSent: false,
|
||||||
ResetPasswordLink: resetPasswordURL.String(),
|
ResetPasswordLink: resetPasswordURL.String(),
|
||||||
},
|
},
|
||||||
|
wantClientID: goodClientID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
creds: credsWithMultipleAudiences,
|
||||||
|
userID: "ID-1",
|
||||||
|
email: "id1@example.com",
|
||||||
|
redirURL: validRedirURL,
|
||||||
|
cantEmail: true,
|
||||||
|
|
||||||
|
wantResponse: schema.ResendEmailInvitationResponse{
|
||||||
|
EmailSent: false,
|
||||||
|
ResetPasswordLink: resetPasswordURL.String(),
|
||||||
|
},
|
||||||
|
wantClientID: goodClientID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
creds: badCreds,
|
creds: badCreds,
|
||||||
|
@ -576,7 +649,7 @@ func TestResendEmailInvitation(t *testing.T) {
|
||||||
wantEmailer := testEmailer{
|
wantEmailer := testEmailer{
|
||||||
cantEmail: tt.cantEmail,
|
cantEmail: tt.cantEmail,
|
||||||
lastEmail: tt.email,
|
lastEmail: tt.email,
|
||||||
lastClientID: tt.creds.ClientID,
|
lastClientID: tt.wantClientID,
|
||||||
lastRedirectURL: tt.redirURL,
|
lastRedirectURL: tt.redirURL,
|
||||||
lastWasInvite: true,
|
lastWasInvite: true,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue