Skip to content

feat: add apps management page and device authorization flow#146

Open
Zingzy wants to merge 3 commits intomainfrom
feat/linked-apps
Open

feat: add apps management page and device authorization flow#146
Zingzy wants to merge 3 commits intomainfrom
feat/linked-apps

Conversation

@Zingzy
Copy link
Copy Markdown
Member

@Zingzy Zingzy commented Apr 4, 2026

  • Created a new apps management page at /dashboard/apps to manage connected apps and discover available apps.
  • Added sidebar navigation link to the apps management page.
  • Implemented device consent page for authorizing apps with user permissions.
  • Added error handling page for authorization errors.
  • Updated integration tests to cover the new device authorization flow, including consent and revocation of app access.
  • Enhanced the app grant repository with necessary indexes for user and app associations.
  • Modified authentication service to support app-specific token management during refresh and exchange processes.

Summary by Sourcery

Introduce an app registry–backed apps management dashboard and extend the device authorization flow with consent, revocation, and app-scoped token handling.

New Features:

  • Add a dashboard Apps page to list connected, available, and coming-soon applications with revoke actions.
  • Introduce device auth consent and error pages to guide users through authorizing third-party apps.
  • Provide an app registry loaded from YAML to define first-party and ecosystem apps.

Enhancements:

  • Extend the device auth flow to validate apps against the registry, enforce redirect URI allowlists, and require explicit user consent where no prior grant exists.
  • Scope device auth tokens and refresh tokens to specific apps and enforce active grant checks during exchange and refresh.
  • Track per-app user grants in a dedicated repository with indexes and soft deletion to support revocation and analytics.
  • Expose an endpoint for revoking app access from the dashboard that invalidates associated device tokens.
  • Update dashboard navigation and styling to support the new Apps page.

Tests:

  • Expand unit and integration tests to cover app-scoped device auth flows, consent handling, revocation behavior, and the new app-grants indexes.

Summary by CodeRabbit

  • New Features
    • Device authentication flow for third‑party apps with consent, per‑app allowlisting, and app‑scoped token lifecycle (issue/refresh/revoke)
    • Apps dashboard page with responsive app cards, connect/disconnect actions, and sidebar navigation link
    • App registry configuration and new consent/error pages plus client-side behaviors and dashboard styles

- Created a new apps management page at /dashboard/apps to manage connected apps and discover available apps.
- Added sidebar navigation link to the apps management page.
- Implemented device consent page for authorizing apps with user permissions.
- Added error handling page for authorization errors.
- Updated integration tests to cover the new device authorization flow, including consent and revocation of app access.
- Enhanced the app grant repository with necessary indexes for user and app associations.
- Modified authentication service to support app-specific token management during refresh and exchange processes.
Copilot AI review requested due to automatic review settings April 4, 2026 21:19
@Zingzy Zingzy self-assigned this Apr 4, 2026
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai bot commented Apr 4, 2026

Reviewer's Guide

Implements a full app registry and device-authorization consent system, including a new dashboard Apps page, per-app device tokens and grants, CSRF-protected consent and revocation endpoints, and associated persistence, token, and test updates.

Sequence diagram for device authorization consent and token exchange flow

sequenceDiagram
    actor User
    participant ClientApp
    participant Browser as Browser_UI
    participant AuthRoutes as AuthRoutes_device
    participant AuthService
    participant AppGrantRepo
    participant TokenRepo
    participant TokenFactory

    User->>ClientApp: Initiate sign_in
    ClientApp->>Browser: Open /auth/device/login?app_id&state&redirect_uri

    Browser->>AuthRoutes: GET /auth/device/login
    AuthRoutes->>AuthRoutes: _get_device_app(app_id)
    AuthRoutes->>AuthRoutes: _validate_redirect_uri(redirect_uri, app)
    alt user_not_logged_in
        AuthRoutes-->>Browser: 302 Redirect /?next=/auth/device/login...
        Browser->>AuthRoutes: User logs in then retries /auth/device/login
    end

    AuthRoutes->>AppGrantRepo: find_active_grant(user_id, app_id)
    alt grant_exists
        AppGrantRepo-->>AuthRoutes: AppGrantDoc
        AuthRoutes->>AuthService: get_user_profile(user_id)
        AuthRoutes->>AuthService: create_device_auth_code(user_id, email, app_id)
        AuthService->>TokenRepo: delete_by_user(user_id, token_type_device_auth, app_id)
        AuthService->>TokenRepo: create(device_auth_token{app_id})
        AuthService-->>AuthRoutes: raw_code
        AuthRoutes->>AuthRoutes: _build_callback_redirect(code, state, redirect_uri, app)
        AuthRoutes-->>Browser: 302 Redirect /auth/device/callback?code&state or redirect_uri
    else no_grant
        AppGrantRepo-->>AuthRoutes: None
        AuthRoutes->>AuthService: get_user_profile(user_id)
        AuthRoutes-->>Browser: Render device_consent.html with csrf_token
        User->>Browser: Click Allow
        Browser->>AuthRoutes: POST /auth/device/consent (app_id, state, redirect_uri, csrf_token)
        AuthRoutes->>AuthRoutes: Validate_csrf_cookie
        AuthRoutes->>AuthRoutes: _get_device_app(app_id)
        AuthRoutes->>AuthRoutes: _validate_redirect_uri(redirect_uri, app)
        AuthRoutes->>AppGrantRepo: create_or_reactivate(user_id, app_id)
        AppGrantRepo-->>AuthRoutes: AppGrantDoc
        AuthRoutes->>AuthService: get_user_profile(user_id)
        AuthRoutes->>AuthService: create_device_auth_code(user_id, email, app_id)
        AuthService->>TokenRepo: delete_by_user(user_id, token_type_device_auth, app_id)
        AuthService->>TokenRepo: create(device_auth_token{app_id})
        AuthService-->>AuthRoutes: raw_code
        AuthRoutes->>AuthRoutes: _build_callback_redirect(code, state, redirect_uri, app)
        AuthRoutes-->>Browser: 302 Redirect with code&state
    end

    ClientApp->>AuthRoutes: POST /auth/device/token (code)
    AuthRoutes->>AuthService: exchange_device_code(code)
    AuthService->>TokenRepo: find_and_consume_device_auth_token(code)
    TokenRepo-->>AuthService: VerificationTokenDoc{user_id, app_id}
    AuthService->>AuthService: load UserDoc
    AuthService->>TokenFactory: issue_tokens(user, amr_ext, app_id)
    TokenFactory-->>AuthService: access_token, refresh_token
    AuthService-->>AuthRoutes: user, access_token, refresh_token, app_id

    alt app_id_present
        AuthRoutes->>AppGrantRepo: find_active_grant(user_id, app_id)
        AppGrantRepo-->>AuthRoutes: AppGrantDoc or None
        alt grant_active
            AuthRoutes->>AppGrantRepo: touch_last_used(user_id, app_id)
        else grant_revoked
            AuthRoutes-->>ClientApp: AuthenticationError app access has been revoked
        end
    end
    AuthRoutes-->>ClientApp: 200 DeviceTokenResponse(access_token, refresh_token, user)
Loading

Sequence diagram for app token refresh and revocation from dashboard

