diff --git a/backend/internal/oidc/preview.go b/backend/internal/oidc/preview.go index 50e0114c..ee1b0595 100644 --- a/backend/internal/oidc/preview.go +++ b/backend/internal/oidc/preview.go @@ -74,14 +74,16 @@ func (b *ClientPreviewBuilder) BuildClientPreview(ctx context.Context, client mo } func (b *ClientPreviewBuilder) validatedScopes(ctx context.Context, client model.OidcClient, scopes []string) (fosite.Arguments, error) { - scopeStrategy := b.strategies.config.GetScopeStrategy(ctx) - clientScopes := Client{OidcClient: client}.GetScopes() + // Mirror the authorize endpoint's scope handling so the preview matches the tokens a real + // authorization would produce, including dropping unknown scopes when configured. + requested := fosite.Arguments(fosite.RemoveEmpty(scopes)) + validated, err := fosite.FilterRequestedScopes(b.strategies.config.GetScopeStrategy(ctx), Client{OidcClient: client}, requested, b.strategies.config.GetIgnoreUnknownScopes(ctx)) + if err != nil { + return nil, err + } - scopeArgs := make(fosite.Arguments, 0, len(scopes)) - for _, scope := range fosite.RemoveEmpty(scopes) { - if !scopeStrategy(clientScopes, scope) { - return nil, fosite.ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope) - } + scopeArgs := make(fosite.Arguments, 0, len(validated)) + for _, scope := range validated { if !scopeArgs.Has(scope) { scopeArgs = append(scopeArgs, scope) } diff --git a/backend/internal/oidc/preview_test.go b/backend/internal/oidc/preview_test.go index 45b797e2..44b4ba62 100644 --- a/backend/internal/oidc/preview_test.go +++ b/backend/internal/oidc/preview_test.go @@ -60,7 +60,7 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) { require.Equal(t, true, preview.UserInfo["email_verified"]) } -func TestClientPreviewBuilderRejectsInvalidScope(t *testing.T) { +func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) { db := testutils.NewDatabaseForTest(t) signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) @@ -72,13 +72,20 @@ func TestClientPreviewBuilderRejectsInvalidScope(t *testing.T) { }) require.NoError(t, err) + require.NoError(t, db.Create(&model.User{ + Base: model.Base{ID: "test-user"}, + Username: "test-user", + }).Error) + + // The preview mirrors the authorize endpoint: unknown scopes are dropped + // from the previewed tokens instead of failing the preview. builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies) - _, err = builder.BuildClientPreview(t.Context(), model.OidcClient{ + preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{ Base: model.Base{ID: "test-client"}, Name: "Test Client", }, "test-user", []string{"openid", "unknown"}, "") - require.Error(t, err) - require.ErrorContains(t, err, "invalid_scope") + require.NoError(t, err) + require.ElementsMatch(t, []string{"openid"}, stringSliceClaim(t, preview.AccessToken["scp"])) } func stringSliceClaim(t *testing.T, value any) []string { diff --git a/backend/internal/oidc/provider.go b/backend/internal/oidc/provider.go index 249e33bd..a0be1bc8 100644 --- a/backend/internal/oidc/provider.go +++ b/backend/internal/oidc/provider.go @@ -46,6 +46,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign AccessTokenIssuer: config.BaseURL, TokenURL: config.TokenBaseURL + "/api/oidc/token", ScopeStrategy: fosite.ExactScopeStrategy, + IgnoreUnknownScopes: true, AudienceMatchingStrategy: fosite.ExactAudienceMatchingStrategy, RedirectURIMatcher: matchRedirectURI, EnforcePKCEForPublicClients: true, diff --git a/backend/internal/oidc/provider_test.go b/backend/internal/oidc/provider_test.go index 33f63e1a..41d42c13 100644 --- a/backend/internal/oidc/provider_test.go +++ b/backend/internal/oidc/provider_test.go @@ -283,3 +283,34 @@ func generateEd25519TestKey(t *testing.T) any { require.NoError(t, err) return key } + +func TestProviderIgnoresUnknownScopes(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: "test-client"}, + Name: "Test Client", + CallbackURLs: model.UrlList{"https://app.example.com/callback"}, + }).Error) + + provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret + BaseURL: "https://issuer.example.com", + TokenBaseURL: "https://issuer.example.com", + Secret: []byte("test-secret"), + }) + require.NoError(t, err) + + // Clients such as MCP clients blindly request scopes Pocket ID does not support, like + // "phone". These are dropped instead of failing the request with invalid_scope. + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid+email+phone&state=state-with-enough-entropy&redirect_uri=https://app.example.com/callback", + nil, + ) + + ar, err := provider.NewAuthorizeRequest(req.Context(), req) + require.NoError(t, err) + require.Equal(t, fosite.Arguments{"openid", "email"}, ar.GetRequestedScopes()) +}