WING-214 — Google sign-in with cohort allowlists (app + MPA/CMS)¶
One Google identity opens every WorkWingman surface a person is entitled to. Cohort membership is decided server-side from Gmail allowlists; the matched cohort becomes the tenant session's audience and is stamped on every telemetry line (WING-208), so BigQuery can filter cohorts and exclude QA.
Surfaces¶
| Surface | Service | Flow | Allowlist source |
|---|---|---|---|
| App (app.workwingman.ai) | ww-team | /api/auth/google/start → /api/auth/google/callback |
WorkWingman:TenantSession:Cohorts (Secret Manager) |
| MPA staff + WingCMS (workwingman.ai / cms.workwingman.ai) | ww-site | /auth/google/start → /auth/google/callback |
WINGCMS_STAFF_EMAILS (Secret Manager) |
Invite codes remain the fallback on the app; the editor key remains the fallback on the CMS.
Cohorts (app)¶
Three cohorts: internal-testing, user-testing, qa. Config shape
(WorkWingman:TenantSession:Cohorts, injected from Secret Manager):
[
{ "Cohort": "internal-testing", "TenantId": "internal", "Emails": ["andrew@…"] },
{ "Cohort": "user-testing", "TenantId": "usertest", "Emails": ["…"] },
{ "Cohort": "qa", "TenantId": "qa-shared", "Emails": ["…"],
"CreatePrivateWorkspacePerMember": false }
]
- Cohort name = session audience (same grammar and audience-isolation validation as invitations; startup fails on violations, duplicate emails across cohorts, or a cohort configured without full Google client settings).
CreatePrivateWorkspacePerMember(default true) derives the tenant as{TenantId}-{sha256(email)[..16]}— deterministic, so a returning member lands in the same workspace and no email appears in a document id.- Emails are exact, case-insensitive addresses. No domain wildcards — lookalike domains can never match.
Per-session mode choice (WING-217)¶
Some people hold two roles — Shereeba does Test Bench QA and real user testing. Cohorts are exclusive, so rather than issuing a second Google account she is asked, at each sign-in, which mode this session is.
- Registry, not a cohort flag. Only emails in
WorkWingman:TenantSession:MultiModeEmailsmay appear in more than one cohort. Deliberately per-email: a cohort-wide flag would mean that once one dual-role member exists, a typo dropping somebody else into a second cohort boots cleanly and silently grants them a mode — including the sharedqaworkspace. - Enforced at startup and at sign-in. A config path that skips startup validation still fails closed instead of offering a choice.
- Nothing is minted until she picks. The verified identity is parked server-side and the
browser holds only an opaque
__Host-ww_pendinghandle (HttpOnly, Secure, Lax, 10 min, its own expiry checked against the service clock rather than the store's eventual TTL). - The choice is re-validated against her live allowlist entries, so a tampered mode buys nothing. The pending record is claimed atomically, so racing tabs cannot both mint.
- Parking the choice already bumps her generation, so signing in to kill a stolen session starts revoking immediately rather than after she finishes picking. A revocation that lands mid-choice wins: completing then mints nothing.
POST /api/auth/google/moderequires a same-origin request (Sec-Fetch-Site,Originfallback) on top of the Lax cookie, and only clears the pending cookie when the request actually carried one — otherwise a cross-site POST could wipe a live pending sign-in.- The active mode is always visible in the session pill, and unknown audiences render verbatim
rather than being collapsed into a friendly guess. The choice is audited (
ww_audit mode_chosen) outside the user-facing telemetry stream, because a self-selected cohort label is a claim about what kind of work this is.
Single-cohort members see none of this: no prompt, no extra round trip, no behaviour change.
Flow security (both surfaces)¶
- Authorization-code + PKCE, confidential web client,
scope=openid email. - State + PKCE verifier live in a
__Host-transaction cookie (HttpOnly, Secure, Lax, 10-minute cap) so the callback is bound to the initiating browser; state comparison is constant-time. - Identity is taken from Google's UserInfo endpoint over TLS;
email_verifiedis required. No local JWT validation surface. - Every failure (bad state, exchange failure, unverified email, unknown email) collapses to one generic denied redirect — no allowlist oracle, and callback responses are rate-limited.
- The app session cookie is the existing opaque
__Host-ww_sessionhandle; Google sign-in reuses the invitation session machinery (TenantSessionService.SignInWithGoogleAsync), so CSRF, admission limiting, retention metadata, and telemetry stamping are unchanged. - CMS callback ends with a same-site interstitial because
wingcms.sessionis SameSite=Strict and a cross-site-initiated redirect chain would drop it.
Cross-surface propagation¶
__Host- cookies cannot span subdomains, deliberately. Propagation rides the Google session
instead: after signing in on any surface, opening another surface's …/google/start completes
silently against the already-authenticated Google account (prompt=select_account shows the
chooser only when multiple accounts exist). One human sign-in; per-surface allowlists still
decide entitlement independently.
Downstream integrations (Drive, Jira, Slack): the session grant's Email is the join key.
Google Drive/Calendar/etc. already attach through the BYO desktop OAuth grant; hosted per-user
connector tokens (Jira, Slack) will key off grant.Email and store under the member's tenant.
That wiring is follow-up work — this ticket lands the identity spine they hang off.
Deployment (BYO credentials, all secrets in GCP Secret Manager)¶
- Create one OAuth web client in the WorkWingman GCP project with redirect URIs
https://app.workwingman.ai/api/auth/google/callbackandhttps://cms.workwingman.ai/auth/google/callback. - ww-team (Secret Manager → env):
WorkWingman__TenantSession__GoogleClientId,…__GoogleClientSecret,…__GoogleRedirectUri, plus the…__Cohorts__…allowlist entries. - ww-site (Secret Manager → env):
WINGCMS_GOOGLE_CLIENT_ID,WINGCMS_GOOGLE_CLIENT_SECRET,WINGCMS_GOOGLE_REDIRECT_URI,WINGCMS_STAFF_EMAILS. - Nothing enabled ⇒ nothing changes: both flows 404 until fully configured (fail-closed).