Raxx · internal docs

internal · gated ↑ index

Auth Unification — Hybrid Identity Model

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


1. Context

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.


2. Invariants (non-negotiable)

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).

3. Identity Model

3.1 Surface classification

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

3.2 The single operator identity

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:

Each 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.

3.3 No long-lived shared admin credentials


4. Identity Flow Diagrams

4.1 Operator login — Console (primary path: passkey)

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

4.2 Operator login — Console (fallback path: Google OAuth)

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

4.3 Operator login — FreeScout (OAuth Login module)

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

4.4 Customer login — Antlers (passkey, primary)

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)

4.5 Velvet admin UI — Google OIDC (Phase 2)

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)

5. CF Access Policy Redesign

5.1 Today vs target

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)

5.2 Workspace OIDC binding at CF Zero Trust

  1. In Cloudflare Zero Trust dashboard: Identity Providers → Add Google Workspace OIDC.
  2. Provide Google OAuth client_id and client_secret (scoped to openid email profile; store in Infisical under /cloudflare/cf-access-google-oidc).
  3. Enable "Email domain restriction": raxx.app only.
  4. Enable sync of Workspace groups (requires Workspace Admin SDK read scope on the OAuth app).
  5. Map Workspace groups to CF Access groups in the Zero Trust Groups UI (see §5.3).
  6. Replace the existing one-time-pin policy on every gated app with a "Google Workspace — email domain = raxx.app" policy.

5.3 CF Access group taxonomy

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.

5.4 Per-surface CF Access policy templates

# 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

6. Per-Surface App-Layer Auth

6.1 Console

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.

6.2 FreeScout

The OAuth Login module by Tagras is licensed and arriving. Configuration steps:

  1. Install module from FreeScout admin (upload zip or marketplace install).
  2. Create a new Google OAuth2 app in Google Cloud Console scoped to openid email profile (separate from CF Access's OAuth app to limit blast radius).
  3. Store client_id and client_secret in Infisical under /freescout/google-oauth.
  4. Configure module: allowed domain raxx.app; auto-create users OFF (operators must pre-exist in FreeScout); role mapping by email or Workspace group.
  5. CF Access remains outer gate. FreeScout Google OAuth is the app-layer identity.

No schema changes required (FreeScout manages its own user table). The integration writes no PII to Raptor or Console databases.

6.3 Velvet admin UI (Phase 2, NV12)

Velvet admin UI is unbuilt. Ships with Google OIDC from day one:

6.4 Antlers customer surfaces

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.


7. Data Model

7.1 Console schema additions

-- 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.

7.2 Velvet admin schema (Phase 2)

-- 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);

8. Migrations

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

9. Transition Plan

Phase 1 — This week (target: 2026-05-09)

Depends on: DQ-4 group taxonomy decision; Google OAuth app created in Cloud Console; FreeScout license key landed in vault.

  1. 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.

  2. 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.

  3. 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.

  4. 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 2 — With Velvet admin UI build (NV12 sub-card)

  1. Velvet admin UI ships with Google OIDC from day one (#NV05). No password path. Ships into CF Access Class 2 policy. Per-caller service tokens for programmatic (already ADR-0037–0041).

Phase 3 — Post-MBT-v1 (customer surface)

  1. Antlers optional Google OAuth (#NV06). Separate ADR required. Passkeys remain primary. Google is additive. No customer forced to use Google. Do not implement before MBT-v1 ships.

10. Rollout Plan

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.


11. Security Considerations

GDPR checklist

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.

Additional posture notes


12. Open Questions (require Kristerpher's decision before sub-cards can be claimed)

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).