sequenceDiagram
    actor User
    participant Browser as Browser_Dashboard
    participant DashboardRoutes
    participant AuthRoutes as AuthRoutes_device
    participant AuthService
    participant AppGrantRepo
    participant TokenRepo

    %% Dashboard Apps page
    User->>Browser: Open /dashboard/apps
    Browser->>DashboardRoutes: GET /dashboard/apps
    DashboardRoutes->>AppGrantRepo: find_active_for_user(user_id)
    AppGrantRepo-->>DashboardRoutes: list AppGrantDoc
    DashboardRoutes-->>Browser: Render apps.html (connected, available, coming_soon)

    %% Device refresh used by external app
    participant ClientApp
    ClientApp->>AuthRoutes: POST /auth/device/refresh (refresh_token)
    AuthRoutes->>AuthService: refresh_token(refresh_token)
    AuthService->>AuthService: verify_refresh_jwt(claims)
    AuthService->>AuthService: load UserDoc
    AuthService->>TokenFactory: issue_tokens(user, amr_from_claims, app_id_from_claims)
    TokenFactory-->>AuthService: new_access, new_refresh
    AuthService-->>AuthRoutes: user, new_access, new_refresh, app_id

    alt app_id_present
        AuthRoutes->>AppGrantRepo: find_active_grant(user_id, app_id)
        AppGrantRepo-->>AuthRoutes: AppGrantDoc or None
        alt grant_active
            AuthRoutes->>AppGrantRepo: touch_last_used(user_id, app_id)
            AuthRoutes-->>ClientApp: 200 DeviceRefreshResponse
        else grant_revoked
            AuthRoutes-->>ClientApp: AuthenticationError app access has been revoked
        end
    else no_app_id
        AuthRoutes-->>ClientApp: 200 DeviceRefreshResponse
    end

    %% Revocation from dashboard
    User->>Browser: Click Disconnect on app
    Browser->>AuthRoutes: POST /auth/device/revoke (form app_id, header x_requested_with=fetch)
    AuthRoutes->>AuthRoutes: Validate_csrf_header
    AuthRoutes->>AppGrantRepo: revoke(user_id, app_id)
    AppGrantRepo-->>AuthRoutes: revoked_bool
    alt revoked
        AuthRoutes->>AuthService: revoke_device_tokens(user_id, app_id)
        AuthService->>TokenRepo: delete_by_user(user_id, token_type_device_auth, app_id)
        TokenRepo-->>AuthService: deleted_count
        AuthService-->>AuthRoutes: deleted_count
        AuthRoutes-->>Browser: 200 {success True}
    else not_found
        AuthRoutes-->>Browser: 404 {error no active grant found}
    end
Loading

Entity relationship diagram for app grants and device auth tokens

