Skip to content

feat(auth): Keycloak SPI for Google incremental scopes #357

Description

@ulanpy

Summary

We need incremental Google OAuth scopes (request Calendar / Sheets / etc. only when the user uses that feature), while keeping:

  • a single Google OAuth client (the one already configured on the Keycloak Google IdP)
  • Keycloak as the broker
  • existing token-exchange (requested_issuer=google) to obtain the stored Google access token for API calls

Today, Google IdP Default Scopes are static. Putting Sheets (or every future Google API) into Default Scopes up front is a consent antipattern. Full /login?reauth=1 (revoke + clear cookies + prompt=login) works for “refresh broker token after IdP scopes changed”, but it is a hard re-login, not incremental consent.

Important separation

These are orthogonal:

Step What it does Updates Google scopes?
Browser grant / broker login / account linking User consents at Google; Keycloak stores new broker tokens Yes
Legacy token-exchange (requested_issuer=google) KC access token → already stored Google access token No — read-only

A custom IdP SPI only changes how the authorize URL to Google is built (dynamic scope + include_granted_scopes). Token-exchange setup stays the same if the IdP alias remains google.

Note: legacy token-exchange is preview/deprecated in Keycloak docs. This issue does not migrate off it; Calendar already depends on it. Track migration separately if needed.

Goal UX

  1. User is already logged into nuspace (Keycloak session intact).
  2. Clicks Export to Google Sheets (or Calendar when scope missing).
  3. Short redirect → Google consent for only the new scope(s).
  4. Return to app; same session; retry export.
  5. Backend: same exchange_token_for_idp(..., requested_issuer="google") as today.

No second Google OAuth client. No storing Google tokens in our DB (still Keycloak “Store tokens” + exchange).

Proposed approach: custom Keycloak Google IdP (SPI)

Replace/extend the built-in Google Identity Provider implementation under the same alias google, so:

  • GCP OAuth client_id/secret unchanged
  • IdP Permissions → token-exchange for client unchanged
  • App code keeps requested_issuer=google

SPI behavior

On broker authorize URL construction:

  1. Start from base login scopes (openid profile email or current Default Scopes for identity).
  2. Read requested extra scopes from a controlled channel, e.g.:
    • query/session note set by our authorize entrypoint, or
    • a dedicated app → Keycloak parameter that the SPI whitelists
  3. Append only allowed Google scopes (whitelist), e.g.:
    • https://www.googleapis.com/auth/calendar.events (or whatever Calendar uses today)
    • https://www.googleapis.com/auth/spreadsheets
  4. Set include_granted_scopes=true so Google merges prior grants (true incremental auth).
  5. Prefer not forcing prompt=login (avoid full password/account picker). Use consent only when Google requires it for new scopes.
  6. After callback, Keycloak stores/refreshes broker tokens as today (Store tokens must stay enabled).

What must NOT change

  • IdP alias: google
  • Token-exchange grant + FGAP:v1 permissions already used for Calendar
  • nuspace cookies / session on grant (do not copy current reauth=1 revoke+cookie-clear path for scope upgrades)

App / nuspace changes (after SPI exists)

  1. New grant entrypoint (replace hard reauth for missing Google API scope), e.g.
    GET /api/google/grant?scopes=spreadsheets&return_to=...

    • Requires authenticated user
    • Redirects into Keycloak broker/authorize path that the SPI understands
    • Does not revoke refresh / clear app cookies
  2. Frontend: on insufficient_google_scope (Calendar today, Sheets later) → open “Grant access” → grant URL with return_to, then retry — not “Sign in again” via /api/login?reauth=1.

  3. Optional: keep reauth=1 only for true session recovery / forced re-login, not for scope upgrades.

  4. Sheets feature (separate or follow-up): GoogleSheetsService + export API using the same token-exchange helper as GoogleCalendarService.

Keycloak / ops checklist

  • Build/deploy Keycloak with custom provider JAR (or documented SPI package)
  • Register custom Google IdP provider; migrate/replace current google IdP config (same client id/secret, Store tokens ON)
  • Confirm token-exchange still works: requested_issuer=google after a normal login
  • Google Cloud OAuth consent screen: add Sheets (and any other) scopes as available; do not require them at every login
  • Whitelist scopes in SPI (never pass arbitrary client-supplied scope strings through unchecked)
  • Document upgrade path when Keycloak version bumps (SPI against Keycloak SPI APIs)

Out of scope / non-goals

  • Second Google OAuth client in GCP
  • Storing Google refresh tokens in nuspace Postgres
  • Putting all API scopes into IdP Default Scopes permanently
  • Migrating off legacy token-exchange (track separately; docs recommend Identity Brokering APIs for linking, but exchange is still how we mint Google access tokens for server-side API calls today)

Acceptance criteria

  • Fresh user logs in with identity scopes only (no Sheets consent at login)
  • First Sheets export triggers Google consent for Sheets only; nuspace session remains
  • After grant, token-exchange returns a Google token that can create/edit a spreadsheet
  • Calendar continues to work for users who already granted Calendar (include_granted_scopes)
  • Missing scope UX uses grant flow, not full /login?reauth=1 cookie wipe
  • No second Google OAuth client; IdP alias remains google

References (internal)

  • Current exchange: backend/modules/auth/keycloak_manager.py (exchange_token_for_idp)
  • Current hard reauth: backend/modules/auth/api.py + service.py (reauth, prompt=login, kc_idp_hint=google)
  • Calendar consumer: backend/modules/calendar/google_calendar_service.py
  • Missing-scope UX: e.g. frontend/src/features/courses/components/schedule-dialog.tsx

Open questions

  1. Exact transport of “extra scopes” into Keycloak (query param vs kc_action / account-link style URL vs custom endpoint) — pick one and document.
  2. Whether Calendar scope should also move to on-demand grant (consistent model) or stay in Default Scopes until Sheets ships.
  3. Keycloak version we run in prod/stage and SPI compatibility constraints.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions