Status: Design locked 2026-05-03
Owner: software-architect
Refs: Kristerpher directive 2026-05-03; Velvet v2 epic #907; FreeScout config #119
Related ADRs: 0001, 0002,
0003, 0031, 0042
Design PR: filed against this doc — Refs parent card in PR body
Raxx currently runs two independent auth regimes:
Kristerpher's directive: "I eventually just want one login for everywhere." The recommended solution, accepted 2026-05-03: Google Workspace as the IDP for every operator surface, with passkeys remaining the primary factor for customer-facing Antlers. A transition plan is required; rebuilding auth after launch is not acceptable.
This document specifies the hybrid identity model, the CF Access redesign, per-surface app-layer auth, the transition plan, and the lock-down posture.
The following constraints override every design choice in this document. If any section reads as violating one, treat it as a bug in the doc, not a decision to route around.
| # | Invariant |
|---|---|
| I1 | No stored credentials. Google OIDC ID tokens are short-lived bearer tokens at the app boundary — they are not stored between requests. The only persistent artifact is the mapping from a Google sub claim to an admin_id. |
| I2 | Passkeys / WebAuthn only for customer-facing Antlers auth (Direction C locked; ADR-0001). Google OAuth is an optional second path post-MBT-v1 only, and must not weaken or replace the passkey path. |
| I3 | Email is the single contact channel (after verification). No phone, no SMS, no push. |
| I4 | GDPR by default: data subject rights, retention limits, DPA-ready logging, breach-notification automation. |
| I5 | Paper-first gating applies to all live-trading code paths. Auth unification does not touch gating logic. |
| I6 | Credentials into infra, not into code. Google OAuth client_id and client_secret live in the secret store. Never in files that ship. |
| I7 | Audit trail for every state change that affects money, permissions, or data access. Every login event writes to the relevant app's audit table. |
| I8 | CF Access remains the outer perimeter gate for all operator surfaces — no exception. |
| I9 | Workspace policy enforces 2FA on every Google account. Raxx does not control the factor; Workspace admin does. This is the accepted posture for operator surfaces (see ADR-0031 D3). |
| Surface | Audience | IDP | App-layer auth | CF Access class |
|---|---|---|---|---|
console.raxx.app |
Operators | Google Workspace | Passkey primary + Google OAuth fallback → maps to admin_id |
Class 2 (transitional until passkey GA; see ADR-0031) |
tickets.raxx.app (FreeScout) |
Operators | Google Workspace | FreeScout OAuth Login module → maps Google email → FreeScout user | Class 3 (third-party tool; CF MFA non-relaxable) |
vault.raxx.app (Infisical) |
Operators | Google Workspace | Infisical Google OIDC SSO | Class 3 (third-party tool; CF MFA non-relaxable) |
internal-docs.raxx.app |
Operators | Google Workspace | CF Access sole auth (no app layer) | Class 4 (static; CF sole auth) |
| Velvet admin UI (NV12) | Operators | Google Workspace | Google OIDC at app layer → maps to velvet_admin_id |
Class 2 |
raxx.app / demo.raxx.app (Antlers) |
Customers | Self (passkeys) | WebAuthn passkeys (primary); Google OAuth optional post-MBT-v1 | Class 1 (customer-facing; no CF gate on customer paths) |
| Programmatic / API callers (Velvet) | Services | Service tokens | Per-caller scoped Velvet service tokens (ADR-0037–0041) | N/A |
A Google Workspace identity (@raxx.app or permitted domain) is the root credential for all operator surfaces. CF Access binds to the Workspace IDP and issues a JWT (the CF Access JWT) that carries:
email: the Google account emailgroups: CF Access group memberships (see §4.2)aud: the application audience tagEach app layer trusts the CF Access JWT and maps email → its local identity record (admin_id, FreeScout user id, Infisical user, etc.). The Google sub claim — not the email — is stored as the IDP-stable identifier to survive email renames. However, email remains the human-readable audit key and must also be stored.
ops@raxx.app.sequenceDiagram
participant Op as Operator
participant CF as CF Access (edge)
participant C as Console app
participant W as Google Workspace
Op->>CF: GET console.raxx.app
CF->>W: OIDC authorize (Google Workspace IDP)
W-->>Op: Google sign-in prompt
Op-->>W: Authenticate + 2FA (Workspace-enforced)
W-->>CF: ID token (email, sub, groups)
CF->>CF: Validate group membership (see §4.2)
CF-->>Op: Set CF Access JWT cookie; forward to Console
Op->>C: GET /dashboard (carries CF Access JWT)
C->>C: Validate CF JWT (JWKS); extract email
C->>C: Lookup admin by email — must exist
C-->>Op: Show passkey login form
Op->>C: POST /auth/passkey/login/begin
C-->>Op: WebAuthn assertion options
Op-->>C: Assertion (Touch ID / YubiKey)
C->>C: Verify assertion; issue full session cookie
C-->>Op: Redirect to /dashboard
sequenceDiagram
participant Op as Operator
participant CF as CF Access (edge)
participant C as Console app
participant G as Google OAuth
Op->>CF: GET console.raxx.app
CF-->>Op: CF Access JWT (already established, as above)
Op->>C: Click "Sign in with Google"
C->>G: OAuth2 PKCE authorize (google_sub, email scopes only)
G-->>Op: Google confirm (already authenticated via CF)
Op-->>G: Approve scopes
G-->>C: Code → exchange → ID token (sub, email)
C->>C: Verify ID token; lookup admin by google_sub
C->>C: If admin exists and is active → issue full session
C-->>Op: Redirect to /dashboard
Note over C: google_sub stored on admin row — no token stored
sequenceDiagram
participant Op as Operator
participant CF as CF Access (edge)
participant FS as FreeScout
participant G as Google OAuth
Op->>CF: GET tickets.raxx.app
CF->>CF: Check Google Workspace IDP + group claim
CF-->>Op: CF Access JWT
Op->>FS: GET /login
FS-->>Op: "Sign in with Google" button (OAuth Login module)
Op->>G: OAuth2 flow
G-->>FS: ID token (email)
FS->>FS: Match email → FreeScout user
FS-->>Op: Authenticated session
Note over CF,FS: CF Access is outer gate; FreeScout OAuth is app auth
sequenceDiagram
participant U as Customer
participant A as Antlers (raxx.app)
participant R as Raptor
U->>A: Click "Sign in"
A->>R: POST /api/auth/login/options
R-->>A: PublicKeyCredentialRequestOptions
A->>U: Browser passkey picker
U-->>A: Assertion (Face ID / YubiKey)
A->>R: POST /api/auth/login/verify
R->>R: Verify assertion; issue session cookie
R-->>A: {user_id, role}
Note over U,R: No CF Access gate on customer path (Class 1)
Note over A,R: Google OAuth path available post-MBT-v1 (separate ADR when built)
sequenceDiagram
participant Op as Operator
participant CF as CF Access (edge)
participant V as Velvet admin UI
participant G as Google OIDC
Op->>CF: GET velvet.raxx.app (or console sub-path)
CF-->>Op: CF Access JWT (Workspace IDP, group validated)
Op->>V: Page load
V->>G: OIDC authorize (PKCE; openid email profile)
G-->>Op: Confirm (already authenticated)
G-->>V: ID token (sub, email, hd claim)
V->>V: Validate hd == raxx.app; map email → velvet_admin_id
V-->>Op: Authenticated session (read CF JWT groups for RBAC)
| Today | Target | |
|---|---|---|
| IDP | Org email allowlist (Cloudflare one-time-pin) | Google Workspace OIDC |
| Group claims | None | CF Access groups mapped from Workspace groups |
| Per-app policy | Email allowlist | Group membership check |
| MFA | TOTP on CF Access for Class 3/4 | Workspace-enforced 2FA (Google's own factors) |
openid email profile; store in Infisical under /cloudflare/cf-access-google-oidc).raxx.app only.CF Access groups carry through as JWT claims. The groups array in the CF JWT is read by each app layer for RBAC resolution. Groups align with the RBAC design in rbac-design.md.
| CF Access group | Maps to Workspace group | Carried JWT claim |
|---|---|---|
cf-ops |
ops@raxx.app (Workspace group) |
groups: ["ops"] |
cf-superadmin |
superadmin@raxx.app |
groups: ["superadmin"] |
cf-developer |
eng@raxx.app |
groups: ["developer"] |
cf-auditor |
auditor@raxx.app |
groups: ["auditor"] |
Every operator surface checks that the CF JWT groups claim contains at least one required group. An operator whose Workspace account is not in any mapped group is denied at the CF layer before reaching the app.
Open question DQ-4: Kristerpher approves the four-group taxonomy (ops, superadmin, developer, auditor) or amends it. This must be decided before Phase 1 CF reconfiguration because CF group creation happens in the dashboard at config time.
# Console — Class 2
policy:
name: "console-operator"
decision: allow
include:
- group: cf-ops
- group: cf-superadmin
- group: cf-developer
# FreeScout — Class 3
policy:
name: "tickets-operator"
decision: allow
include:
- group: cf-ops
- group: cf-superadmin
require:
- auth_method: mfa # non-relaxable; FreeScout auth is unaudited
# Vault (Infisical) — Class 3
policy:
name: "vault-operator"
decision: allow
include:
- group: cf-ops
- group: cf-superadmin
require:
- auth_method: mfa # non-relaxable
# Internal docs — Class 4
policy:
name: "internal-docs-operator"
decision: allow
include:
- group: cf-ops
- group: cf-superadmin
- group: cf-developer
- group: cf-auditor
require:
- auth_method: mfa # non-relaxable (no app layer)
# Velvet admin UI — Class 2 (Phase 2)
policy:
name: "velvet-operator"
decision: allow
include:
- group: cf-ops
- group: cf-superadmin
Current state: passkey registration (bootstrap magic-link) + TOTP elevation. Full session issued after passkey + TOTP.
Change: Add Google OAuth as a fallback login path. Dual-factor retained: CF Access gate (network) + passkey-or-Google (app).
Schema changes to console_admins:
-- Add to existing console_admins table
ALTER TABLE console_admins ADD COLUMN google_sub TEXT UNIQUE;
ALTER TABLE console_admins ADD COLUMN google_email TEXT;
-- google_sub is the stable IDP identifier; google_email is for human audit readability
-- Neither is a credential; neither can be replayed to authenticate.
New route: GET /auth/google/callback — receives Google OAuth code, verifies ID token via Google JWKS, extracts sub + email, looks up admin by google_sub (or by email for the first bind), issues full session. On first Google login, binds google_sub to the existing admin row (email match required; admin must already exist — no self-registration via Google OAuth).
Open question DQ-1: Keep passkey as primary (recommended) with Google OAuth as fallback — or switch Console to Google-only? If Google-only, TOTP enrollment flow (currently shipped in auth.py) becomes dead code and must be removed. Recommended: keep passkey primary; Google is fallback for operators who've lost their passkey device.
The OAuth Login module by Tagras is licensed and arriving. Configuration steps:
openid email profile (separate from CF Access's OAuth app to limit blast radius).client_id and client_secret in Infisical under /freescout/google-oauth.raxx.app; auto-create users OFF (operators must pre-exist in FreeScout); role mapping by email or Workspace group.No schema changes required (FreeScout manages its own user table). The integration writes no PII to Raptor or Console databases.
Velvet admin UI is unbuilt. Ships with Google OIDC from day one:
hd claim == raxx.app at the app layer (belt-and-suspenders against CF misconfiguration).sub + email → velvet_admin_id in Velvet's own DB (same pattern as Console google_sub).groups claim to resolve operator permissions within Velvet.No change in Phase 1 or Phase 2. Passkeys remain the primary and required factor per ADR-0001 and Direction C. Google OAuth as an optional second sign-in path is scoped to Phase 3 (post-MBT-v1) and will require a new ADR before implementation.
Open question DQ-3: Is "no customer is forced to use Google" a hard requirement? Recommended yes — a customer who has registered a passkey must never be told "sign in with Google instead." Google is additive.
-- Migration: 0NNN_console_google_oidc.sql
-- Adds Google OIDC binding columns to console_admins.
-- No existing rows are disrupted; both columns are nullable on add.
ALTER TABLE console_admins ADD COLUMN google_sub TEXT UNIQUE;
ALTER TABLE console_admins ADD COLUMN google_email TEXT;
-- Index for login lookup by google_sub
CREATE INDEX idx_console_admins_google_sub ON console_admins(google_sub)
WHERE google_sub IS NOT NULL;
-- Audit log entries for Google OAuth events
-- Reuses existing console_audit_log table with new action values:
-- 'auth.google_bind' — first time google_sub bound to admin row
-- 'auth.google_login' — full session issued via Google OAuth path
-- 'auth.google_unbind' — operator removes Google binding (passkey remains)
What is absent: no storage of Google access tokens, refresh tokens, or ID token strings. The google_sub value is an opaque identifier, not a credential. It cannot be used to authenticate to Google.
-- velvet_admins table (new, ships with Velvet admin UI build)
CREATE TABLE velvet_admins (
id TEXT PRIMARY KEY, -- uuid v4
google_sub TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
display_name TEXT,
role TEXT NOT NULL DEFAULT 'ops',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX idx_velvet_admins_google_sub ON velvet_admins(google_sub);
| Migration | Table | Operation | Rollback |
|---|---|---|---|
0NNN_console_google_oidc.sql |
console_admins |
ADD COLUMN google_sub, google_email |
DROP COLUMN (columns are nullable; no data loss if never populated) |
| Velvet admin schema | velvet_admins (new) |
CREATE TABLE | DROP TABLE (new table; no existing data) |
| CF Access policy | CF Zero Trust (infra) | Replace one-time-pin IDP with Google Workspace OIDC | Re-add one-time-pin IDP; CF Access supports multiple IDPs simultaneously |
| FreeScout module | FreeScout (app) | Install + configure OAuth Login module | Disable module; existing FreeScout user accounts unaffected |
Depends on: DQ-4 group taxonomy decision; Google OAuth app created in Cloud Console; FreeScout license key landed in vault.
CF Access: bind Google Workspace OIDC IDP (#NV01). Create Google OAuth app for CF Access (Cloud Console). Configure Zero Trust Identity Provider. Create four CF Access groups per §5.3. Replace one-time-pin policies with group-based policies on all four gated apps. Validate login with Kristerpher's Google account.
FreeScout: install + configure OAuth Login module (#NV02). Module zip in vault. Install via FreeScout admin. Configure Google OAuth app (separate from CF Access's). Test: login via Google OAuth, confirm FreeScout user matches. CF Access gate remains as outer layer.
Console: "Sign in with Google" fallback path (#NV03). Add google_sub + google_email columns to console_admins (migration). New /auth/google/callback route. Bind Kristerpher's Google account to existing admin row. Test both paths: passkey (primary) and Google (fallback). Audit log entries for both. Feature-flagged behind CONSOLE_GOOGLE_AUTH=1.
Vault: Google OIDC SSO in Infisical (#NV04). Infisical supports Google OIDC natively. Configure under Infisical org SSO settings. Map @raxx.app domain. Test with Kristerpher's Google account. CF Access outer gate remains.
| Phase | Flag | State |
|---|---|---|
| Dark | CONSOLE_GOOGLE_AUTH=0 (default) |
Migration deployed, routes exist but unreachable |
| Internal | CONSOLE_GOOGLE_AUTH=1 on staging |
Kristerpher tests both login paths |
| Beta | CONSOLE_GOOGLE_AUTH=1 on prod |
Full session issued via Google path; audit log confirmed |
| GA | Flag removed; Google path always-on | Passkey remains primary; Google is permanent fallback |
CF Access IDP swap (Phase 1, step 1) is not feature-flagged — it is a CF Zero Trust configuration change. The existing one-time-pin IDP is left enabled alongside Google OIDC during a 48-hour validation window, then removed.
| Question | Answer |
|---|---|
| What PII does this collect? | google_sub (opaque identifier, not PII in isolation), google_email (PII). Both stored in console_admins — operator data, not customer data. |
| What is the retention period? | Retained for the lifetime of the admin account. Removed on account deletion (operator off-boarding). |
| How is it deleted on DSR? | console_admins row deletion (or nulling of google_sub/google_email) on operator DSR. Covered by existing DSR admin tooling. |
| What is logged for audit? | Every Google OAuth login, bind, and unbind writes to console_audit_log. Retained 2 years (same as other audit rows; ADR-0003). |
| Does any part of this store a credential that can be replayed? | No. google_sub cannot authenticate to Google. ID tokens are verified in-flight and discarded. No refresh tokens stored. |
| What happens on breach? | console_admins exfiltration exposes google_sub and google_email. Attacker cannot replay these to access Google or the Console (no credential value). Incident response: rotate all console session tokens, notify per ADR-0003 breach pipeline. |
| Where are secrets? | Google OAuth client_id + client_secret in Infisical. CF Access OAuth app credentials in Infisical. Both rotatable without redeploy via Velvet rotation flow. |
| Kill-switch for live execution paths? | CONSOLE_GOOGLE_AUTH=0 disables Google path without disabling passkey path. CF Access policy can be reverted via CF dashboard without a Raptor deploy. |
hd claim validation: every app receiving a Google ID token must validate that hd (hosted domain) == raxx.app. This prevents a personal Google account from matching by email coincidence.exp claim on every presentation. Expired ID tokens must be rejected, not silently accepted.DQ-1: Console auth path priority — keep passkey as primary with Google OAuth as fallback (recommended), or switch Console to Google-only and retire the TOTP + passkey-bootstrap flow?
DQ-2: 2FA enforcement — Workspace-level only (recommended; Workspace admin controls factors), or also per-app TOTP for SEV-1 / break-glass actions on the Console? The existing TOTP step in auth.py already provides this; the question is whether to retain it post-Google integration or simplify to Google + Workspace 2FA only.
DQ-3: Customer passkey — is "passkeys are primary and no customer is ever forced to Google" a hard requirement? (Recommended yes, per Direction C.)
DQ-4: CF Access group taxonomy — approve ops, superadmin, developer, auditor as the four group names? These must be created in CF Zero Trust before Phase 1 can proceed. Amendments after creation require re-issuing all CF Access JWTs (cache flush).
End of design. See ADR-0042 for the architectural decision record. Sub-cards: NV01–NV06 (filed in issue tracker, linked to parent card).