erDiagram
    UserDoc ||--o{ AppGrantDoc : has
    UserDoc ||--o{ VerificationTokenDoc : has

    AppGrantDoc {
        string id
        string user_id
        string app_id
        datetime granted_at
        datetime last_used_at
        datetime revoked_at
    }

    VerificationTokenDoc {
        string id
        string user_id
        string email
        string token_hash
        string token_type
        datetime expires_at
        datetime created_at
        datetime used_at
        int attempts
        string app_id
    }

    UserDoc {
        string id
        string email
        string status
        bool email_verified
    }

    AppRegistry ||--o{ AppGrantDoc : referenced_by
    AppRegistry {
        string app_id
        string name
        string status
        string type
    }
Loading

Class diagram for app registry, grants, and token handling

classDiagram
    class AppStatus {
        <<enumeration>>
        LIVE
        COMING_SOON
    }

    class AppType {
        <<enumeration>>
        DEVICE_AUTH
    }

    class AppEntry {
        +string name
        +string icon
        +string description
        +bool verified
        +AppStatus status
        +AppType type
        +list~string~ redirect_uris
        +dict~string,string~ links
        +list~string~ permissions
        +bool is_live_device_app()
    }

    class AppGrantDoc {
        +PyObjectId id
        +PyObjectId user_id
        +string app_id
        +datetime granted_at
        +datetime last_used_at
        +datetime revoked_at
    }

    class AppGrantRepository {
        +AppGrantRepository(collection)
        +find_active_grant(user_id, app_id) AppGrantDoc
        +find_active_for_user(user_id) list~AppGrantDoc~
        +find_all_for_user(user_id) list~AppGrantDoc~
        +create_or_reactivate(user_id, app_id) AppGrantDoc
        +revoke(user_id, app_id) bool
        +touch_last_used(user_id, app_id) None
    }

    class VerificationTokenDoc {
        +PyObjectId id
        +PyObjectId user_id
        +string email
        +string token_hash
        +string token_type
        +datetime expires_at
        +datetime created_at
        +datetime used_at
        +int attempts
        +string app_id
    }

    class TokenRepository {
        +delete_by_user(user_id, token_type, app_id) int
    }

    class TokenFactory {
        +generate_access_token(user, amr) string
        +generate_refresh_token(user, amr, app_id) string
        +issue_tokens(user, amr, app_id) tuple~string,string~
    }

    class AuthService {
        +refresh_token(refresh_token_str) tuple~UserDoc,string,string,string~
        +create_device_auth_code(user_id, email, app_id) string
        +exchange_device_code(code) tuple~UserDoc,string,string,string~
        +revoke_device_tokens(user_id, app_id) int
        +get_user_profile(user_id) UserDoc
    }

    class AuthRoutesDevice {
        +device_login(request, user, auth_service, grant_repo, app_id, redirect_uri, state)
        +device_consent_approve(request, user, auth_service, grant_repo, app_id, state, csrf_token, redirect_uri)
        +device_token(request, body, auth_service, grant_repo)
        +device_refresh(request, body, auth_service, grant_repo)
        +revoke_app(request, user, auth_service, grant_repo, app_id)
    }

    AppEntry <.. AppStatus
    AppEntry <.. AppType

    AppGrantRepository --> AppGrantDoc
    AuthService --> TokenFactory
    AuthService --> TokenRepository
    AuthService --> AppGrantRepository
    AuthRoutesDevice --> AuthService
    AuthRoutesDevice --> AppGrantRepository

    VerificationTokenDoc <.. TokenRepository
    AppEntry <.. AuthRoutesDevice
    AppGrantDoc <.. AuthRoutesDevice
    VerificationTokenDoc <.. AuthService
    AppGrantDoc <.. AuthService
Loading

File-Level Changes

Change Details Files
Introduce app registry configuration and models to describe ecosystem apps and drive UI and auth behavior.
  • Add apps.yaml config listing apps, metadata, redirect URIs, links, and permissions.
  • Create AppEntry/AppStatus/AppType Pydantic models with helper method to determine live device-auth apps.
  • Implement app registry loader that validates YAML, warns on errors/missing icons, and attaches registry to app state at startup.
config/apps.yaml
schemas/models/app.py
shared/app_registry.py
app.py
Add Mongo-backed app grant repository and indexes to track per-user app consents.
  • Define AppGrantDoc model representing app-grants documents with granted/last_used/revoked timestamps.
  • Implement AppGrantRepository with methods to find/create/reactivate/revoke/touch grants and list per user, with logging and soft-delete semantics.
  • Ensure Mongo indexes for app-grants on (user_id, app_id) unique and revoked_at filters, and extend tests to assert index creation.
  • Expose get_app_grant_repo dependency for use in routes.
schemas/models/app_grant.py
repositories/app_grant_repository.py
repositories/indexes.py
tests/unit/repositories/test_indexes.py
dependencies/services.py
dependencies/__init__.py
Extend verification token schema and repository to support app-scoped device auth codes.
  • Add optional app_id field to VerificationTokenDoc used for device auth tokens.
  • Allow token_repository.delete_by_user to filter by app_id in addition to token_type.
  • Update docstrings to mention device auth code usage.
schemas/models/token.py
repositories/token_repository.py
Make refresh-token and device-auth flows app-aware and enforce grant checks, including token revocation.
  • Change AuthService.refresh_token and exchange_device_code to return app_id and preserve amr/app_id claims through rotations, issuing tokens with optional app_id.
  • Add revoke_device_tokens method to AuthService and wire it to delete device-auth tokens (optionally per app) via token repository.
  • Update TokenFactory to propagate app_id into refresh tokens and issue_tokens, and adjust unit tests for new return signatures.
  • Update existing auth route tests to expect the new refresh_token return shape.
services/auth_service.py
services/token_factory.py
tests/unit/services/test_auth_service.py
tests/integration/test_auth_flow.py
tests/integration/test_auth_routes.py
Implement full device authorization HTTP flow with consent UI, error UI, token exchange, refresh, and revocation, all app-aware and grant-aware.
  • Refactor /auth/device/login to validate app_id against app registry, validate redirect_uri per app, handle unauthenticated redirect with preserved params, auto-approve when an active grant exists, or render a new consent page with CSRF cookie when no grant exists.
  • Add /auth/device/consent POST handler that validates CSRF token, creates or reactivates app grant, logs consent, issues device auth code bound to app_id, and redirects to callback/redirect_uri while clearing CSRF cookie.
  • Extend /auth/device/token to retrieve app_id from device code exchange, verify the corresponding grant is still active (race-window closure), touch last_used_at, and return tokens and profile; add /auth/device/refresh to refresh device tokens from body-provided refresh_token with grant checks and last_used update; and add /auth/device/revoke endpoint protected by X-Requested-With header that soft-revokes grants and invalidates device tokens via AuthService.
  • Add helpers in auth_routes for app lookup, redirect URI validation, error templating, callback redirect building, and CSRF constants, plus adjust set_password to require JwtUser and clarify docs.
  • Create device_consent.html and device_error.html templates to render consent and error pages.
  • Expand integration tests for device auth to cover app registry wiring, validation errors, unauthenticated redirects, auto-approve vs consent rendering, token exchange (including revoked grants), consent CSRF handling, and revoke endpoint behavior.
routes/auth_routes.py
tests/integration/test_device_auth.py
templates/device_consent.html
templates/device_error.html
schemas/dto/requests/auth.py
schemas/dto/responses/auth.py
Add dashboard Apps management page listing connected, available, and coming-soon apps with revoke UX.
  • Add /dashboard/apps route that requires auth, loads user profile, fetches active app grants, joins them with the app registry, and groups into connected/available/coming_soon collections for the template.
  • Create dashboard/apps.html template with sections for connected, available, and coming-soon apps, including icons, verification badges, last active info, and a kebab menu that triggers a revoke confirmation modal calling /auth/device/revoke via fetch.
  • Add apps page CSS for layout, cards, menus, and modal styles, and wire a sidebar nav link that highlights on /dashboard/apps.
routes/dashboard_routes.py
templates/dashboard/apps.html
static/css/dashboard/apps.css
templates/dashboard/partials/sidebar.html

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c6058272-2e27-4bdd-97b5-cb14c4586cdc

📥 Commits

Reviewing files that changed from the base of the PR and between dd9b490 and 8c66a19.

📒 Files selected for processing (8)
  • routes/auth_routes.py
  • routes/dashboard_routes.py
  • shared/app_registry.py
  • templates/device_consent.html
  • tests/integration/test_device_auth.py
  • tests/unit/repositories/test_app_grant_repository.py
  • tests/unit/repositories/test_indexes.py
  • tests/unit/shared/test_app_registry.py
✅ Files skipped from review due to trivial changes (1)
  • templates/device_consent.html
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/unit/repositories/test_indexes.py
  • shared/app_registry.py
  • routes/auth_routes.py

📝 Walkthrough

Walkthrough

Loads a validated app registry at startup and adds an app-scoped device-auth feature: new device consent/login/token/refresh/revoke endpoints, AppGrant persistence and indexes, token changes to carry app_id, dashboard apps UI/templates/styles, DI/provider for grant repo, and corresponding unit/integration tests.

Changes

Cohort / File(s) Summary
App startup & registry
app.py, shared/app_registry.py, config/apps.yaml, config.py
Load validated app registry from config/apps.yaml into app.state.app_registry at startup; new YAML config file; removed device_auth_redirect_uris setting.
App grant persistence & indexes
repositories/app_grant_repository.py, repositories/indexes.py, schemas/models/app_grant.py
New AppGrantRepository with find/create/reactivate/revoke/touch methods; added app-grants collection indexes (unique (user_id, app_id) and compound indexes on (user_id, revoked_at) and (app_id, revoked_at)); new AppGrantDoc model.
App registry models & DTOs
schemas/models/app.py, schemas/dto/requests/auth.py, schemas/dto/responses/auth.py
New AppEntry, AppStatus, AppType models; added DeviceRefreshRequest and DeviceRefreshResponse DTOs.
Auth service & tokens
services/auth_service.py, services/token_factory.py, repositories/token_repository.py, schemas/models/token.py
Refresh/device-code flows extended to carry app_id (signatures updated); TokenFactory embeds app_id into refresh tokens; TokenRepository.delete_by_user accepts optional app_id; added AuthService.revoke_device_tokens.
Routes & DI changes
routes/auth_routes.py, routes/dashboard_routes.py, dependencies/services.py, dependencies/__init__.py
New device-auth endpoints (/auth/device/*) including consent CSRF handling and app-scoped lifecycle; dashboard /dashboard/apps route reads app_registry; added get_app_grant_repo DI provider and re-export.
UI/templates/styles
templates/dashboard/apps.html, templates/device_consent.html, templates/device_error.html, templates/dashboard/partials/sidebar.html, static/css/dashboard/apps.css
New apps dashboard template and sidebar link, device consent and error templates, client-side revoke flow JS, and responsive CSS for apps grid/cards.
Tests
tests/integration/test_device_auth.py, tests/integration/test_auth_flow.py, tests/integration/test_auth_routes.py, tests/unit/services/test_auth_service.py, tests/unit/repositories/test_indexes.py, tests/unit/repositories/test_app_grant_repository.py, tests/unit/shared/test_app_registry.py
Expanded/refactored device-auth integration tests, added AppGrantRepository unit tests and app-registry tests, updated mocks/expectations to include app_id in token refresh returns, and added index assertions for app-grants.
Exports / DI re-exports
dependencies/__init__.py
Re-exported get_app_grant_repo.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant App as App Server
    participant AuthSvc as AuthService
    participant GrantRepo as AppGrantRepository
    participant DB as Database

    Client->>App: GET /auth/device/login (app_id, redirect_uri, state)
    App->>App: validate app_id & redirect_uri vs registry
    alt user has active grant
        App->>AuthSvc: create_device_auth_code(user_id, email, app_id)
        AuthSvc->>DB: insert device token (with app_id)
        AuthSvc-->>App: code
        App-->>Client: redirect to callback (code, state)
    else no active grant
        App-->>Client: render device consent page
    end
Loading
sequenceDiagram
    participant Client
    participant App as App Server
    participant GrantRepo as AppGrantRepository
    participant DB as Database

    Client->>App: POST /auth/device/consent (app_id, csrf_token)
    App->>App: verify CSRF from secure cookie
    alt CSRF valid
        App->>GrantRepo: create_or_reactivate(user_id, app_id)
        GrantRepo->>DB: upsert app-grant
        GrantRepo-->>App: AppGrantDoc
        App-->>Client: redirect to callback
    else CSRF invalid
        App-->>Client: render device error (403)
    end
Loading
sequenceDiagram
    participant Client
    participant App as App Server
    participant AuthSvc as AuthService
    participant GrantRepo as AppGrantRepository
    participant DB as Database

    Client->>App: POST /auth/device/token (code)
    App->>AuthSvc: exchange_device_code(code)
    AuthSvc->>DB: read device token (may include app_id)
    AuthSvc-->>App: (user, access, refresh, app_id)
    App->>GrantRepo: find_active_grant(user_id, app_id)
    alt grant active
        GrantRepo->>DB: update last_used_at
        GrantRepo-->>App: OK
        App-->>Client: return tokens
    else grant missing/revoked
        App-->>Client: error (grant revoked)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

Security, frontend

Poem

🐰 I hopped in with a tiny registry,

consent and tokens snug as can be,
Grants awoken, icons on display,
CSRF guards keep foxes at bay,
Dashboard blooms — hop, connect away!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add apps management page and device authorization flow' directly summarizes the main changes: introducing a new dashboard page for managing apps and implementing a complete device authorization flow.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/linked-apps

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Zingzy Zingzy added the priority: high High priority tasks label Apr 4, 2026
@Zingzy Zingzy moved this to 🏗️ In Progress in spoo.me Development Roadmap Apr 4, 2026
@Zingzy Zingzy added this to the Advanced Features and Security milestone Apr 4, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 8 issues, and left some high level feedback:

  • Access to request.app.state.app_registry in multiple places assumes the registry is always initialized; consider wrapping this in a small helper (e.g. get_app_registry(request)) that defaults to an empty dict or raises a clearer error if missing to make the routes more robust and test-friendly.
  • The device auth error handling currently returns a generic device_error.html for various failure cases; if you expect clients to react differently to conditions like unknown app vs invalid redirect URI vs revoked grant, consider standardizing an error code in the response JSON or querystring so frontends and extensions can distinguish these states programmatically.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Access to `request.app.state.app_registry` in multiple places assumes the registry is always initialized; consider wrapping this in a small helper (e.g. `get_app_registry(request)`) that defaults to an empty dict or raises a clearer error if missing to make the routes more robust and test-friendly.
- The device auth error handling currently returns a generic `device_error.html` for various failure cases; if you expect clients to react differently to conditions like unknown app vs invalid redirect URI vs revoked grant, consider standardizing an error code in the response JSON or querystring so frontends and extensions can distinguish these states programmatically.

## Individual Comments

### Comment 1
<location path="repositories/app_grant_repository.py" line_range="81-90" />
<code_context>
+        """
+        now = datetime.now(timezone.utc)
+        try:
+            doc = await self._col.find_one_and_update(
+                {"user_id": user_id, "app_id": app_id},
+                {
+                    "$set": {
+                        "granted_at": now,
+                        "revoked_at": None,
+                    },
+                    "$setOnInsert": {
+                        "user_id": user_id,
+                        "app_id": app_id,
+                        "last_used_at": None,
+                    },
+                },
+                upsert=True,
+                return_document=True,
+            )
+            return AppGrantDoc.from_mongo(doc)  # type: ignore[return-value]
+        except PyMongoError as exc:
+            log.error(
</code_context>
<issue_to_address>
**issue (bug_risk):** find_one_and_update usage may not return a document as written, leading to runtime errors

`return_document=True` is not a valid PyMongo argument; it should use the `ReturnDocument` enum (e.g. `ReturnDocument.AFTER`). Depending on the driver, this may fail or return the pre-update/`None` document. Also, `doc` can still be `None`, which would break `AppGrantDoc.from_mongo(doc)`. Please switch to `ReturnDocument.AFTER` and add an explicit `doc is None` check with a clear error or fallback to avoid runtime failures.
</issue_to_address>

### Comment 2
<location path="repositories/token_repository.py" line_range="156-165" />
<code_context>

     async def delete_by_user(
-        self, user_id: ObjectId, token_type: str | None = None
+        self,
+        user_id: ObjectId,
+        token_type: str | None = None,
+        app_id: str | None = None,
     ) -> int:
         """
-        Delete all tokens for a user, optionally filtered by token type.
+        Delete all tokens for a user, optionally filtered by token type and app_id.

         Returns the number of documents deleted.
         """
         query: dict = {"user_id": user_id}
         if token_type is not None:
             query["token_type"] = token_type
+        if app_id is not None:
+            query["app_id"] = app_id
         try:
</code_context>
<issue_to_address>
**suggestion (performance):** Filtering tokens by app_id could benefit from an index for scalability

With `app_id` now part of the `delete_by_user` query, patterns like `{"user_id": ..., "token_type": ..., "app_id": ...}` will be frequent for per-app revocations. If the `verification-tokens` collection lacks an index including `app_id`, these `delete_many` calls may become slow as data grows. Consider adding a compound index such as `(user_id, token_type, app_id)` or at least `(user_id, app_id)` to keep these operations efficient.
</issue_to_address>

### Comment 3
<location path="tests/integration/test_device_auth.py" line_range="365-374" />
<code_context>
+    auth_svc.revoke_device_tokens.assert_awaited_once()
+
+
+def test_revoke_no_grant_returns_404(auth_svc, grant_repo, authed_user, _app_factory):
+    grant_repo.revoke.return_value = False
+
+    c = _app_factory(auth_svc, grant_repo, authed_user)
+    resp = c.post(
+        "/auth/device/revoke",
+        data={"app_id": "spoo-snap"},
+        headers={"X-Requested-With": "fetch"},
+    )
+    assert resp.status_code == 404
</code_context>
<issue_to_address>
**suggestion (testing):** Add integration coverage for the new /auth/device/refresh endpoint and revoke race conditions

This suite already covers device login/consent/token/revoke flows, but the new `/auth/device/refresh` endpoint isn’t exercised. Please add integration tests that:

- Call `POST /auth/device/refresh` with a valid refresh token and verify the `auth_service.refresh_token` result is returned/serialized correctly.
- When `auth_service.refresh_token` returns an `app_id`, assert `grant_repo.find_active_grant` is called and `grant_repo.touch_last_used` is awaited on success.
- When `find_active_grant` returns `None`, assert the endpoint responds with 401 (via `AuthenticationError`) and does not call `touch_last_used`.

These should mirror the existing `/auth/device/token` and revoke race-condition coverage for the refresh path.

Suggested implementation:

```python
    resp = c.post(
        "/auth/device/revoke",
        data={"app_id": "spoo-snap"},
        headers={"X-Requested-With": "fetch"},
    )
    assert resp.status_code == 404


def test_refresh_token_success(auth_svc, grant_repo, authed_user, _app_factory):
    # auth_service returns a token payload that should be serialized directly
    refresh_result = {
        "access_token": "new-access",
        "refresh_token": "new-refresh",
        "expires_in": 3600,
    }
    auth_svc.refresh_token.return_value = refresh_result

    c = _app_factory(auth_svc, grant_repo, authed_user)
    resp = c.post(
        "/auth/device/refresh",
        data={"refresh_token": "refresh-token-123"},
        headers={"X-Requested-With": "fetch"},
    )

    assert resp.status_code == 200
    assert resp.json() == refresh_result
    auth_svc.refresh_token.assert_awaited_once()


def test_refresh_token_touches_grant_when_found(
    auth_svc, grant_repo, authed_user, _app_factory
):
    # When refresh_token returns an app_id, we should look up and touch the grant
    refresh_result = {
        "access_token": "new-access",
        "refresh_token": "new-refresh",
        "expires_in": 3600,
        "app_id": "spoo-snap",
    }
    auth_svc.refresh_token.return_value = refresh_result

    # Simulate an active grant existing for this app_id
    grant_repo.find_active_grant.return_value = object()

    c = _app_factory(auth_svc, grant_repo, authed_user)
    resp = c.post(
        "/auth/device/refresh",
        data={"refresh_token": "refresh-token-123"},
        headers={"X-Requested-With": "fetch"},
    )

    assert resp.status_code == 200
    assert resp.json() == refresh_result

    auth_svc.refresh_token.assert_awaited_once()
    grant_repo.find_active_grant.assert_awaited_once()
    grant_repo.touch_last_used.assert_awaited_once()


def test_refresh_token_unauthorized_when_no_active_grant(
    auth_svc, grant_repo, authed_user, _app_factory
):
    # When refresh_token returns an app_id but there is no active grant,
    # the endpoint should raise AuthenticationError and respond with 401.
    refresh_result = {
        "access_token": "new-access",
        "refresh_token": "new-refresh",
        "expires_in": 3600,
        "app_id": "spoo-snap",
    }
    auth_svc.refresh_token.return_value = refresh_result

    # No active grant found for this app_id
    grant_repo.find_active_grant.return_value = None

    c = _app_factory(auth_svc, grant_repo, authed_user)
    resp = c.post(
        "/auth/device/refresh",
        data={"refresh_token": "refresh-token-123"},
        headers={"X-Requested-With": "fetch"},
    )

    assert resp.status_code == 401

    auth_svc.refresh_token.assert_awaited_once()
    grant_repo.find_active_grant.assert_awaited_once()
    grant_repo.touch_last_used.assert_not_awaited()

```

Depending on the existing implementation, you may need to:
1. Adjust the method name on `auth_svc` if it is not `refresh_token` (e.g. `refresh_device_token`), and update assertions accordingly.
2. Match the exact shape of the token payload that `/auth/device/refresh` returns (field names and structure) so that `refresh_result` mirrors the real service contract used elsewhere in this test file.
3. Align the arguments in `grant_repo.find_active_grant.assert_awaited_once(...)` and `grant_repo.touch_last_used.assert_awaited_once(...)` with the actual call signatures used by the production code (e.g. whether they receive `authed_user`, `app_id`, or a grant object).
</issue_to_address>

### Comment 4
<location path="tests/integration/test_device_auth.py" line_range="226-235" />
<code_context>
     assert "state=xyz" in loc


+def test_device_login_without_grant_shows_consent(
+    auth_svc, grant_repo, authed_user, _app_factory
+):
+    c = _app_factory(auth_svc, grant_repo, authed_user)
+    resp = c.get("/auth/device/login?app_id=spoo-snap&state=xyz")
+    assert resp.status_code == 200
+    assert "Spoo Snap" in resp.text
+    assert "Allow" in resp.text
+    assert "Connecting as" in resp.text
+    assert "csrf_token" in resp.text
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting that the consent CSRF cookie is set when rendering the consent page

This test only checks that a CSRF token appears in the HTML, but not that the `_consent_csrf` cookie is actually set on the response, which the POST `/auth/device/consent` handler relies on. Please also assert that `"_consent_csrf"` is present in `resp.cookies` and, if feasible, that it is `httponly`, `secure`, and has the expected `samesite` attribute to better cover the CSRF mechanism end to end.

```suggestion
def test_device_login_without_grant_shows_consent(
    auth_svc, grant_repo, authed_user, _app_factory
):
    c = _app_factory(auth_svc, grant_repo, authed_user)
    resp = c.get("/auth/device/login?app_id=spoo-snap&state=xyz")
    assert resp.status_code == 200
    assert "Spoo Snap" in resp.text
    assert "Allow" in resp.text
    assert "Connecting as" in resp.text
    assert "csrf_token" in resp.text

    # Ensure the consent CSRF cookie is set for the consent POST handler
    assert "_consent_csrf" in resp.cookies

    # Optionally, verify CSRF cookie security attributes via the Set-Cookie header
    set_cookie_header = resp.headers.get("set-cookie", "")
    assert "_consent_csrf=" in set_cookie_header
    assert "HttpOnly" in set_cookie_header
    assert "Secure" in set_cookie_header
    # Adjust the expected SameSite value if the application uses a different setting
    assert "SameSite=lax" in set_cookie_header
```
</issue_to_address>

### Comment 5
<location path="tests/unit/services/test_auth_service.py" line_range="324" />
<code_context>
         refresh_tok = svc._generate_refresh_token(user, amr="pwd")
         svc._user_repo.find_by_id.return_value = user

-        result_user, new_access, new_refresh = await svc.refresh_token(refresh_tok)
+        result_user, new_access, new_refresh, app_id = await svc.refresh_token(
+            refresh_tok
+        )
</code_context>
<issue_to_address>
**suggestion (testing):** Add unit tests for app_id propagation in refresh_token and exchange_device_code and for revoke_device_tokens

The current tests only cover the classic refresh token case where `app_id` is `None`. We should also add coverage for the new app-specific behavior:

- For `refresh_token`, construct a refresh token with an `app_id` claim, call `refresh_token`, and assert that the returned `app_id` matches the claim and that `self._tokens.issue_tokens` is called with the expected `amr` and `app_id`.
- For `exchange_device_code`, set `token_doc.app_id = "spoo-snap"`, then assert that the returned `app_id` matches and that `issue_tokens` is called with `app_id="spoo-snap"`.
- For `revoke_device_tokens`, verify that with and without an `app_id`, `token_repo.delete_by_user` is called with the correct arguments and the returned count is forwarded.

These tests will protect the new app-specific token flow from regressions.

Suggested implementation:

```python
        result_user, new_access, new_refresh, app_id = await svc.refresh_token(
            refresh_tok
        )
        assert result_user is user
        assert isinstance(new_access, str)
        assert isinstance(new_refresh, str)
        assert app_id is None

    @pytest.mark.asyncio
    async def test_refresh_token_app_specific_propagates_app_id_and_calls_issue_tokens(self):
        # Arrange
        user = make_user_doc(email_verified=True)
        svc._user_repo.find_by_id.return_value = user

        refresh_tok = svc._generate_refresh_token(
            user,
            amr="pwd",
            app_id="spoo-snap",
        )

        # Act
        result_user, new_access, new_refresh, app_id = await svc.refresh_token(
            refresh_tok
        )

        # Assert
        assert result_user is user
        assert isinstance(new_access, str)
        assert isinstance(new_refresh, str)
        assert app_id == "spoo-snap"

        svc._tokens.issue_tokens.assert_called_once()
        _args, kwargs = svc._tokens.issue_tokens.call_args
        # We only assert on the new behaviour we care about here
        assert kwargs.get("amr") == "pwd"
        assert kwargs.get("app_id") == "spoo-snap"

    @pytest.mark.asyncio
    async def test_refresh_token_invalid_raises(self):
        svc._token_repo.consume_by_hash.return_value = token_doc
        svc._user_repo.find_by_id.return_value = make_user_doc(email_verified=True)


```

Because I only see a small slice of `test_auth_service.py`, the following tests should be added near the existing tests for `exchange_device_code` and `revoke_device_tokens`. You can paste them into that file (keeping consistent style/fixtures):

```python
    @pytest.mark.asyncio
    async def test_exchange_device_code_app_specific_propagates_app_id_and_calls_issue_tokens(self):
        # Arrange
        user = make_user_doc(email_verified=True)
        token_doc.user_id = user.id
        token_doc.app_id = "spoo-snap"

        svc._user_repo.find_by_id.return_value = user
        # Adjust this to the real repo method used in exchange_device_code
        svc._token_repo.consume_device_code.return_value = token_doc

        # Act
        result_user, access, refresh, app_id = await svc.exchange_device_code(
            device_code="some-device-code"
        )

        # Assert
        assert result_user is user
        assert isinstance(access, str)
        assert isinstance(refresh, str)
        assert app_id == "spoo-snap"

        svc._tokens.issue_tokens.assert_called_once()
        _args, kwargs = svc._tokens.issue_tokens.call_args
        # amr is whatever exchange_device_code uses (e.g., "device"), update if different
        assert kwargs.get("app_id") == "spoo-snap"

    @pytest.mark.asyncio
    async def test_revoke_device_tokens_without_app_id_deletes_all_and_returns_count(self):
        # Arrange
        user = make_user_doc(email_verified=True)
        svc._token_repo.delete_by_user.return_value = 3

        # Act
        count = await svc.revoke_device_tokens(user_id=user.id)

        # Assert
        svc._token_repo.delete_by_user.assert_called_once_with(
            user_id=user.id,
            app_id=None,
        )
        assert count == 3

    @pytest.mark.asyncio
    async def test_revoke_device_tokens_with_app_id_deletes_only_app_and_returns_count(self):
        # Arrange
        user = make_user_doc(email_verified=True)
        svc._token_repo.delete_by_user.return_value = 2

        # Act
        count = await svc.revoke_device_tokens(
            user_id=user.id,
            app_id="spoo-snap",
        )

        # Assert
        svc._token_repo.delete_by_user.assert_called_once_with(
            user_id=user.id,
            app_id="spoo-snap",
        )
        assert count == 2
```

You may need to:

1. Adjust `svc._token_repo.consume_device_code` to the actual repository method used inside `exchange_device_code` (e.g., `consume_by_device_code`, `consume_by_hash`, etc.).
2. Update the `amr` assertion in the `exchange_device_code` test if `exchange_device_code` uses a specific `amr` value (for example `"device"` or `"otp"`), and/or assert on any other arguments you care about.
3. Ensure these new tests are placed inside the same test class or module where `svc`, `token_doc`, and `make_user_doc` fixtures/helpers are already available.
</issue_to_address>

### Comment 6
<location path="tests/unit/repositories/test_indexes.py" line_range="24-33" />
<code_context>
         api_keys_col = AsyncMock()
         tokens_col = AsyncMock()

+        app_grants_col = AsyncMock()
+
         db.__getitem__ = lambda self, name: {
</code_context>
<issue_to_address>
**suggestion (testing):** Extend index tests to cover all app-grants indexes, not just the unique (user_id, app_id)

Right now this only asserts the unique `("user_id", 1), ("app_id", 1)` index on `app-grants`. Please also assert the other two expected indexes `("user_id", 1), ("revoked_at", 1)` and `("app_id", 1), ("revoked_at", 1)` (e.g. with `assert_any_await` on the corresponding `create_index` calls) so the test fully reflects the repository’s index contract and catches regressions.

Suggested implementation:

```python
        tokens_col.create_index.assert_any_await([("token", 1)], unique=True)

        # app-grants indexes:
        # 1) unique (user_id, app_id)
        app_grants_col.create_index.assert_any_await(
            [("user_id", 1), ("app_id", 1)], unique=True
        )
        # 2) (user_id, revoked_at)
        app_grants_col.create_index.assert_any_await(
            [("user_id", 1), ("revoked_at", 1)]
        )
        # 3) (app_id, revoked_at)
        app_grants_col.create_index.assert_any_await(
            [("app_id", 1), ("revoked_at", 1)]
        )

```

If the `tokens_col.create_index` assertion in your file differs from
`tokens_col.create_index.assert_any_await([("token", 1)], unique=True)`,
adjust the `<<<<<<< SEARCH` block accordingly so it exactly matches the
current assertion, then keep the `REPLACE` content as-is beneath it.

Also ensure that the index definitions in your repository code for the
`"app-grants"` collection use the same key order and options as asserted
here; if the repository uses different field names, order, or options
(e.g. `sparse=True`, `background=True`), mirror those in these
`assert_any_await` calls.
</issue_to_address>

### Comment 7
<location path="tests/integration/test_auth_flow.py" line_range="184" />
<code_context>

     mock_svc = AsyncMock()
     mock_svc.login.return_value = (user, _ACCESS_TOKEN, _REFRESH_TOKEN)
-    mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh")
+    mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh", None)

     app = _build_test_app({get_auth_service: lambda: mock_svc})
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding an end-to-end test for refresh with an app-bound refresh token

This test only covers the case where `refresh_token` returns an app-agnostic token (`app_id=None`). To fully exercise the new app-specific refresh behavior on `/auth/device/refresh`, please add an integration test that:
- Mocks `auth_service.refresh_token` to return `(user, "new.access", "new.refresh", "spoo-snap")`.
- Calls `/auth/device/refresh` with a JSON body.
- Verifies the JSON response and, with a `grant_repo` mock, that an active grant is required and `touch_last_used` is called.

That will validate the new refresh entrypoint end-to-end over HTTP, beyond the existing unit tests.

Suggested implementation:

```python
    mock_svc = AsyncMock()
    mock_svc.login.return_value = (user, _ACCESS_TOKEN, _REFRESH_TOKEN)
    mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh", None)

    app = _build_test_app({get_auth_service: lambda: mock_svc})
    with TestClient(app, raise_server_exceptions=False) as client:
        verified_user,
        "refreshed.access",
        "refreshed.refresh",
        None,
    )
    mock_svc.get_user_profile.return_value = verified_user
    mock_svc.set_password.side_effect = ValidationError("password already set")


async def test_device_refresh_with_app_bound_refresh_token(
    get_auth_service,
    get_grant_repo,
) -> None:
    """
    End-to-end test for /auth/device/refresh when auth_service.refresh_token
    returns an app-bound refresh token (non-None app_id).
    """
    # Arrange
    user = _build_user()

    mock_auth_svc = AsyncMock()
    mock_auth_svc.login.return_value = (user, _ACCESS_TOKEN, _REFRESH_TOKEN)
    mock_auth_svc.refresh_token.return_value = (
        user,
        "new.access",
        "new.refresh",
        "spoo-snap",
    )

    mock_grant_repo = AsyncMock()

    app = _build_test_app(
        {
            get_auth_service: lambda: mock_auth_svc,
            get_grant_repo: lambda: mock_grant_repo,
        }
    )

    # Act
    with TestClient(app, raise_server_exceptions=False) as client:
        response = client.post(
            "/auth/device/refresh",
            json={
                "refresh_token": _REFRESH_TOKEN,
                "app_id": "spoo-snap",
            },
        )

    # Assert response payload
    assert response.status_code == 200
    body = response.json()

    assert body["access_token"] == "new.access"
    assert body["refresh_token"] == "new.refresh"
    # Ensure the user object is present and matches the refreshed user
    assert body["user"]["id"] == str(user.id)

    # Assert grant_repo interaction: active grant is required and last_used is touched
    mock_grant_repo.get_active_grant.assert_awaited_once()
    mock_grant_repo.touch_last_used.assert_awaited_once()

```

You may need to adapt a few details to match your existing test helpers and repository API:

1. If `_build_user()` or `_ACCESS_TOKEN` / `_REFRESH_TOKEN` are named differently, swap in the appropriate helpers/constants used elsewhere in this file.
2. Replace `get_active_grant` / `touch_last_used` with the exact methods on your `grant_repo` that enforce an active grant for a given `app_id` and update the "last used" timestamp. If your repository uses a more specific signature (e.g. `get_active_grant(user_id=user.id, app_id="spoo-snap")`), update the assertions accordingly.
3. Adjust the JSON body keys (`"refresh_token"`, `"app_id"`) if your `/auth/device/refresh` endpoint expects different field names, mirroring whatever the existing app-agnostic refresh test in this file already uses.
4. If tests are synchronous in this file (regular `def` instead of `async def`), convert the new test to `def test_device_refresh_with_app_bound_refresh_token(...)` and remove `async`/`await` usage to match the style of other tests.
</issue_to_address>

### Comment 8
<location path="tests/integration/test_auth_routes.py" line_range="211" />
<code_context>

     mock_svc = AsyncMock()
     mock_svc.login.return_value = (user, _ACCESS_TOKEN, _REFRESH_TOKEN)
-    mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh")
+    mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh", None)

     app = _build_test_app({get_auth_service: lambda: mock_svc})
</code_context>
<issue_to_address>
**suggestion (testing):** Add a route-level test for the new /auth/device/refresh endpoint

`test_refresh_rotates_tokens` was updated for the new `refresh_token` signature, but there’s no equivalent coverage for `/auth/device/refresh`, which has its own request/response types. Please add a route-level test in this file that:
- Mocks `auth_service.refresh_token` to return `(user, "new.access", "new.refresh", "spoo-snap")`.
- Uses a mocked `grant_repo` from `get_app_grant_repo` to return an active grant and asserts `find_active_grant`/`touch_last_used` are called with the expected args.
- Issues `POST /auth/device/refresh` with a JSON `refresh_token` and asserts a 200 response matching the `DeviceRefreshResponse` schema.

This will verify the new route wiring and grant checks end-to-end.

Suggested implementation:

```python
def test_refresh_rotates_tokens():
    user = _make_user_doc()
    mock_svc = AsyncMock()
    mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh", None)

    app = _build_test_app({get_auth_service: lambda: mock_svc})
    with TestClient(app, raise_server_exceptions=False) as client:
        # existing assertions for /auth/refresh go here
        ...



def test_device_refresh_route_rotates_tokens_and_updates_grant():
    user = _make_user_doc()

    mock_auth_service = AsyncMock()
    mock_auth_service.refresh_token.return_value = (
        user,
        "new.access",
        "new.refresh",
        "spoo-snap",
    )

    mock_grant_repo = AsyncMock()
    # Return some active grant object; tests only care that it is truthy
    mock_grant_repo.find_active_grant.return_value = object()

    app = _build_test_app(
        {
            get_auth_service: lambda: mock_auth_service,
            get_app_grant_repo: lambda: mock_grant_repo,
        }
    )

    with TestClient(app, raise_server_exceptions=False) as client:
        response = client.post(
            "/auth/device/refresh",
            json={"refresh_token": "old.refresh"},
        )

    assert response.status_code == 200

    body = response.json()
    # The exact field names should match DeviceRefreshResponse
    assert body["access_token"] == "new.access"
    assert body["refresh_token"] == "new.refresh"
    assert body["device_token"] == "spoo-snap"

    # Ensure we wired the service and repo correctly
    mock_auth_service.refresh_token.assert_awaited_once()
    mock_grant_repo.find_active_grant.assert_awaited_once()
    mock_grant_repo.touch_last_used.assert_awaited_once()

```

The snippet above shows only the visible tail of `test_refresh_rotates_tokens`; you should remove the placeholder `...` and keep your existing assertions for the `/auth/refresh` route.

You will also need to:

1. Adjust the assertions for `find_active_grant` and `touch_last_used` to match the actual call signatures used in your implementation (e.g., specific positional/keyword arguments such as user id, device id, grant id, or refresh token).
2. Align the response field names with your `DeviceRefreshResponse` schema (for example, if the device token field is named differently such as `device_refresh_token`, update the key used in the assertions).
3. Ensure `get_app_grant_repo` is imported or otherwise in scope in this test file, consistent with how other tests access it.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an app registry + grants system to support a new Dashboard “Apps” page and a device-authorization consent flow for external apps (extensions/desktop/CLI), including refresh/revocation paths.

Changes:

  • Introduces an app registry loaded from config/apps.yaml, surfaced in the dashboard and device-consent UI.
  • Implements device auth consent/grant checks (grant creation, token exchange validation, refresh, revoke) and ties refresh tokens to app_id.
  • Adds MongoDB indexes and test updates for the new flows.

Reviewed changes

Copilot reviewed 28 out of 40 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/unit/services/test_auth_service.py Updates unit tests for new (…, app_id) return values from refresh/exchange methods.
tests/unit/repositories/test_indexes.py Extends index-ensuring test coverage to include app-grants collection index creation.
tests/integration/test_device_auth.py Expands integration tests for device login/consent/token exchange/revocation flows.
tests/integration/test_auth_routes.py Updates mocked refresh-token return tuple shape.
tests/integration/test_auth_flow.py Updates mocked refresh-token return tuple shape in auth journey tests.
templates/device_error.html Adds a dedicated device-authorization error page.
templates/device_consent.html Adds a device-consent UI for app authorization.
templates/dashboard/partials/sidebar.html Adds sidebar navigation link to /dashboard/apps.
templates/dashboard/apps.html Adds dashboard Apps management page + revoke UX.
static/images/apps/zapier.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/vscode.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/telegram-bot.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/spoo-snap.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/spoo-mobile.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/spoo-desktop.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/spoo-cli.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/slack-bot.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/raycast.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/n8n.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/mcp.svg Adds icon asset for apps registry/dashboard display.
static/images/apps/discord-bot.svg Adds icon asset for apps registry/dashboard display.
static/css/dashboard/apps.css Adds styling for the new dashboard Apps page and revoke modal.
shared/app_registry.py Adds YAML-backed app registry loader with validation/logging.
services/token_factory.py Embeds optional app_id claim into refresh tokens; plumbs through token issuance.
services/auth_service.py Preserves amr/app_id across refresh; binds device auth codes/tokens to app_id; adds token revocation helper.
schemas/models/token.py Extends verification-token model to include optional app_id for device auth tokens.
schemas/models/app.py Introduces typed app registry model/enums.
schemas/models/app_grant.py Adds app-grant document model for user↔app authorization tracking.
schemas/dto/responses/auth.py Adds DTO for /auth/device/refresh response.
schemas/dto/requests/auth.py Adds DTO for /auth/device/refresh request.
routes/dashboard_routes.py Adds /dashboard/apps handler that merges registry entries with active grants.
routes/auth_routes.py Implements device login/consent/token/refresh/revoke endpoints + template rendering.
repositories/token_repository.py Extends token deletion to optionally filter by app_id.
repositories/indexes.py Adds app-grants collection indexes (unique + active/revoked query helpers).
repositories/app_grant_repository.py Adds repository for grant CRUD, soft-revoke, and touch-last-used.
dependencies/services.py Adds get_app_grant_repo dependency provider.
dependencies/init.py Re-exports get_app_grant_repo.
config/apps.yaml Adds initial app registry entries (live + coming soon) with metadata.
config.py Removes deprecated global device-auth redirect allowlist setting.
app.py Loads and stores app registry on app.state at startup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
routes/auth_routes.py (1)

223-239: ⚠️ Potential issue | 🔴 Critical

Reject app-scoped refresh tokens on the cookie refresh endpoint.

refresh_token() now preserves app_id, but this handler throws it away and issues browser cookies anyway. That means a device app can present its app-scoped refresh JWT as the refresh_token cookie to /auth/refresh and bypass the active-grant check that /auth/device/refresh enforces, so revocation is no longer effective.

🔐 One safe direction
-        _user, new_access, new_refresh, _app_id = await auth_service.refresh_token(
+        _user, new_access, new_refresh, app_id = await auth_service.refresh_token(
             refresh_token_str
         )
+        if app_id is not None:
+            resp = JSONResponse(
+                {
+                    "error": "invalid or expired refresh token",
+                    "code": "AUTHENTICATION_ERROR",
+                },
+                status_code=401,
+            )
+            clear_auth_cookies(resp, jwt_cfg)
+            return resp
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@routes/auth_routes.py` around lines 223 - 239, The handler must reject
app-scoped refresh tokens returned by auth_service.refresh_token: after calling
auth_service.refresh_token(refresh_token_str) check the returned _app_id (or
app_id) and if it is not None, treat it as an invalid/unsupported token for the
browser endpoint — log a warning, clear cookies using clear_auth_cookies(resp,
jwt_cfg), and return a 401 JSONResponse (same shape used for
AuthenticationError) instead of issuing cookies; only call
set_auth_cookies(resp, new_access, new_refresh, jwt_cfg) and return the
RefreshResponse when _app_id is None.
🧹 Nitpick comments (1)
repositories/token_repository.py (1)

175-181: Include app_id in delete failure logs for triage clarity.

When app-scoped deletion fails, missing app_id in logs makes incident diagnosis harder.

🧾 Small logging improvement
             log.error(
                 "token_repo_delete_by_user_failed",
                 user_id=str(user_id),
                 token_type=token_type,
+                app_id=app_id,
                 error=str(exc),
                 error_type=type(exc).__name__,
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@repositories/token_repository.py` around lines 175 - 181, The delete failure
log in token_repository currently omits app_id, hindering triage; update the
log.error call that emits "token_repo_delete_by_user_failed" (in the token
repository delete-by-user code path) to include app_id (e.g., app_id=str(app_id)
or None-safe conversion) alongside user_id and token_type so failed app-scoped
deletions record the app identifier for debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@repositories/token_repository.py`:
- Around line 169-170: When building the Mongo query for app-scoped deletes,
don't always set query["app_id"] = app_id; instead, if the token type is
DEVICE_AUTH (use the DEVICE_AUTH constant/enum and the local variables query and
app_id), set query["$or"] = [{"app_id": app_id}, {"app_id": {"$exists": False}},
{"app_id": None}] so legacy device-auth documents without an app_id are matched;
for all other token types keep the exact match by setting query["app_id"] =
app_id.

In `@routes/auth_routes.py`:
- Around line 711-719: The current post-refresh grant check only ensures a
matching active grant (grant_repo.find_active_grant) but misses validating the
refresh token's issuance/version against the grant's revocation state, allowing
old tokens to become valid again after re-consent; modify the logic in the auth
flow (around auth_service.refresh_token, grant_repo.find_active_grant, and
grant_repo.touch_last_used) to obtain the token's issued_at or token_version
from auth_service.refresh_token and compare it to the grant's revocation
timestamp or current grant.version field returned by find_active_grant,
rejecting the refresh (raise AuthenticationError) if the token was issued before
revocation or its version is stale, and only call grant_repo.touch_last_used
after this additional validation succeeds.
- Around line 665-674: The touch_last_used bookkeeping call should be made
best-effort so a write failure doesn't turn a successful exchange_device_code()
into a 500; keep the current grant check using grant_repo.find_active_grant()
and raising AuthenticationError("app access has been revoked") when missing, but
wrap grant_repo.touch_last_used(user.id, app_id) in a try/except/handler that
catches exceptions, logs the failure (do not re-raise), and proceeds to return
the tokens as normal; reference exchange_device_code,
grant_repo.find_active_grant, grant_repo.touch_last_used, and
AuthenticationError when locating the code to change.

In `@routes/dashboard_routes.py`:
- Line 142: The route assumes request.app.state.app_registry always exists and
will raise AttributeError if missing; update the handler to guard access by
using getattr(request.app.state, "app_registry", {}) (and cast to dict[str,
AppEntry] if needed) or check hasattr(request.app.state, "app_registry") before
use, defaulting to an empty dict and optionally logging a warning so the route
can render safely when the registry was not populated; ensure you update any
references to app_registry in this handler to work with the empty default.
- Around line 151-154: The current order checks app.status before grant
membership, causing apps with an active grant to be classified as coming_soon;
change the logic in the loop that builds entry (reference variables app, app_id,
entry, grant_map, coming_soon) so that you first check if app_id is in grant_map
and set entry["grant"] and add it to the connected/connected_list (or the same
list used for connected apps) before falling back to the AppStatus.COMING_SOON
check; alternatively, if you must keep the status branch, add a condition to
treat COMING_SOON apps as connected when app_id is in grant_map so connected
grants are shown.

In `@shared/app_registry.py`:
- Around line 47-54: raw_apps may be present but not a mapping, causing
raw_apps.items() to raise; before iterating in the loop that builds registry
(variable registry of type dict[str, AppEntry]) add an explicit type check
(e.g., isinstance(raw_apps, dict) or collections.abc.Mapping) and if it is not a
mapping, log a warning (similar to the existing
log.warning("app_registry_empty", path=str(path)) but indicating wrong type) and
return an empty dict so the subsequent loop over raw_apps.items() is safe.
- Around line 36-41: The YAML load only catches yaml.YAMLError but misses
filesystem I/O failures; update the try/except around "with open(path) as f:
data = yaml.safe_load(f)" to also catch file I/O errors (e.g., OSError/IOError)
and log them similarly before returning {}. Specifically, add an except
OSError/IOError (or OSError as exc) branch that calls
log.error("app_registry_io_error", path=str(path), error=str(exc)) and returns
{} (keeping the existing yaml.YAMLError handler for parsing errors).

In `@templates/dashboard/apps.html`:
- Around line 84-94: The template renders an <a class="app-card app-card--link">
even when app.links is empty, producing a clickable anchor without an href;
update the template around the app-card anchor logic to conditionally render a
non-anchor disabled card (e.g., a <div> with class "app-card
app-card--disabled") or supply a safe fallback href when app.links is absent,
using the existing app.links checks (app.links, app.links|length,
'chrome'/'firefox' keys) so only when a real URL exists you output the <a ...
href="..."> and otherwise render the non-clickable disabled variant.
- Around line 51-53: The button currently injects app.app_id and app.name
directly into the onclick string which is unsafe; instead set data attributes
(e.g., data-app-id and data-app-name) on the button element (the button with
class "kebab-item kebab-item--danger") and remove the inline JS arguments, then
update the event handling to read event.currentTarget.dataset.appId and .appName
and pass those values into the existing revokeApp function (or call revokeApp
from a wrapper that reads the dataset). This avoids breaking on quotes and
prevents inline interpolation vulnerabilities while keeping the existing
revokeApp(identifier, name) usage.

In `@templates/device_consent.html`:
- Around line 186-188: Replace the inline templated string in the img onerror
handler with a data attribute and a safe JS read: add a data-fallback attribute
(populated with {{ (user.user_name or user.email)[:2]|upper }} from the
template) on the <img> that uses user.pfp.url, and change the onerror handler to
read from this.dataset.fallback (e.g., set parentElement.textContent =
this.dataset.fallback) so no templated value is injected into executable JS;
update the <img> tag where user.pfp.url is used and the onerror handler
accordingly.

---

Outside diff comments:
In `@routes/auth_routes.py`:
- Around line 223-239: The handler must reject app-scoped refresh tokens
returned by auth_service.refresh_token: after calling
auth_service.refresh_token(refresh_token_str) check the returned _app_id (or
app_id) and if it is not None, treat it as an invalid/unsupported token for the
browser endpoint — log a warning, clear cookies using clear_auth_cookies(resp,
jwt_cfg), and return a 401 JSONResponse (same shape used for
AuthenticationError) instead of issuing cookies; only call
set_auth_cookies(resp, new_access, new_refresh, jwt_cfg) and return the
RefreshResponse when _app_id is None.

---

Nitpick comments:
In `@repositories/token_repository.py`:
- Around line 175-181: The delete failure log in token_repository currently
omits app_id, hindering triage; update the log.error call that emits
"token_repo_delete_by_user_failed" (in the token repository delete-by-user code
path) to include app_id (e.g., app_id=str(app_id) or None-safe conversion)
alongside user_id and token_type so failed app-scoped deletions record the app
identifier for debugging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0b3adef-6593-427a-a62d-9cd152d305f9

📥 Commits

Reviewing files that changed from the base of the PR and between 8dc08ef and 2f8afd3.

⛔ Files ignored due to path filters (12)
  • static/images/apps/discord-bot.svg is excluded by !**/*.svg
  • static/images/apps/mcp.svg is excluded by !**/*.svg
  • static/images/apps/n8n.svg is excluded by !**/*.svg
  • static/images/apps/raycast.svg is excluded by !**/*.svg
  • static/images/apps/slack-bot.svg is excluded by !**/*.svg
  • static/images/apps/spoo-cli.svg is excluded by !**/*.svg
  • static/images/apps/spoo-desktop.svg is excluded by !**/*.svg
  • static/images/apps/spoo-mobile.svg is excluded by !**/*.svg
  • static/images/apps/spoo-snap.svg is excluded by !**/*.svg
  • static/images/apps/telegram-bot.svg is excluded by !**/*.svg
  • static/images/apps/vscode.svg is excluded by !**/*.svg
  • static/images/apps/zapier.svg is excluded by !**/*.svg
📒 Files selected for processing (28)
  • app.py
  • config.py
  • config/apps.yaml
  • dependencies/__init__.py
  • dependencies/services.py
  • repositories/app_grant_repository.py
  • repositories/indexes.py
  • repositories/token_repository.py
  • routes/auth_routes.py
  • routes/dashboard_routes.py
  • schemas/dto/requests/auth.py
  • schemas/dto/responses/auth.py
  • schemas/models/app.py
  • schemas/models/app_grant.py
  • schemas/models/token.py
  • services/auth_service.py
  • services/token_factory.py
  • shared/app_registry.py
  • static/css/dashboard/apps.css
  • templates/dashboard/apps.html
  • templates/dashboard/partials/sidebar.html
  • templates/device_consent.html
  • templates/device_error.html
  • tests/integration/test_auth_flow.py
  • tests/integration/test_auth_routes.py
  • tests/integration/test_device_auth.py
  • tests/unit/repositories/test_indexes.py
  • tests/unit/services/test_auth_service.py
💤 Files with no reviewable changes (1)
  • config.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: 🏗️ In Progress

Development

Successfully merging this pull request may close these issues.

3 participants