From 9421d43f8ecab0f3d245d79196cb2f33818c8054 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 17 Aug 2026 08:34:28 +0200 Subject: [PATCH] fix: ignore trailing slash in resources --- backend/internal/api/service.go | 6 ++++++ backend/internal/api/service_test.go | 15 ++++++++++++++- backend/internal/oidc/api_resource.go | 3 +++ backend/internal/oidc/api_resource_test.go | 11 +++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/backend/internal/api/service.go b/backend/internal/api/service.go index 52cb9919..613284a0 100644 --- a/backend/internal/api/service.go +++ b/backend/internal/api/service.go @@ -88,6 +88,9 @@ func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err } func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) { + // Store one canonical resource identifier so trailing-slash variants cannot create separate audiences + input.Resource = strings.TrimRight(input.Resource, "/") + // Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens if isIssuerAudience(input.Resource, s.issuer) { return API{}, apperror.InvalidField("resource", "reserved", "is reserved by Pocket ID and cannot be used for a custom API") @@ -443,6 +446,9 @@ func (s *Service) DescribePermissions(ctx context.Context, audience string, keys return nil, nil } + // Match scope information against the same canonical audience used during resource resolution + audience = strings.TrimRight(audience, "/") + var permissions []Permission err := s.db.WithContext(ctx). Model(&Permission{}). diff --git a/backend/internal/api/service_test.go b/backend/internal/api/service_test.go index cb7c7f05..d3ffe035 100644 --- a/backend/internal/api/service_test.go +++ b/backend/internal/api/service_test.go @@ -250,6 +250,19 @@ func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) { } } +func TestCreateTrimsResourceTrailingSlashes(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + svc := New(Dependencies{DB: db}).service + + created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com///"}) + require.NoError(t, err) + assert.Equal(t, "https://api.orders.example.com", created.Audience) + + // A trailing-slash variant must conflict with the canonical resource instead of creating a second audience + _, err = svc.Create(t.Context(), apiCreateDto{Name: "Duplicate", Resource: "https://api.orders.example.com/"}) + require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse)) +} + func TestDescribePermissions(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service @@ -263,7 +276,7 @@ func TestDescribePermissions(t *testing.T) { }}) require.NoError(t, err) - infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com", []string{"read:orders", "unknown"}) + infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com/", []string{"read:orders", "unknown"}) require.NoError(t, err) require.Len(t, infos, 1) assert.Equal(t, "read:orders", infos[0].Key) diff --git a/backend/internal/oidc/api_resource.go b/backend/internal/oidc/api_resource.go index e2e9d854..c4f5003c 100644 --- a/backend/internal/oidc/api_resource.go +++ b/backend/internal/oidc/api_resource.go @@ -3,6 +3,7 @@ package oidc import ( "context" "slices" + "strings" "github.com/ory/fosite" "github.com/pocket-id/pocket-id/backend/internal/dto" @@ -53,6 +54,8 @@ func resolveResource(ctx context.Context, tx *gorm.DB, provider APIAccessProvide if !fosite.IsValidResourceIndicatorURI(resource) || provider == nil { return "", nil, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource) } + // Resolve every trailing-slash variant against the same canonical resource and stamp that value into the token audience + resource = strings.TrimRight(resource, "/") allowedScopes, apiExists, err := provider.AllowedScopesForAudience(ctx, tx, clientID, resource, subjectType) if err != nil { diff --git a/backend/internal/oidc/api_resource_test.go b/backend/internal/oidc/api_resource_test.go index b19e7df7..52622fa8 100644 --- a/backend/internal/oidc/api_resource_test.go +++ b/backend/internal/oidc/api_resource_test.go @@ -95,6 +95,17 @@ func TestResolveResourceCustomAPIGrantsValidScopes(t *testing.T) { assert.ElementsMatch(t, []string{"openid", "read:orders"}, granted) } +func TestResolveResourceTrimsTrailingSlashes(t *testing.T) { + provider := userAccess(map[string][]string{ + "https://api.orders.example.com": {"read:orders"}, + }) + + audience, granted, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com///", []string{"read:orders"}, SubjectTypeUser) + require.NoError(t, err) + assert.Equal(t, "https://api.orders.example.com", audience) + assert.Equal(t, []string{"read:orders"}, granted) +} + func TestResolveResourceRejectsScopeFromAnotherAPI(t *testing.T) { provider := userAccess(map[string][]string{ "https://api.orders.example.com": {"read:orders", "write:orders"},