Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

user api: accept bearer tokens with multiple audiences #531

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
59 changes: 43 additions & 16 deletions server/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package server

import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
Expand Down Expand Up @@ -262,18 +261,32 @@ func (s *UserMgmtServer) getCreds(r *http.Request, requiresAdmin bool) (api.Cred
return api.Creds{}, api.ErrorUnauthorized
}

clientID, ok, err := claims.StringClaim("aud")
if err != nil {
log.Errorf("userMgmtServer: GetCreds err: %q", err)
return api.Creds{}, err
// The "aud" claim is allowed to be both a list of clients or a single client. Check for both cases.
clientIDs, ok, err := claims.StringsClaim("aud")
if err != nil || !ok {
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 == "" {
return api.Creds{}, errors.New("no aud(client ID) claim")
if len(clientIDs) == 0 {
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 {
log.Errorf("userMgmtServer: GetCreds err: %q", err)
log.Errorf("userMgmtServer: GetCreds err: failed to verify token %q", err)
return api.Creds{}, api.ErrorUnauthorized
}

Expand All @@ -295,18 +308,32 @@ func (s *UserMgmtServer) getCreds(r *http.Request, requiresAdmin bool) (api.Cred
return api.Creds{}, err
}

isAdmin, err := s.cm.IsDexAdmin(clientID)
if err != nil {
log.Errorf("userMgmtServer: GetCreds err: %q", err)
return api.Creds{}, err
i := 0
for _, clientID := range clientIDs {
// Make sure the client actually exists.
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{
ClientID: clientID,
User: usr,
ClientIDs: clientIDs,
User: usr,
}, nil
}

Expand Down
43 changes: 25 additions & 18 deletions user/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ type Emailer interface {
}

type Creds struct {
ClientID string
User user.User
// IDTokens can be issued for multiple clients.
ClientIDs []string
User user.User
}

// TODO(ericchiang): Don't pass a dbMap. See #385.
Expand Down Expand Up @@ -144,6 +145,22 @@ func (u *UsersAPI) DisableUser(creds Creds, userID string, disable bool) (schema
}, 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) {
log.Infof("userAPI: CreateUser")
if !u.Authorize(creds) {
Expand All @@ -155,14 +172,9 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s
return schema.UserCreateResponse{}, mapError(err)
}

metadata, err := u.clientManager.Metadata(creds.ClientID)
if err != nil {
return schema.UserCreateResponse{}, mapError(err)
}

validRedirURL, err := client.ValidRedirectURL(&redirURL, metadata.RedirectURIs)
clientID, err := validRedirectURL(u.clientManager, redirURL, creds.ClientIDs)
if err != nil {
return schema.UserCreateResponse{}, ErrorInvalidRedirectURL
return schema.UserCreateResponse{}, err
}

id, err := u.userManager.CreateUser(schemaUserToUser(usr), user.Password(hash), u.localConnectorID)
Expand All @@ -177,7 +189,7 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s

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.
emailSent := err == nil && url == nil
Expand All @@ -200,14 +212,9 @@ func (u *UsersAPI) ResendEmailInvitation(creds Creds, userID string, redirURL ur
return schema.ResendEmailInvitationResponse{}, ErrorUnauthorized
}

metadata, err := u.clientManager.Metadata(creds.ClientID)
if err != nil {
return schema.ResendEmailInvitationResponse{}, mapError(err)
}

validRedirURL, err := client.ValidRedirectURL(&redirURL, metadata.RedirectURIs)
clientID, err := validRedirectURL(u.clientManager, redirURL, creds.ClientIDs)
if err != nil {
return schema.ResendEmailInvitationResponse{}, ErrorInvalidRedirectURL
return schema.ResendEmailInvitationResponse{}, err
}

// Retrieve user to check if it's already created
Expand All @@ -221,7 +228,7 @@ func (u *UsersAPI) ResendEmailInvitation(creds Creds, userID string, redirURL ur
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.
emailSent := err == nil && url == nil
Expand Down
87 changes: 80 additions & 7 deletions user/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,16 @@ func (t *testEmailer) sendEmail(email string, redirectURL url.URL, clientID stri
}

var (
clock = clockwork.NewFakeClock()
goodClientID = "client.example.com"
clock = clockwork.NewFakeClock()
goodClientID = "client.example.com"
nonAdminClientID = "user.example.com"

goodCreds = Creds{
User: user.User{
ID: "ID-1",
Admin: true,
},
ClientID: goodClientID,
ClientIDs: []string{goodClientID},
}

badCreds = Creds{
Expand All @@ -68,13 +69,21 @@ var (
},
}

credsWithMultipleAudiences = Creds{
User: user.User{
ID: "ID-1",
Admin: true,
},
ClientIDs: []string{nonAdminClientID, goodClientID},
}

disabledCreds = Creds{
User: user.User{
ID: "ID-1",
Admin: true,
Disabled: true,
},
ClientID: goodClientID,
ClientIDs: []string{goodClientID},
}

resetPasswordURL = url.URL{
Expand All @@ -87,6 +96,11 @@ var (
Host: goodClientID,
Path: "/callback",
}
validRedirURL2 = url.URL{
Scheme: "http",
Host: nonAdminClientID,
Path: "/callback",
}
)

func makeTestFixtures() (*UsersAPI, *testEmailer) {
Expand Down Expand Up @@ -169,14 +183,25 @@ 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) {
return hostport, nil
}
secGen := func() ([]byte, error) {
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 {
panic("Failed to create client manager: " + err.Error())
}
Expand Down Expand Up @@ -223,6 +248,10 @@ func TestGetUser(t *testing.T) {
id: "NO_ID",
wantErr: ErrorResourceNotFound,
},
{
creds: credsWithMultipleAudiences,
id: "ID-1",
},
}

for i, tt := range tests {
Expand Down Expand Up @@ -318,6 +347,7 @@ func TestCreateUser(t *testing.T) {
cantEmail bool

wantResponse schema.UserCreateResponse
wantClientID string
wantErr error
}{
{
Expand All @@ -340,6 +370,29 @@ func TestCreateUser(t *testing.T) {
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,
Expand All @@ -362,6 +415,7 @@ func TestCreateUser(t *testing.T) {
},
ResetPasswordLink: resetPasswordURL.String(),
},
wantClientID: goodClientID,
},
{
creds: goodCreds,
Expand Down Expand Up @@ -397,6 +451,7 @@ func TestCreateUser(t *testing.T) {
if tt.wantErr != nil {
if err != tt.wantErr {
t.Errorf("case %d: want=%q, got=%q", i, tt.wantErr, err)
continue
}

tok := ""
Expand All @@ -420,11 +475,13 @@ func TestCreateUser(t *testing.T) {
}
if err != nil {
t.Errorf("case %d: want nil err, got: %q ", i, err)
continue
}

newID := response.User.Id
if newID == "" {
t.Errorf("case %d: expected non-empty newID", i)
continue
}

tt.wantResponse.User.Id = newID
Expand All @@ -436,7 +493,7 @@ func TestCreateUser(t *testing.T) {
wantEmalier := testEmailer{
cantEmail: tt.cantEmail,
lastEmail: tt.usr.Email,
lastClientID: tt.creds.ClientID,
lastClientID: tt.wantClientID,
lastRedirectURL: tt.redirURL,
lastWasInvite: true,
}
Expand Down Expand Up @@ -497,6 +554,7 @@ func TestResendEmailInvitation(t *testing.T) {

wantResponse schema.ResendEmailInvitationResponse
wantErr error
wantClientID string
}{
{
creds: goodCreds,
Expand All @@ -507,6 +565,7 @@ func TestResendEmailInvitation(t *testing.T) {
wantResponse: schema.ResendEmailInvitationResponse{
EmailSent: true,
},
wantClientID: goodClientID,
},
{
creds: goodCreds,
Expand All @@ -519,6 +578,20 @@ func TestResendEmailInvitation(t *testing.T) {
EmailSent: false,
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,
Expand Down Expand Up @@ -576,7 +649,7 @@ func TestResendEmailInvitation(t *testing.T) {
wantEmailer := testEmailer{
cantEmail: tt.cantEmail,
lastEmail: tt.email,
lastClientID: tt.creds.ClientID,
lastClientID: tt.wantClientID,
lastRedirectURL: tt.redirURL,
lastWasInvite: true,
}
Expand Down