ADR 0042 — Auth Unification: Hybrid Identity Model
Status: Accepted
Date: 2026-05-03
Deciders: Kristerpher (product owner), software-architect
Scope: All Raxx surfaces — operator and customer-facing
Design doc: docs/architecture/auth-unification.md
Supersedes: Nothing fully — extends ADR-0001 (passkeys-only applies to customer surfaces), extends ADR-0031 (platform auth posture)
Refs: Velvet v2 epic #907; directive 2026-05-03
Context
Raxx operates multiple surfaces. Each had its own ad hoc auth state:
- Console: passkey + TOTP (in progress, epic #146)
- FreeScout: one-time-pin email via CF Access + FreeScout local passwords (no SSO)
- Vault (Infisical): Infisical local credentials behind CF Access
- Internal docs: CF Access sole auth
- Antlers: passkeys (Direction C, locked 2026-04-25)
The operator auth story was fragmented: there was no single identity that worked across Console, tickets, and vault. Operators maintained separate credentials for each surface. CF Access provided a network gate but did not supply a unified identity layer. The directive: one login for all operator surfaces, with a transition plan so this is not revisited post-launch.
ADR-0001 established passkeys as the sole auth factor. Its scope was "all user-facing authentication." Operator surfaces evolved independently of that scope. This ADR resolves the gap by establishing a clear surface classification and IDP strategy.
Decision
D1: Google Workspace is the IDP for all operator surfaces
Every operator surface (Console, FreeScout, Infisical vault, internal docs, Velvet admin UI) uses Google Workspace as its identity provider, bound via CF Access Zero Trust. Operators authenticate once with their Google Workspace account (with Workspace-enforced 2FA); CF Access issues a JWT that carries email and groups. Each app layer reads that JWT and maps email → its local identity record.
This is not a violation of ADR-0001 because ADR-0001 scoped its passkey requirement to "user-facing authentication" — i.e. customers authenticating to Antlers. Operator surfaces were always implicitly outside that scope.
D2: Passkeys remain the primary factor for customer-facing Antlers
ADR-0001 and Direction C are unchanged. The customer auth flow on raxx.app and demo.raxx.app uses WebAuthn passkeys as the primary required factor. Google OAuth is an optional second sign-in path for Antlers in Phase 3 only, and only if it can be added without weakening or replacing the passkey path.
D3: Console keeps passkey primary + adds Google OAuth fallback
The Console specifically ships an additional "Sign in with Google" path as a fallback for operators who have lost their passkey device. The passkey path is not retired. Both paths issue the same full session; both write to console_audit_log. The passkey path continues to be the recommended primary path because it is phishing-resistant, origin-scoped, and does not depend on Google's availability.
The google_sub claim (not the Google email; sub is the stable identifier) is stored on the console_admins row as a binding. This is not a credential. It cannot be used to authenticate to Google. No Google token (access, refresh, or ID) is stored.
D4: Workspace 2FA policy is the accepted MFA posture for Google-gated operator surfaces
Rather than implementing per-app TOTP on every operator surface, the Workspace admin enforces strong 2FA on all @raxx.app accounts. CF Access validates the Google IDP session. This meets the intent of ADR-0031's Class 2 / Class 3 MFA requirements through a different mechanism (IDP-enforced factor rather than CF-enforced TOTP).
The Console passkey path still passes through CF Access at the network layer (presence-only), with the phishing-resistant passkey as the app-layer strong factor — consistent with ADR-0031 D3's long-term target.
D5: No auto-provisioning through Google OAuth
Google OAuth on any surface is an auth path, not a registration path. An operator must exist in the app's local identity table before Google OAuth can authenticate them. A valid Google Workspace account that is not pre-provisioned is rejected with 403. This prevents privilege escalation via Google account creation.
D6: hd claim validation is mandatory at every app layer
Every service that receives a Google ID token must validate that the hd (hosted domain) claim equals raxx.app. This is belt-and-suspenders: CF Access already enforces the email domain at the network layer, but an app that skips hd validation creates a path for personal Google accounts to match by email coincidence if the CF policy is ever misconfigured.
D7: PKCE required on every OAuth authorization flow
All OAuth 2.0 flows (Console Google fallback, Velvet admin UI OIDC, FreeScout OAuth module) must use PKCE. Authorization Code without PKCE is forbidden. This is enforced at code-review time and added to the CI security checklist.
Consequences
Positive
- Single Google Workspace login covers every operator surface. Operators stop maintaining separate credentials for Console, FreeScout, and vault.
- CF Access policy becomes auditable and reproducible (group-based, not email-allowlist-based).
- Workspace admin controls operator 2FA posture centrally. Adding or removing an operator's access is a single Workspace group change.
- FreeScout and Infisical vault gain a real SSO path instead of relying on CF Access as the sole gate with local passwords as a hidden second factor.
- Console passkey path is unchanged. Operators who prefer passkeys keep that path.
Negative / risks
- Google availability dependency for operator surfaces. If Google Workspace OIDC is down, operators cannot log into any gated surface via Google. Mitigation: Console passkey path is independent of Google; break-glass passkey access remains available.
google_substorage onconsole_admins. This is a new persistent data element. It is not a credential (cannot replay), but it is a stable identifier for an operator. Covered under GDPR operator data retention policy.- FreeScout OAuth module is a third-party module (Tagras). It is installed in the FreeScout instance — security review required before install. The module handles Google ID tokens; the code must be verified to not persist tokens.
- CF Access IDP migration is a one-time operation. During the swap from one-time-pin to Google OIDC, a 48-hour window with both IDPs active is required to avoid lockout. The window must be tested in staging first (staging CF policy updated ahead of prod).
- Phase 3 Google OAuth for Antlers requires a new ADR. This decision defers the customer-surface question and is explicit about deferral. No implementation should proceed without that ADR.
Neutral
- ADR-0001 (passkeys-only) is not superseded — its scope was always customer-facing. This ADR clarifies the operator-surface gap rather than overriding the invariant.
- GDPR obligations (ADR-0003) apply to
google_emailstored inconsole_admins. Covered by existing DSR tooling. - Antlers customers are not affected by Phase 1 or Phase 2.
Alternatives Considered
Alternative A: Passkeys-only for all surfaces including operators (no Google OAuth)
Rejected for operator surfaces (though this remains the customer-surface goal). The Console passkey bootstrap flow requires a magic-link email per operator per device. Scaling this to FreeScout, Infisical, and internal docs would require building custom passkey auth for each third-party tool, which is infeasible. The ADR-0031 Class 3 designation for FreeScout and vault acknowledges this: they are third-party tools whose auth cannot be replaced without replacing the tool.
For the Console specifically, passkey remains the primary path. Google is fallback, not replacement.
Alternative B: SAML federation through a dedicated IdP (Okta, Auth0, Azure AD)
Rejected at this stage. Raxx runs a one-operator team. Introducing an enterprise IdP adds cost, operational complexity, and another credential surface to protect, for zero user-experience benefit over "sign in with Google Workspace" which all operators already have. SAML readiness is preserved in the RBAC schema (ADR-0020 / rbac-design.md §9) for a future enterprise customer scenario.
Alternative C: CF Access is the only gate; no app-layer auth change
Rejected. CF Access provides network-layer identity and session management but does not supply a per-app identity record. FreeScout and Infisical have their own user tables; without SSO those tables have separate credentials. The directive was "one login for everywhere" — CF Access alone satisfies the network gate but not the app-identity unification.
Alternative D: Google OAuth replaces passkeys on the Console entirely
Rejected as primary path. The passkey is phishing-resistant and origin-scoped. Google OAuth, even with Workspace-enforced 2FA, relies on Google's OIDC endpoint being reachable. Replacing a phishing-resistant factor with a phishable one (OAuth is susceptible to MITM relay attacks if the state/PKCE implementation has gaps) is a security regression. Google is a fallback for device loss recovery, not a replacement for the primary factor.
References
- docs/architecture/auth-unification.md — full design doc
- [ADR-0001](https://internal-docs.raxx.app/architecture/adr/0001-webauthn-passkeys-only.html) — passkeys-only (customer-facing scope)
- [ADR-0002](https://internal-docs.raxx.app/architecture/adr/0002-no-stored-credentials.html) — no stored credentials
- [ADR-0003](https://internal-docs.raxx.app/architecture/adr/0003-gdpr-by-default.html) — GDPR by default
- [ADR-0031](https://internal-docs.raxx.app/architecture/adr/0031-platform-auth-posture.html) — platform auth posture (defense-in-depth; surface classes)
- [ADR-0119](https://internal-docs.raxx.app/architecture/adr/0119-rbac-groups-not-direct-roles.html) — RBAC groups, SAML readiness
- Velvet v2 epic #907 — per-caller service tokens for programmatic access
- FreeScout config #119 — FreeScout configuration runbook
- NIST SP 800-63B §5.1.7 — Phishing-Resistant Authenticators
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- Google Identity OIDC:
https://accounts.google.com/.well-known/openid-configuration