diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go index 81235bff..c5b0a5b3 100644 --- a/backend/internal/oidc/device_service.go +++ b/backend/internal/oidc/device_service.go @@ -50,6 +50,20 @@ func (s *deviceService) createDeviceAuthorization(ctx context.Context, req *http return nil, request, err } + client := request.GetClient().(Client) + resource, err := request.GetResource() + if err != nil { + return nil, request, err + } + audience, grantedScopes, _, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes()) + if err != nil { + if resource != "" && errors.Is(err, fosite.ErrAccessDenied) { + return nil, request, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource) + } + return nil, request, err + } + grantResourceIndicator(request, audience, grantedScopes) + session := NewEmptySession() response, err := s.provider.NewDeviceResponse(ctx, request, session) if err != nil { diff --git a/backend/internal/oidc/device_service_test.go b/backend/internal/oidc/device_service_test.go index 6fb392fb..f7c45479 100644 --- a/backend/internal/oidc/device_service_test.go +++ b/backend/internal/oidc/device_service_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/ory/fosite" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -84,31 +85,38 @@ func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing. require.Equal(t, reauthenticatedAt, session.IDTokenClaims().AuthTime) } +func TestDeviceServiceCreateRejectsResourceWithoutUserDelegatedGrant(t *testing.T) { + const ( + userID = "test-user" + clientID = "test-client" + audience = "https://api.orders.example.com" + ) + apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{ + audience: { + SubjectTypeClient: {"write:orders"}, + }, + }} + service, store, _ := newTestDeviceService(t, clientID, userID, false, nil, apiAccess) + + form := url.Values{ + "client_id": {clientID}, + "resource": {audience}, + "scope": {"write:orders"}, + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/device/authorize", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + _, _, err := service.createDeviceAuthorization(t.Context(), req) + require.ErrorIs(t, err, fosite.ErrInvalidTarget) + + var count int64 + require.NoError(t, store.db.Model(&OAuth2Session{}).Where("kind IN ?", []string{sessionKindDeviceCode, sessionKindUserCode}).Count(&count).Error) + require.Zero(t, count) +} + func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, requiresReauthentication bool, reauth ReauthenticationTokenConsumer) (*deviceService, *Store, *oidcProvider, string, string) { t.Helper() - db := testutils.NewDatabaseForTest(t) - require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error) - require.NoError(t, db.Create(&model.OidcClient{ - Base: model.Base{ID: clientID}, - Name: "Test Client", - IsPublic: true, - RequiresReauthentication: requiresReauthentication, - }).Error) - - store := NewStore(db, nil) - signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - provider, err := newProvider(store, 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) - - claimsService := newClaimsService(db, nil, "", nil) - authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}, nil) - service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db) + service, store, provider := newTestDeviceService(t, clientID, userID, requiresReauthentication, reauth, nil) form := url.Values{ "client_id": {clientID}, @@ -121,3 +129,32 @@ func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, require return service, store, provider, response.UserCode, response.DeviceCode } + +func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthentication bool, reauth ReauthenticationTokenConsumer, apiAccess APIAccessProvider) (*deviceService, *Store, *oidcProvider) { + t.Helper() + + db := testutils.NewDatabaseForTest(t) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error) + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: clientID}, + Name: "Test Client", + IsPublic: true, + RequiresReauthentication: requiresReauthentication, + }).Error) + + store := NewStore(db, apiAccess) + signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + provider, err := newProvider(store, 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) + + claimsService := newClaimsService(db, nil, "", nil) + authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}, apiAccess) + service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db) + + return service, store, provider +}