fix: re-check api permissions on access token refresh

This commit is contained in:
Elias Schneider
2026-07-07 10:58:39 +02:00
parent 337fc6fd1e
commit e8cb0c831c
2 changed files with 85 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
package oidc package oidc
import ( import (
"context"
"log/slog" "log/slog"
"slices" "slices"
@@ -52,6 +53,13 @@ func (h *tokenHandler) token(c *gin.Context) {
return return
} }
err = h.validateRefreshAPIGrant(ctx, client, accessRequest)
if err != nil {
slog.WarnContext(ctx, "Rejected refresh token request: API grant is no longer allowed for the user subject", "error", err.Error())
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
return
}
// The client credentials grant has no authorize step so the RFC 8707 resource is resolved here to stamp the API audience and limit the granted scope to what the client is allowed for that API // The client credentials grant has no authorize step so the RFC 8707 resource is resolved here to stamp the API audience and limit the granted scope to what the client is allowed for that API
// It resolves against the client-subject grants: a permission delegated by users does not let the client act as itself // It resolves against the client-subject grants: a permission delegated by users does not let the client act as itself
// The other grants had their audience and scope resolved at authorize or device time and restored from storage, so they must be left untouched // The other grants had their audience and scope resolved at authorize or device time and restored from storage, so they must be left untouched
@@ -103,3 +111,36 @@ func (h *tokenHandler) token(c *gin.Context) {
h.provider.WriteAccessResponse(ctx, c.Writer, accessRequest, response) h.provider.WriteAccessResponse(ctx, c.Writer, accessRequest, response)
} }
func (h *tokenHandler) validateRefreshAPIGrant(ctx context.Context, client Client, accessRequest fosite.AccessRequester) error {
if !accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeRefreshToken)) {
return nil
}
issuer := ""
if h.claimsService != nil {
issuer = h.claimsService.baseURL
}
resource, err := refreshGrantResource(client.GetID(), issuer, accessRequest.GetGrantedAudience())
if err != nil {
return err
}
_, _, err = resolveResource(ctx, h.apiAccess, client.GetID(), resource, accessRequest.GetGrantedScopes(), SubjectTypeUser)
return err
}
func refreshGrantResource(clientID, issuer string, grantedAudience fosite.Arguments) (string, error) {
resource := ""
for _, audience := range grantedAudience {
if audience == "" || audience == clientID || audience == issuer {
continue
}
if resource != "" && resource != audience {
return "", fosite.ErrInvalidTarget.WithHint("Refresh-token grants may only target one API resource.")
}
resource = audience
}
return resource, nil
}

View File

@@ -551,7 +551,7 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
Secret: []byte(secret), Secret: []byte(secret),
}) })
require.NoError(t, err) require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil) handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
form := url.Values{ form := url.Values{
"grant_type": {"refresh_token"}, "grant_type": {"refresh_token"},
@@ -637,4 +637,47 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
require.Empty(t, body["access_token"]) require.Empty(t, body["access_token"])
require.Equal(t, "invalid_scope", body["error"]) require.Equal(t, "invalid_scope", body["error"])
}) })
t.Run("revoking only the user-delegated API grant makes the next refresh fail", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
const clientID, userID = "client-user-revoked", "user-user-revoked"
seedUserAndClient(t, db, clientID, userID)
clientOnlyAPI := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
apiResource: {
SubjectTypeClient: {"read:orders"},
},
}}
token := mintRefreshToken(t, db, clientID, userID,
fosite.Arguments{"openid", "read:orders"},
fosite.Arguments{apiResource},
)
body := doRefresh(t, db, clientOnlyAPI, clientID, token, nil)
require.Empty(t, body["access_token"])
require.Equal(t, "access_denied", body["error"])
})
t.Run("refresh rechecks the granted scope against the granted API audience", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
const clientID, userID = "client-split-grant", "user-split-grant"
seedUserAndClient(t, db, clientID, userID)
splitGrantAPI := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
apiResource: {
SubjectTypeUser: {"write:orders"},
},
"https://api.inventory.example.com": {
SubjectTypeUser: {"read:orders"},
},
}}
token := mintRefreshToken(t, db, clientID, userID,
fosite.Arguments{"openid", "read:orders"},
fosite.Arguments{apiResource},
)
body := doRefresh(t, db, splitGrantAPI, clientID, token, nil)
require.Empty(t, body["access_token"])
require.Equal(t, "invalid_scope", body["error"])
})
} }