From e8cb0c831ca914522babce101cf8533acbf94136 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Tue, 7 Jul 2026 10:58:39 +0200 Subject: [PATCH] fix: re-check api permissions on access token refresh --- backend/internal/oidc/token_handler.go | 41 +++++++++++++++++++ backend/internal/oidc/token_handler_test.go | 45 ++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/backend/internal/oidc/token_handler.go b/backend/internal/oidc/token_handler.go index c01df191..6dd170ba 100644 --- a/backend/internal/oidc/token_handler.go +++ b/backend/internal/oidc/token_handler.go @@ -1,6 +1,7 @@ package oidc import ( + "context" "log/slog" "slices" @@ -52,6 +53,13 @@ func (h *tokenHandler) token(c *gin.Context) { 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 // 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 @@ -103,3 +111,36 @@ func (h *tokenHandler) token(c *gin.Context) { 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 +} diff --git a/backend/internal/oidc/token_handler_test.go b/backend/internal/oidc/token_handler_test.go index 54ca1c39..ed5f2391 100644 --- a/backend/internal/oidc/token_handler_test.go +++ b/backend/internal/oidc/token_handler_test.go @@ -551,7 +551,7 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) { Secret: []byte(secret), }) 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{ "grant_type": {"refresh_token"}, @@ -637,4 +637,47 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) { require.Empty(t, body["access_token"]) 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"]) + }) }