Skip to content

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:MultiModeEmails may 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 shared qa workspace.
  • 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_pending handle (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/mode requires a same-origin request (Sec-Fetch-Site, Origin fallback) 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_verified is 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_session handle; 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.session is 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)

  1. Create one OAuth web client in the WorkWingman GCP project with redirect URIs https://app.workwingman.ai/api/auth/google/callback and https://cms.workwingman.ai/auth/google/callback.
  2. ww-team (Secret Manager → env): WorkWingman__TenantSession__GoogleClientId, …__GoogleClientSecret, …__GoogleRedirectUri, plus the …__Cohorts__… allowlist entries.
  3. ww-site (Secret Manager → env): WINGCMS_GOOGLE_CLIENT_ID, WINGCMS_GOOGLE_CLIENT_SECRET, WINGCMS_GOOGLE_REDIRECT_URI, WINGCMS_STAFF_EMAILS.
  4. Nothing enabled ⇒ nothing changes: both flows 404 until fully configured (fail-closed).