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
- User is already logged into nuspace (Keycloak session intact).
- Clicks Export to Google Sheets (or Calendar when scope missing).
- Short redirect → Google consent for only the new scope(s).
- Return to app; same session; retry export.
- 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:
- Start from base login scopes (
openid profile email or current Default Scopes for identity).
- 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
- 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
- Set
include_granted_scopes=true so Google merges prior grants (true incremental auth).
- Prefer not forcing
prompt=login (avoid full password/account picker). Use consent only when Google requires it for new scopes.
- 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)
-
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
-
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.
-
Optional: keep reauth=1 only for true session recovery / forced re-login, not for scope upgrades.
-
Sheets feature (separate or follow-up): GoogleSheetsService + export API using the same token-exchange helper as GoogleCalendarService.
Keycloak / ops checklist
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
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
- Exact transport of “extra scopes” into Keycloak (query param vs
kc_action / account-link style URL vs custom endpoint) — pick one and document.
- Whether Calendar scope should also move to on-demand grant (consistent model) or stay in Default Scopes until Sheets ships.
- Keycloak version we run in prod/stage and SPI compatibility constraints.
Summary
We need incremental Google OAuth scopes (request Calendar / Sheets / etc. only when the user uses that feature), while keeping:
requested_issuer=google) to obtain the stored Google access token for API callsToday, 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:
requested_issuer=google)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 remainsgoogle.Goal UX
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:requested_issuer=googleSPI behavior
On broker authorize URL construction:
openid profile emailor current Default Scopes for identity).https://www.googleapis.com/auth/calendar.events(or whatever Calendar uses today)https://www.googleapis.com/auth/spreadsheetsinclude_granted_scopes=trueso Google merges prior grants (true incremental auth).prompt=login(avoid full password/account picker). Use consent only when Google requires it for new scopes.What must NOT change
googlereauth=1revoke+cookie-clear path for scope upgrades)App / nuspace changes (after SPI exists)
New grant entrypoint (replace hard reauth for missing Google API scope), e.g.
GET /api/google/grant?scopes=spreadsheets&return_to=...Frontend: on
insufficient_google_scope(Calendar today, Sheets later) → open “Grant access” → grant URL withreturn_to, then retry — not “Sign in again” via/api/login?reauth=1.Optional: keep
reauth=1only for true session recovery / forced re-login, not for scope upgrades.Sheets feature (separate or follow-up):
GoogleSheetsService+ export API using the same token-exchange helper asGoogleCalendarService.Keycloak / ops checklist
googleIdP config (same client id/secret, Store tokens ON)requested_issuer=googleafter a normal loginOut of scope / non-goals
Acceptance criteria
/login?reauth=1cookie wipegoogleReferences (internal)
backend/modules/auth/keycloak_manager.py(exchange_token_for_idp)backend/modules/auth/api.py+service.py(reauth,prompt=login,kc_idp_hint=google)backend/modules/calendar/google_calendar_service.pyfrontend/src/features/courses/components/schedule-dialog.tsxOpen questions
kc_action/ account-link style URL vs custom endpoint) — pick one and document.