Authentication (Clerk)
Overview
Authentication is handled by Clerk. Clerk manages sign-up, sign-in, OAuth (Google), email verification, and password resets. The app no longer has custom JWT generation, bcrypt password hashing, or Auth0 integration.
See api-reference.md for the full endpoint/schema reference, including the SessionToken header contract.
Architecture
Frontend Flow
- User visits
/sign-inor/sign-up→ web renders Clerk's<SignIn>/<SignUp>components; native (Capacitor) renders neither — see Native Sign-In & Sign-Up Entry Surface below - User authenticates (email/password, Google OAuth, etc.)
- Clerk redirects to
/dashboard - Router guard waits for Clerk to load, obtains a session token
- Router calls
syncUserGraphQL mutation to create/find the local DB user Session.setPublicId()stores the user'spublic_idin localStorage- Reactive refs (
useClerkSync) update → all component queries fire
Backend Flow
- Every GraphQL request includes a
SessionTokenheader with the Clerk JWT GraphqlController#current_usercallsClerkJwtVerifier.verify(token)- The verifier decodes the RS256 JWT using Clerk's JWKS endpoint (cached)
- The
subclaim contains the Clerk user ID →User.find_by(clerk_user_id:) - If no local user exists,
fetch_clerk_usercalls Clerk's API andClerkUserSynccreates one context[:current_user]is set for GraphQL resolvers
Key Files
| File | Purpose |
|---|---|
ionic_frontend/src/views/SignIn.vue | Web: Clerk <SignIn> component with demo login button. Native: own OAuth + password UI, never Clerk's component — see Native Sign-In & Sign-Up Entry Surface |
ionic_frontend/src/views/SignUp.vue | Web: Clerk <SignUp> component. Native: own OAuth-only account-creation UI, never Clerk's component — see Native Sign-In & Sign-Up Entry Surface |
ionic_frontend/src/composables/useNativeOAuth.ts | Shared native OAuth logic (handleNativeOAuth, enabled strategies) — consumed by both SignIn.vue and SignUp.vue |
ionic_frontend/src/router/index.ts | Auth guard, Clerk token wait, syncUser call |
ionic_frontend/src/composables/useClerkSync.ts | Shared reactive refs for synced user state |
ionic_frontend/src/apollo-client.ts | Apollo auth middleware (injects Clerk token) |
ionic_frontend/src/helpers/session.js | localStorage utilities + decoded() compat shim |
rails_api/app/services/clerk_jwt_verifier.rb | RS256 JWT verification via JWKS |
rails_api/app/interactions/user_identity/clerk_user_sync.rb | Find-or-create local user from Clerk profile |
rails_api/app/graphql/mutations/sync_user.rb | Returns user + firstSignIn for onboarding |
rails_api/app/controllers/graphql_controller.rb | Extracts current_user from Clerk JWT |
Demo Account
A demo account is available for testing and previews:
Email:
demo@objectuve.comPassword:
Objectuve-Demo-2026!Clerk user ID: differs per Clerk instance — IDs are not portable between them:
Clerk instance ID Development / staging ( prime-rooster-38,ins_3BB6SHq3xvAVWFg2CT6HCd8zmNZ)user_3BDXP3qf0JbHXzxJS4fHNRxUw5SProduction ( ins_3BFNbcIZ5fIRl9gR9yu8dBnAL9M)user_3BFX1kMjFVMmED4xfEfFquPZtxKTwo independent seed paths provision this account, one per environment tier:
- Production —
db/seeds/production.rbcreates the account (skipping with a warning, not raising, if unset) and reads the ID fromDEMO_CLERK_USER_ID(set indeploy/service.production.yaml). - Development / staging —
db/seeds/development/users.rb(loaded by bothdb/seeds/development.rbanddb/seeds/staging.rb, and re-run on everyrake demo:reset) self-heals the account on each seed: it creates the row if missing, restores it if soft-deleted, and otherwise leaves it in place — closing a gap where a missing account previously failed silently instead of raising (OBJ-3555). It reads the ID fromDEMO_CLERK_USER_ID_DEV, falling back to theprime-rooster-38literal above when that's unset.
Never hardcode either value into seed or app code without an env-var override — a cross-instance ID produces a demo account that cannot authenticate.
- Production —
Demo Login Button
The sign-in page has a hidden "Try Demo Account" button that programmatically signs in with the demo credentials. To reveal it, add ?demo to the URL:
/sign-in?demoThis is useful for:
- PR preview environments
- Local development after
db:seed - QA testing without creating new accounts
- Smoke tests that need a pre-seeded user
The demo user is pre-created in Clerk's test mode. The seed files (db/seeds/development.rb, db/seeds/production.rb) create a matching local user with goals, communities, and other sample data linked to the demo Clerk user ID.
The Clerk user ID is configurable via environment variable — DEMO_CLERK_USER_ID for production, DEMO_CLERK_USER_ID_DEV for development/staging — for environments that use a different Clerk instance.
Demo Login Failure Handling
If window.Clerk hasn't finished loading when the button is tapped, SignIn.vue polls for readiness with a bounded wait (DEMO_CLERK_WAIT_MS = 5s) rather than waiting indefinitely. Every non-success path — the bounded wait timing out, a missing clerk.client, an unexpected needs_second_factor (the demo account should never require 2FA — treat this as a signal to check the Clerk dashboard), a non-complete sign-in status, or a thrown error — surfaces the same user-visible message ("Demo sign-in didn't go through. Try again in a moment.") and resets the button to its idle state. Nothing is left silently spinning.
Environment Variables
Backend (rails_api/.env)
CLERK_SECRET_KEY=sk_test_...Loaded by dotenv-rails in development/test. In production, this is stored in GCP Secret Manager as clerk-secret-key and injected into Cloud Run.
Frontend (ionic_frontend/.env)
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...Loaded by Vite at build time. In CI/CD, this is set as an environment variable in the GitHub Actions workflow.
Clerk Dashboard Configuration
- Allowed redirect URLs:
http://localhost:5173,https://app.objectuve.com,https://staging.app.objectuve.com,capacitor://localhost - Sign-in methods: Email/Password, Google OAuth, Apple OAuth (
oauth_apple— primary/native: the App-Store-mandated equivalent login option, listed first in the native OAuth button order; see Native (Capacitor) Auth Path below)- "Google OAuth" being enabled here is a Clerk-instance-level fact, not a guarantee it works on every platform. iOS's native Google Sign-In (the
GOOGLE_IN_PROCESSpath below) additionally depends on a client-side credential — a Google Cloud Console iOS OAuth client ID, wired throughionic_frontend/ios/google-signin.xcconfig— that is entirely independent of this Clerk setting. That credential was never wired into the build pipeline, so native Google Sign-In was 100% broken on iOS from 2026-08-16 to when OBJ-2772 landed the wiring + a CI gate, even though Clerk had Google enabled the entire time. See gotchas.md § An empty xcconfig*_DEFAULTwith an unwired*_OVERRIDEships silently broken to every real build for the mechanism, and the Google OAuth row below for the iOS-specific flow this credential gates.- Clerk exposes no readable surface — public or authenticated — for the native client-id trust list this credential depends on, so the drift guard cannot cover it (OBJ-3395). Both the public FAPI (
GET /v1/environment, both instances) and the authenticated Backend API (/v1/instance,/v1/instance/organization_settings,/v1/oauth_applications,/v1/saml_connections) were probed live: none return a field for the nativeaudtrust list Clerk validates the Google sign-in token against. The one Google-adjacent field/v1/environmentdoes return,display_config.google_one_tap_client_id, is a different credential — the web Google Identity Services "One Tap" widget's client ID — and would not move if the native trust list changed on the dashboard, so tracking it inscripts/clerk-auth-config.expected.jsonwould give false confidence rather than real coverage. Until Clerk adds a read API for this, keeping it correct is a manual pairing (rotate/add the iOS client ID in Google Cloud Console and add it to Clerk's dashboard SSO connection), not a mechanical check.
- Clerk exposes no readable surface — public or authenticated — for the native client-id trust list this credential depends on, so the drift guard cannot cover it (OBJ-3395). Both the public FAPI (
- "Google OAuth" being enabled here is a Clerk-instance-level fact, not a guarantee it works on every platform. iOS's native Google Sign-In (the
- Username: enabled at the Clerk-instance level, but that setting only drives the hosted web
<SignUp>form — it is not what makes username universal across every sign-up path. See Username Collection below. - Legal consent:
sign_up.legal_consent_enabledistrueon both production and staging (scripts/clerk-auth-config.expected.json, re-probed 2026-08-19) — this is the requirement that actually blocks native Google OAuth account creation today (see Native (Capacitor) Auth Path below), and this milestone deliberately did not touch it.
Production auth-config changes go through a pull request, never the dashboard alone. scripts/clerk-auth-config.expected.json is the checked-in, PR-reviewed statement of what production should hold — never hand-edit it to match a live (possibly incident) state just to turn a red check green; it encodes intent, not current reality (see the file's own _meta.warning).
The incident this rule exists to prevent (OBJ-2604). On 2026-08-15T15:19:40Z, production Clerk's username.required and password.required were toggled true → false, instance-wide, directly in the Clerk dashboard — no diff, no PR, no review — as OBJ-2577's chosen remedy for native Google OAuth's missing_requirements dead-end. It did not work: sign_up.legal_consent_enabled was (and is) true, so Google sign-up still could not satisfy Clerk's requirements; the change traded away web username enforcement for nothing.
Exposure window:
2026-08-15T15:19:40Z→2026-08-19T00:35:27Z(~3.4 days). The closing bound is RESTORE-1:username.requiredlast observedfalseat00:34:33Z, first observedtrueat00:35:27Z— Clerk's dashboard exposes no save timestamp, so this 54-second bound is the tightest honest one, and the later edge was chosen deliberately (it can only widen the count, never miss an account). Staging's matching flip was corrected the same night,00:32:36Z–00:34:33Z(RESTORE-6).Blast radius: exactly 1 production account (RESTORE-5).
count=1 total=1— checked againstUser.unscopedfor soft-deletes too (0;Userusesacts_as_paranoid, so a plain.countalone would silently omit soft-deleted rows).totalandcountare the same row: production took exactly one sign-up in the window, and it landed without a username. The rate is not meaningful at n=1 — the blast radius is: one account, not a cohort. Scope limit: this counts RailsUserrows, not Clerk identities — a Clerk identity created but never synced throughClerkUserSyncwould not appear here.User impact: zero. The one affected account needs no correction — no backfill, no manual fix.
username.requiredis enforced by Clerk on the Backend API'screateUser/updateUsercalls, but it is not enforced on sign-in — the load-bearing, counter-intuitive fact here, since reasoning from the setting's name alone gets it wrong: an instance-level "required" attribute that gates account creation without gating authentication. A username-less account signs in cleanly on both web (hosted<SignIn>, run 32291914194) and native (direct FAPI,status=completeon the first call) — neither surface asks for a username. Source: COHORT-1 (OBJ-2729,2026-08-19T19:56Z), captured traces on both surfaces, not code reads.- This is inferred for production, not observed. The traces above are from staging. Production holds the same
username.required = trueand runs the same code, so the inference is strong — but confirming it directly would mean signing into a real user's account, which was deliberately not done. - Confirmatory-only aside: during COHORT-1's provisioning, a username-less session was separately observed being redirected to
/profile/complete/about?redirect=/dashboardby the live router gate. That trace was correctly excluded as COHORT-1 evidence (it was captured under a different, temporarily-flipped config state), but it retires the caveat that Phase 1's restated criterion 4 (evaluateUsernameGateredirects a username-less session) had closed on unit tests alone, with no live-session observation.
- This is inferred for production, not observed. The traces above are from staging. Production holds the same
password.requiredstaysfalseon production — a deliberate decision (Josh), not an unfinished restoration. Objectuve supports passkey as a first factor and OAuth sign-in, so a mandatory password is a step backward for both; a required password also plausibly re-addspasswordto the OAuthmissing_requirementsset, the exact mechanism that dead-ended OBJ-2577 in the first place. The original ask to restore it ("parity with staging") is also withdrawn: that premise never held — staging readpassword.required: falsetoo, same as production, both before and after this incident.A separate, unrelated Clerk attribute —
password.used_for_first_factor— is alsofalseon both instances, and it's the reason a stuck/factor-onescreen with no password field can look exactly like a lockout when it isn't. Clerk's hosted<SignIn>auto-selectsemail_linkas the first factor for any account with a password. The drift guard below now tracks this field too (added by PR #2510, same OBJ-2769 investigation), so a live divergence surfaces on its next daily run — but checkingsupported_first_factors/GET /v1/environmentdirectly is still the right immediate diagnostic for a stuck/factor-onescreen right now. See gotchas.md § A stuck/factor-onewith no password field isn't necessarily a lockout for the full detail and recovery path. (OBJ-2729, OBJ-2769)scripts/check-clerk-auth-config.mjscompares live production against that snapshot, and live production against staging (modulo the snapshot'sstaging_accepted_divergences, each of which must carry an owner issue). That list is currently empty — the three divergences originally recorded under OBJ-2638 all converged, so staging is now held to full parity with production on every compared path. Adding an entry is the one edit to this snapshot that makes the guard weaker, andscripts/check-clerk-auth-config.test.mjspins the list to empty precisely so that adding one has to be deliberate.The snapshot also supports
staging_temporary_windows(fields:attribute,opened_at,expected_close_by,owner_issue) — a second, time-boxed exemption mechanism, distinct from the permanentstaging_accepted_divergencesabove. A divergence matching an open, unexpired window reports as informational rather than an unaccepted failure; onceexpected_close_bypasses, it falls through to the same hard-failure path as an unrecorded divergence — a stale window is not a way to extend grace. Use this for an authorized, in-flight staging flip (e.g. provisioning a test fixture) that's expected to resolve by a known date, not for an open-ended exception (OBJ-2755).A divergence that resolves is a hard failure, not a pass. If a path listed in
staging_accepted_divergencesis currently equal between prod and staging, the script exits non-zero and asks you to remove the entry — the snapshot is stale, and a stale exemption is an unnoticed hole. This is a real failure mode, not a hypothetical: it is what turned the nightly cron red until OBJ-2638 cleared the list..github/workflows/clerk-auth-config-drift.ymlruns it daily (0 13 * * *UTC ≈ 08:00 America/Chicago) against both instances' liveGET /v1/environment, plus a self-test on every PR that touches the guard's own files.A mismatch is always either a real out-of-band dashboard change or a stale snapshot — there's no legitimate-false-positive class here, so a red run means investigate, not silence. See OBJ-2609.
Username Collection
username is required everywhere downstream of sign-up — /u/:username, ally search, and PublicProfileType.username is null: false (rails_api/app/graphql/types/public_profile_type.rb:7) — but historically only one of the app's sign-up paths actually collected one at sign-up time. Coverage today comes from two layers, but they are not equivalent, and the Clerk dashboard setting is not optional just because a second layer exists:
- Web (Clerk hosted
<SignUp>). Collects a username inline, as part of Clerk's own sign-up form, because the Clerk Dashboard "Username" requirement above applies to that hosted flow. - Native Apple, OAuth redirect (web Google/Apple, native Google once OBJ-2506 ships), and Android native (whatever OBJ-2542 lands). None of these are guaranteed to collect a username at sign-up — native Apple sign-up verifiably doesn't (
useClerkNative.ts'screateSignInWithAppleToken/createSignUpWithAppleTokencalls carry no username field), and the others aren't Clerk components this app's own code can inspect. Instead, all of them are covered by a best-effort, path-agnostic post-auth username gate:evaluateUsernameGate(ionic_frontend/src/router/index.ts) reads the synced local user'susernamefrom the Apollo cache on every protected navigation and redirects to/profile/complete/aboutwhenever it's blank, regardless of which path the account came from.- Feature-flagged via
username_gate_enabled(default off) inionic_frontend/src/lib/featureFlags.ts— with the flag off, this layer provides zero coverage. - Fails open on an ordinary cache miss, not just on error.
if (!cached) return null(ionic_frontend/src/router/index.ts:144) skips the redirect whenever the Apollo cache has no entry for the user yet — and a cache miss is the normal state for a user who just signed up, not an edge case. This is distinct from (and broader than) the outertry/catch's fail-open on a genuine cache-read error. - Exempts
/profile/complete/*(its own destination),/join-team/*(an invite-arrival user resolves their invite first), public routes, and bypass-auth (fallback/magic-link) sessions. - Wired into the router's
beforeEachafter the invite-resume hop and before the onboarding (/welcome) gate.
- Feature-flagged via
ProfileCompletionModal is not a third layer. SideMenu.vue renders it (SideMenu.vue:215) only when the user opens it via the side-menu "Complete Your Profile" CTA (SideMenu.vue:43, hasIncompleteProfile) — a dismissible nudge the user can ignore indefinitely, not a route gate. It adds no coverage guarantee beyond the two layers above.
Backend username handling. ClerkUserSync (rails_api/app/interactions/user_identity/clerk_user_sync.rb:9, 19, 38) takes username as an optional input (default: nil) and only writes it when Clerk actually sent one — it does not generate or backfill a username itself, which is why the app-side gate above is what actually closes the gap, not anything in this interaction. Username validity is enforced by CheckValidUsername (rails_api/app/graphql/mutations/check_valid_username.rb): case-insensitive uniqueness (lower(username) comparison, line 20) and format (/\A[a-zA-Z0-9_]{3,30}\z/, line 8), wired into the gate's /profile/complete/about step via a debounced availability check.
Do not extend Clerk's instance-level "Username" requirement to force it on every strategy (e.g. oauth_token_apple) expecting it to be a simpler fix than the gate above — it will break native Apple sign-up. useClerkNative.signInWithApple() (ionic_frontend/src/composables/useClerkNative.ts) only checks for result.createdSessionId before declaring success; a missing_requirements result falls through to signInWithApple()'s throw. If Clerk enforced a username requirement on the oauth_token_apple strategy the way it does on the hosted <SignUp> form, createSignInWithAppleToken/createSignUpWithAppleToken would return a result with no session, and signInWithApple() throws — native Apple sign-up fails outright instead of degrading gracefully. This is exactly why the fix here is the app-side gate, not a Clerk dashboard change.
Update (OBJ-3033, App Store 4.4.1/116 review): the throw itself is no longer silently misread downstream. This doc originally warned only that forcing a username requirement onto oauth_token_apple would make signInWithApple()/signInWithGoogle() throw on missing_requirements — it didn't anticipate that the same throw was independently reachable today, with no Clerk config change at all, via first_name/last_name (both already required: true on production, and never forwarded from the Apple/Google native plugin response into createSignUpWithToken()). Apple's App Store review hit exactly this: a repeat-reviewer Apple ID that no longer supplies name on re-authorization triggered missing_requirements, and the throw — at the time a plain, untyped Error — was structurally indistinguishable from a dropped connection to useNativeOAuth's isTerminalClerkError() check, so it rendered "Can't reach the sign-in service." The throw now carries a structured ClerkSignUpIncompleteError.signUpStatus (useClerkNative.ts:46-48, thrown at :528-529 for Apple and :615-616 for Google), and useNativeOAuth's catch branches on that property with a fail-closed default arm instead of falling through to the connectivity message — mirroring the signInWithPassword/ClerkSignInIncompleteError.signInStatus pattern (OBJ-2532) referenced elsewhere in this doc. The underlying "no handling for missing_requirements" gap this section warns about is closed for any trigger, not just a hypothetical username-requirement change — see gotchas.md § An unenumerated third-party auth status falling through to the connectivity message for the full mechanism.
Separately — and with a different consequence — do not flip the requirement to "off" expecting it to solve an OAuth missing_requirements problem. Flipping to off does not touch native Apple sign-up at all; it silently removes username enforcement from the hosted web <SignUp> form, with no compensating layer for whoever signs up through it while it's off. This is not hypothetical: OBJ-2577 flipped username.required to off on production for exactly this reason, native Apple sign-up did not break (nothing was being extended, so the mechanism above never applied), and the OAuth problem wasn't fixed either — see the incident record above.
Clerk instance divergence (staging vs. production): the username attribute is confirmed enabled: true, required: true on both production and the prime-rooster-38 staging instance (GET /v1/environment, re-probed 2026-08-19) — no divergence on this setting. The previously reported Apple sign-in + passkey availability + device verification divergence is no longer observable: scripts/check-clerk-auth-config.mjs run against both instances' live GET /v1/environment confirms social.oauth_apple.enabled, attributes.passkey.enabled, and sign_in.second_factor.required all match (re-verified 2026-08-19, OBJ-2535/OBJ-2638). staging_accepted_divergences in scripts/clerk-auth-config.expected.json is currently empty — the three divergences originally recorded under OBJ-2638 all converged, so staging is now held to full parity with production on every compared path.
Clerk Appearance & Theming
The sign-in and sign-up pages integrate Clerk's <SignIn> and <SignUp> drop-in components, themed via the useClerkAppearance() composable (ionic_frontend/src/composables/useClerkAppearance.ts) plus a small set of CSS overrides in ionic_frontend/src/styles/clerk.css for markup the appearance API can't reach.
Appearance contract
Base theme — baseTheme is set to Clerk's imported dark theme (from @clerk/themes) when dark mode is active, and omitted (Clerk's light defaults) otherwise. This handles Clerk's own dark-mode rendering of inputs, labels, and card background before the variables/elements overrides below layer on top.
Variables (appearance.variables) — set explicitly in both light and dark mode, never left to Clerk's inference:
colorPrimary— brand blue (#177bbblight /#4eaeeadark)colorBackground,colorInputBackground,colorText,colorInputText— card and input surface/text colorscolorNeutral— pinned equal tocolorText(#0f172alight /#f1f5f9dark). See "ThecolorNeutraltrap" below.colorTextSecondary— muted text (#64748blight /#94a3b8dark)
Elements (appearance.elements) — targeted overrides where Clerk's defaults were wrong or where Objectuve's own chrome replaces Clerk's: alternativeMethodsBlockButton/...Text (the "use another method" rows), socialButtonsBlockButton/...Text/...Icon, formButtonPrimary, formFieldInput/formFieldLabel, card/cardBox/rootBox/footer (layout), and logoBox/logoImage/header (hidden — Objectuve renders its own brand hero instead of Clerk's stock logo/header).
CSS overrides (clerk.css) — for rendered elements the appearance API doesn't govern:
.cl-formFieldInputbox-shadow — Clerk's CSS-in-JS wins the cascade overelements.formFieldInput.boxShadow, so the input ring is owned here, at a single boundary-ink value (#64748b) that clears WCAG 1.4.11 (3:1) against both card surfaces..cl-formFieldInput::placeholderand.cl-footerItem(dark mode) — the identifier-input placeholder and the "Secured by Clerk" footer badge both render from a Clerk-internal color that novariables/elementskey reaches. The footer badge has no stable class of its own (only build-hashedcl-internal-*), so the rule targets its nearest stable ancestor, excluding the orange dev-mode notice that shares it..cl-header— fallback for theelements.headerhide, in caseheaderisn't a valid element key on the installed Clerk version.
The colorNeutral trap
colorNeutral reads like a border or surface color. It isn't — it's a foreground ink: Clerk derives its entire neutral alpha ramp (muted text, icons, borders) from it. Source of truth is the installed @clerk/themes package — dark.mjs sets colorNeutral: "white" (Clerk's light default is black). Get the polarity wrong — light ink on a light card, or dark ink on a dark card — and every element that relies on the ramp without its own explicit color override goes invisible against the card. That's exactly what happened in OBJ-1396: colorNeutral was inverted in both modes, and the "use another method" step (the one screen with no per-element override) rendered blank in both light and dark.
Don't drop the variable and let Clerk infer it either — Clerk then falls back to OS color-scheme preference, not the app's own theme setting. Under app-light + OS-dark that reproduces the identical bug in a configuration nobody manually tests. Keep colorNeutral pinned to an explicit, polarity-correct value in both modes, equal to colorText — useClerkAppearance.spec.ts asserts this invariant as a regression guard.
Rationale (updated)
This surface mixes structural overrides (hiding Clerk's own logo/header), variable overrides (colors, pinned per mode), and CSS overrides (for markup outside the appearance API) — more than the general "prefer the vendor's stock theme" guidance in Coding Style § Third-Party Widget Theming suggests. That guidance's premise — that Clerk's own defaults are correctness-tested for legibility — is what OBJ-1396 disproved for colorNeutral. The working rule here:
- Prefer Clerk's stock values where they're actually verified correct; don't hand-style elements speculatively.
- Where a Clerk default is wrong (as
colorNeutral's polarity was) or unreachable throughvariables/elements(as the input placeholder and footer badge are), override it explicitly, pin the value for every mode, and back it with a regression test — inference and per-mode guessing are how this shipped broken twice.
Migrating Existing Users
For production deployments with existing users from the old auth system (bcrypt + Auth0), use the rake migration tasks:
# Check how many users need migration
bundle exec rake clerk:migration_status
# Preview what would happen (no changes made)
DRY_RUN=true bundle exec rake clerk:migrate_users
# Run the migration
bundle exec rake clerk:migrate_usersWhat the migration does
For each user without a clerk_user_id:
- Password users: Creates a Clerk account with their bcrypt hash imported via
password_hasher: "bcrypt". Users can sign in with their existing password — no reset needed. - OAuth users: Creates a Clerk account by email. When the user signs in via Google through Clerk, it matches by email.
- Duplicate handling: If the email already exists in Clerk (e.g., from a manual sign-up), the task finds the existing Clerk user and links it to the local record.
- Username: Carried over if present.
Post-migration
After running the migration, verify with rake clerk:migration_status that all users show as "Already linked". Users can then sign in through Clerk with their existing credentials.
The migration task lives in rails_api/lib/tasks/clerk_migration.rake.
Fallback Authentication
A secret-based fallback auth mechanism is available for when Clerk is unavailable or for automated testing (Cypress smoke tests, bots).
How It Works
The backend accepts a SessionToken: Fallback <secret>:<email> header format that bypasses Clerk JWT verification and looks up the user directly by email.
- Disabled by default — only works when
FALLBACK_AUTH_SECRETenv var is set - Secure comparison — uses
ActiveSupport::SecurityUtils.secure_compareto prevent timing attacks - No password needed — the shared secret IS the credential
Frontend Fallback Form
Visit /sign-in?fallback to reveal a simple email + secret login form that bypasses Clerk entirely. The form stores a fallback token in localStorage, and Apollo sends it as the SessionToken header.
A failed attempt shows exactly two error strings, by design: fallback_auth (rails_api/app/interactions/user_identity/authenticate_session_token.rb) returns nil for both a wrong secret and an unknown email, so those two cases are indistinguishable to the client — surfacing them as separate messages would require the backend to reveal whether an email exists, which this dev-only surface deliberately doesn't do. The frontend form (ionic_frontend/src/views/SignIn.vue) collapses everything else into a network/other message instead of guessing at a third branch.
Cypress / Automated Testing
Cypress E2E tests use a hardcoded test-secret with a mocked GraphQL backend — they do not hit the real backend or use a CI-injected secret:
// ionic_frontend/tests/e2e/helpers/auth.ts
window.localStorage.setItem('objectuve_fallback_token', 'Fallback test-secret:cypress@test.com')Playwright smoke tests (deployed-env) use FALLBACK_AUTH_SECRET directly (no CYPRESS_ prefix), injected from GitHub Actions secrets.
Environment Setup
# Generate a secret
openssl rand -hex 32
# Local: add to rails_api/.env
FALLBACK_AUTH_SECRET=<generated-secret>
# Deployed: add to GCP Secret Manager
echo -n "<secret>" | gcloud secrets create fallback-auth-secret --data-file=-Security Notes
- In production, omit
FALLBACK_AUTH_SECRETto disable fallback auth entirely - For staging/preview, set it to enable smoke tests and demo access
- The secret should be different per environment
- Never commit the secret to the repository
Magic Code Sign-In
An email-based sign-in code, offered from the native card's "Sign in with email code" entry point and from a web fallback panel when Clerk hasn't loaded — independent of window.Clerk.
- Code shape: 6-character alphanumeric, not numeric-only (
MagicCode::CHARSET,rails_api/app/models/magic_code.rb) — the charset excludes visually ambiguous characters (0/O,1/I/L).CODE_LENGTH = 6. - Expiry & rate limit: a code expires after
MagicCode::EXPIRY(10 minutes); requesting a new one immediately invalidates the previous unused code (MagicCode.generate_for). The backend allows at mostMAX_REQUESTS_PER_WINDOW= 3 requests per 10-minute window per user (MagicCode.rate_limited?). RequestMagicCodealways returnssuccess: true, by design (rails_api/app/graphql/mutations/request_magic_code.rb) — whether the email exists, is rate-limited, or the send genuinely goes out, the client always sees the same success response. This is deliberate: the mutation must never let a client distinguish "no account with that email" from "code sent," or it leaks account existence.- Client-side resend cooldown is UX-only.
SignIn.vue's 60-second resend cooldown/countdown has no way to reflect real server rate-limit state, since the server never signals it — it exists purely to slow down accidental double-taps, not to enforce the backend's 3-per-10-minute limit.
Native (Capacitor) Auth Path
On Capacitor (Android + iOS), OAuth presents a structural challenge: @clerk/vue sets SignIn-state cookies in the WebView's cookie jar, but OAuth providers require Custom Tab (Android) or SFSafariViewController (iOS) per Google's secure-browsers policy. These separate browsers maintain isolated cookie jars, so the OAuth callback cannot see the state cookies, and authentication fails with authorization_invalid.
Objectuve v1.13 bypasses this limitation entirely by calling Clerk's Frontend API directly with bearer-token auth on native platforms, matching the pattern used by Clerk's official @clerk/expo SDK.
Sign in with Apple is the exception (v4.2.1): every other native OAuth strategy (oauth_google) still goes through the redirect flow below — Browser.open() into a Custom Tab / SFSafariViewController, deep-link callback, handshake exchange. Apple does not: useAuthStore.signInWithApple() → useClerkNative.signInWithApple() calls a local Swift plugin (AppleSignInPlugin.swift) that opens ASAuthorizationController, iOS's in-process system sheet, and hands back an Apple identity token with no browser and no redirect URL involved. Clerk verifies the token server-side via its oauth_token_apple strategy. This exists because the SFSafariViewController path stranded App Store reviewers on iPad — Clerk's sso-callback is a universal link reached via a server-side redirect, universal links don't fire on those, so the sheet never closed (App Store guideline 2.1(a), fixed in build 4.2.1). See Clerk Frontend API gotchas — Sign in with Apple bypasses the redirect flow entirely for the full detail.
No Android implementation of the Apple plugin. AppleSignInPlugin.swift only exists under ionic_frontend/ios/; there is no Android counterpart, and ionic_frontend/src/plugins/appleSignIn.ts says so directly ("iOS only ... No web or Android implementation — callers must guard on platform before invoking this"). Historical bug, fixed OBJ-2542: before that fix, useNativeOAuth.ts's enabledOAuthStrategies included oauth_apple for both platforms with no platform guard, so tapping "Continue with Apple" on Android called straight into AppleSignIn.authorize() and rejected at the Capacitor bridge (UNIMPLEMENTED — the plugin was never registered on Android, same failure mode docs/operations/mobile-builds.md describes for an unregistered in-target plugin) rather than falling back to anything. useNativeOAuth.ts now resolves enabledOAuthStrategies and nativeSignUpStrategies off Capacitor.getPlatform() through a platform-keyed STRATEGIES_BY_PLATFORM map (not isNativePlatform(), which is true for both iOS and Android and was the root cause of the missing guard) — Android excludes oauth_apple from both lists entirely; iOS is unchanged.
UI/write gotcha: window.Clerk never loads inside the Capacitor native WebView, so any auth-gated UI logic reading @clerk/vue's useAuth() or Session.active() directly will misrender on native even when the session below is valid, and any code calling window.Clerk directly to mutate the session (e.g. sign-out) will silently no-op on native instead of tearing it down — see Never call window.Clerk directly on native — gating UI or mutating a session for the correct pattern (useAuthStore().isSignedIn / useAuthStore().signOut()).
Native transfer sign-ups must self-collect legal consent — Clerk demands it, and no native flow delegates to Clerk's hosted UI. Clerk's dashboard has legal_consent_enabled: true (confirmed live against GET /v1/environment, OBJ-2602). On web this requirement is invisible: SignUp.vue's web branch renders Clerk's hosted <SignUp> component, and that component renders Clerk's own missing_requirements continue-form — consent checkbox included — itself. That web-side checkbox is real repo-facing surface, not just a Clerk implementation detail: clerk-sign-up.spec.ts initially didn't interact with it and failed deterministically until OBJ-2625 added the guard — see gotchas.md § Clerk-hosted <SignUp>/<SignIn> field requirements drift on the dashboard side with zero repo signal. Native has nothing to delegate to, and every native sign-up path — the Android transfer-promotion branch off /sso-callback, and iOS's in-process Apple/Google token exchanges — is our own UI calling Clerk's Frontend API directly. The consent collection itself is post-hoc, not pre-flight: the initial sign-up POST never carries legal_accepted; Clerk answers status: 'missing_requirements' naming legal_accepted as the outstanding field, and the app then completes the sign-up with a second, ID-scoped PATCH once consent is captured. See Native Architecture step 4 for the full mechanism across all three entry points.
Native Sign-In & Sign-Up Entry Surface
SignIn.vue and SignUp.vue each branch on Capacitor.isNativePlatform() (isNative). On native, neither view renders Clerk's <SignIn>/<SignUp> component at all — both render NativeOAuthButtons (OAuth), and SignIn.vue additionally renders a direct email/password form that calls Clerk's Frontend API via useAuthStore.signInWithPassword(), bypassing window.Clerk the same way the OAuth path above does. This split exists for the reason stated in the gotcha just above: window.Clerk never loads inside the Capacitor WebView, so Clerk's own components render nothing there — the root cause of the App Store 2.1 rejection (v4.0.0 build 46) this surface exists to avoid.
A bare /sign-up link on native is a dead screen — don't route to it assuming Clerk's hosted component will render. SignUp.vue's native branch (v-if="isNative") already replaces Clerk's <SignUp> entirely; that's the only reason a sign-up affordance is safe to add to the native sign-in card (SignIn.vue's footer link routes to /sign-up and relies on this branch, not on Clerk). Shipped in OBJ-2503 (PR #2326).
Native account-creation paths, as of OBJ-2542 — platform-dependent, not platform-agnostic:
| Path | iOS | Android |
|---|---|---|
| Sign in with Apple | Working, including legal consent (OBJ-2602). useClerkNative.signInWithApple() promotes a transferable/needs_identifier result to a new account via clerkFrontendApi.createSignUpWithAppleToken(), which posts to /v1/client/sign_ups with no legal_accepted. When Clerk answers missing_requirements naming legal_accepted, signInWithApple() sets pendingConsentSignUpId and the global AgreeToTermsModal (mounted in App.vue) takes over — same mechanism as the Android row below. See Native Architecture step 4. Not yet confirmed on-device; unit-tested and code-reviewed only. | Not offered. No Android implementation of the Apple plugin exists (see the gap noted above) — STRATEGIES_BY_PLATFORM excludes oauth_apple from Android's nativeSignUp/enabled lists entirely, rather than shipping a button that fails on tap. |
| Google OAuth | Not enabled for native account creation via a dedicated sign-up button, but the in-process sign-in exchange covers legal consent the same way Apple above does. nativeSignUp for iOS is Apple-only, so there's no dedicated Google sign-up button — but useNativeOAuth.ts's GOOGLE_IN_PROCESS = platform === 'ios' routes iOS's sign-in Google button through useClerkNative.signInWithGoogle() → the GoogleSignIn native plugin (GoogleSignIn-iOS SDK, GoogleSignInPlugin.swift) → clerkFrontendApi.createSignUpWithGoogleToken() for a brand-new identity; a missing_requirements response sets pendingConsentSignUpId exactly as the Apple row describes. This whole path additionally depends on a client-side iOS OAuth client ID (GOOGLE_IOS_CLIENT_ID, ionic_frontend/ios/google-signin.xcconfig), separate from Clerk's own Google-enabled setting — that credential was never wired into the build pipeline, so this path was 100% broken end-to-end from 2026-08-16 until OBJ-2772 landed the wiring and a CI gate. Not yet confirmed on-device. | nativeSignUp still excludes Google — Android's nativeSignUp list is Apple-only, same as iOS, so SignUp.vue's native branch offers no Google button. The sign-in card's Google button (enabledOAuthStrategies) is the only Google entry point on native Android, and as of OBJ-2577 it no longer dead-ends on a brand-new identity: a bare OAuth callback (OBJ-2540's original symptom — no rotating_token_nonce, no __clerk_handshake) is now detected as a transferable sign-in and promoted via clerkFrontendApi.createSignUpFromTransfer, native-verified live against production Clerk — the transfer POST fires and Clerk accepts it. As of OBJ-2602, the code path is complete: a missing_requirements response naming legal_accepted surfaces the same global AgreeToTermsModal, and pendingConsentSignUpId persists to secureStorage (PENDING_CONSENT_KEY) so the flow survives the app being killed mid-consent. Not yet confirmed against a real device and production Clerk — no crew runtime had Android device/emulator access, so the consent round-trip is unit-tested and code-reviewed but not on-device-verified; per Roy's review this gates the next production tag, not the master merge (staging only, per this repo's beta release cadence). See Native Architecture step 4 for the mechanism. |
| Email / password | Not built. clerkFrontendApi.ts exposes createSignIn* calls and createSignUpWithAppleToken only — there is no native email/password sign-up path (sign-in exists — SignIn.vue's native password form). Building one is a materially larger slice (new FAPI surface + email verification step); not scheduled as of this writing. | Not built (same as iOS). |
| No native path — web fallback | n/a — Apple covers this. | This is Android's only account-creation path today. With nativeSignUp empty, SignUp.vue's native branch renders a "Create your account" CTA instead of <NativeOAuthButtons> — tapping it opens app.objectuve.com/sign-up in a Chrome Custom Tab (Browser.open, not AppLauncher) and the user finishes on the web, then returns to sign in. Not a native fix — an honest exit instead of a button that always fails. Full states/copy: docs/ui-specs/obj-2539-android-native-signup.md. |
Both views share one implementation: useNativeOAuth() (ionic_frontend/src/composables/useNativeOAuth.ts) owns handleNativeOAuth, enabledOAuthStrategies (sign-in), and nativeSignUpStrategies (sign-up) — handleNativeOAuth exists in exactly one place in the codebase, consumed by both SignIn.vue and SignUp.vue.
Native Password Sign-In Status Contract
useClerkNative.signInWithPassword() (ionic_frontend/src/composables/useClerkNative.ts:136-182) completes exactly one Clerk sign-in status by construction, not by enumeration: needs_first_factor is retried once via attemptFirstFactorPassword, and only a subsequent status === 'complete' with a createdSessionId sets signedIn and persists the session (useClerkNative.ts:178-180). Every other status — needs_second_factor, needs_client_trust, or any status Clerk introduces later — throws a structured ClerkSignInIncompleteError carrying signInStatus (useClerkNative.ts:163-176) instead of falling through to a default success/failure guess.
SignIn.vue's passwordLogin() catch block (SignIn.vue:898-936) branches on that error in a fixed order — three stances, fail-closed twice and fail-open once:
| # | Stance | Trigger | User-facing copy | Why |
|---|---|---|---|---|
| 1 | Fail closed (needs verification) | ClerkSignInIncompleteError (any signInStatus) — routed through copyForSignInStatus() (SignIn.vue:856-859), a lookup with an explicit default arm | "Your password's fine — this sign-in needs a verification step we can't finish in the app yet." + a "Sign in with an email code instead" recovery button (recoverWithEmailCode(), SignIn.vue:871-882, carrying across the email already typed) | No status currently has copy distinct from the default, so every status — enumerated or not — renders the same honest message instead of a guess. |
| 2 | Fail closed (rate limited) | isTerminalClerkError(e) && e.status === 429 | "Too many attempts — wait a moment, then try again." | Clerk itself returned 429 — it evaluated the request and is telling the app to back off, not a case of Clerk being unreachable. |
| 3 | Fail closed (rejected) | isTerminalClerkError(e) (any other 4xx) | "That email or password didn't work. Double-check and try again." | Any other 400–499 means Clerk evaluated the credentials and rejected them — safe to tell the user their password was wrong. |
| 4 | Fail open | Anything else — a 5xx (including one that already exhausted clerkFetch's MAX_RETRIES) or a network-level failure (offline, DNS, timeout; clerkFetch's outer try/catch rethrows these unmodified, with no status attached) | "Can't reach the sign-in service. Check your connection and try again." | Neither case says anything about whether the credentials were correct, so the app defaults to the connectivity message instead of accusing a possibly-correct password. |
isTerminalClerkError and the status/retryExhausted properties it reads live on ClerkApiError (clerkFrontendApi.ts:119-133), populated once at the single clerkFetch throw site. It's the same discriminator useClerkNative.init() uses to decide whether to clear a stored session on a 4xx vs. stay signed in through a 5xx.
The contract this establishes: the connectivity message is reserved for cases where Clerk's evaluation of the credentials is actually unknown — a network-level failure, or a 5xx (even one that already exhausted retries). This is no longer read off the shape of the error message (a Clerk API-prefixed string, or a → 429 suffix matched by regex); it's read off the HTTP status Clerk actually returned, carried as a structured ClerkApiError.status property instead (isTerminalClerkError(), OBJ-2690). That change fixed a real bug in the old string-matching mechanism: a 5xx response is an HTTP response Clerk returned, not a connection-level failure, but the old branch order treated any Clerk API-prefixed response — including a transient 5xx — as "password didn't work," misrouting it into step 3 instead of the connectivity message. Separately, the default arm in step 1 still means a third-party auth status the app has never seen before — Clerk adding a new challenge type, or a config change on Clerk's side — can never be misread as "check your connection," because it's caught before the branch chain reaches step 4. This closes the gap that let needs_client_trust (device verification) render the connectivity message on a working password, account, and network (OBJ-2532). See gotchas.md § An unenumerated third-party auth status falling through to the connectivity message.
needs_second_factor and needs_client_trust are not two independent statuses to separately design for: per Clerk's own docs, needs_second_factor is the legacy status name for the same Device Trust challenge needs_client_trust names in newer custom flows — which name a given call sees depends on the requesting app's Clerk dashboard config, not on whether the account actually has a second factor enrolled (confirmed against a live staging probe returning both names for the same challenge, OBJ-2532 Task 4 investigation). Both collapse into the same default-arm copy above; there was never a need to special-case either one by name.
Native Architecture
The native auth system encodes state in the bearer token itself, making cookie-jar isolation irrelevant:
Client token acquisition (
secureStorage.getClientToken)- On first OAuth:
POST /v1/client?_clerk_js_version=5→ returns client token inAuthorizationresponse header - Token is persisted to encrypted secure storage (
@aparajita/capacitor-secure-storage) - Token rotates on demand if the API returns a new one; lifecycle is managed by Clerk
- On first OAuth:
OAuth initiation (
useClerkNative.signInWithOAuth)- Call
POST /v1/client/sign_inswith bearer token and strategy (e.g.oauth_google) - API returns
external_verification_redirect_urlpointing to the OAuth provider Browser.open()launches the provider in a Custom Tab (Android) or SFSafariViewController (iOS)
- Call
Deep-link return (
appUrlOpenhandler →SsoCallback.vue)- OAuth provider redirects to deep link:
https://app.objectuve.com/sso-callback?rotating_token_nonce=...(native path) or?__clerk_handshake=...(web/cookie path) - Frontend parses whichever parameter is present from the URL
- OAuth provider redirects to deep link:
Session exchange (
useClerkNative.completeOAuthRedirect) — prefersrotating_token_nonce, falls back to handshake- If
rotating_token_nonceis present: callPOST /v1/client/sign_ins/{id}?rotating_token_nonce=...with bearer token and the pending sign-in id (clerkFrontendApi.reloadSignIn) — this is the nonce the native (Capacitor) OAuth path actually returns - Else fall back to
__clerk_handshake: callPOST /v1/client/handshakewith bearer token and the handshake parameter (clerkFrontendApi.completeHandshake) — kept so the handler is robust to whichever Clerk emits (OBJ-249) - Bare callback, neither param present (OBJ-2577): Clerk staged a sign-up instead of completing the sign-in — reached on native Android when a Google identity has no existing account.
completeOAuthRedirectreloads the client (GET /v1/client,clerkFrontendApi.getClient) and checksclient.sign_in.first_factor_verification.status === 'transferable'(clerkFrontendApi.isSignInTransferable); on a hit, it promotes the identity withPOST /v1/client/sign_ups {transfer: 'true'}(clerkFrontendApi.createSignUpFromTransfer,clerkFrontendApi.ts:703-708) — nolegal_acceptedon this call. Fail-closed by design:getClientthrowing, an ambiguous/non-transferable state, or a sign-up response that isn'tstatus: 'complete'with a session ID all fall through to the pre-existing throw and resolve{ redirectTo: '/sign-in' }— no path creates an account speculatively. - Legal consent capture — post-hoc, not pre-flight (OBJ-2602): with
legal_consent_enabledon, the transfer POST above answersstatus: 'missing_requirements'naminglegal_acceptedas outstanding instead of completing outright. All three native sign-up entry points share this shape and the same recovery:completeOAuthRedirect(Android transfer, this step),signInWithApple(), andsignInWithGoogle()(both iOS in-process) each callisLegalConsentPending()on the result; on a hit they set module-scopependingConsentSignUpIdand persist it tosecureStorageunderPENDING_CONSENT_KEY(useClerkNative.ts), then return without completing the sign-in —completeOAuthRedirectresolves{ redirectTo: null }, telling the caller not to navigate. A singleAgreeToTermsModalmounted globally inApp.vue(not view-local — it has to reach iOS's in-process paths, which never render/sso-callback) reacts topendingConsentSignUpId, gated onready && (!isSignedIn || pendingConsentOrigin === 'in-session')(App.vue's:openbinding) — see the OBJ-2660 paragraph below for the second clause. Accepting callsuseClerkNative.completeSignUpConsent(), whichPATCHes/v1/client/sign_ups/{id}withlegal_accepted: 'true'(clerkFrontendApi.completeSignUpWithLegalConsent,clerkFrontendApi.ts:723-731) — Clerk's documented ID-scoped completion endpoint, not a secondPOST /v1/client/sign_ups. Declining callsdeclineSignUpConsent(), which clears the pending state and arms a decline noticeNativeOAuthButtonsrenders on the next sign-in attempt. Persisted, not in-memory: becausependingConsentSignUpIdsurvives tosecureStorage, an app killed mid-consent (backgrounded during the Custom Tab round-trip, or force-quit) re-presents the modal on relaunch instead of stranding a verified-but-incomplete sign-up —init()rehydrates it fromPENDING_CONSENT_KEY. Not yet confirmed on-device against production Clerk — unit-tested and code-reviewed only (see the entry-surface table above). - Second-account sign-up while already signed in (OBJ-2660): the router's auth guard only blocks unauthenticated access (
router/index.ts:768,if (to.meta.public) next()), so an already-signed-in user can reach/sign-upand legitimately start a native sign-up for a different account — which reaches the identicalpendingConsentSignUpId && signedInstate the paragraph above gates on.pendingConsentOrigin('in-session' | 'rehydrated' | null) distinguishes the two:'in-session'is set at the same three write sites the moment they callisLegalConsentPending(),'rehydrated'is set only wheninit()restores the pending id fromsecureStorage, andApp.vue's:opengate reopens the modal specifically for'in-session'. Deliberately not persisted — see Clerk Frontend API gotchas § The legal-consent suppression watcher only fires on a rehydrated pending id for why persisting it would reintroduce the OBJ-2602 defect one field over. This replaces the live session, not adds to it: there is no multi-session support in the native store —completeSignUpConsent()overwrites the module-scopesessionIdand thesecureStoragesession id, so completing the second sign-up signs the user out of the first account and orphans that session server-side. No in-app copy warns of this today (deferred, Orion's OBJ-2660 package Q1) — the flow completes correctly, it just silently swaps which account is signed in. Narrows one guarantee: an app killed mid-second-account sign-up rehydrates on relaunch as'rehydrated'origin, which the fail-closed rule (correctly) treats as anomalous and sweeps — the modal does not re-present in that specific case. See UI-SPEC g21 caveat. - Either path returns a new rotating client token and session ID
- Session ID and client token are persisted; JWT is cached with 10-minute TTL
- If
Token caching & refresh (
useClerkNative.getSessionToken)- Apollo middleware calls
getSessionToken()on each GraphQL request - If cached JWT is valid (>60s remaining), return it immediately
- If expired, call
POST /v1/client/sessions/{sid}/tokenswith bearer token to refresh - In-flight promises are deduplicated to prevent race conditions on Apollo stampedes
Email/password login fallback — Email/password login via the embedded
<SignIn>component setswindow.Clerk.sessionbut never writesclientTokenorsessionIdto secureStorage.useClerkNative's own_doRefresh()just returnsnullin that case (!clientToken || !sessionId); the fallback towindow.Clerk?.session?.getToken() ?? nulllives one layer up, inuseAuthStore.resolveSessionToken(), which calls the nativegetSessionToken()first and only readswindow.Clerkdirectly if that comes back empty. The OAuth path is unaffected — OAuth login populates both secureStorage values viacompleteOAuthRedirect()before any native refresh call, so the fallback is never needed there.- Apollo middleware calls
Sign-out (
useAuthStore.signOut)- Native: clear
secureStorage(client token, session ID, cached JWT); setsignedIn = false - Web: call
window.Clerk.signOut(); catch and Sentry-tag errors on the web path
- Native: clear
Key Implementation Details
Storage layer (secureStorage.ts)
- Uses
@aparajita/capacitor-secure-storagefor encrypted persistence - Keys are prefixed with
objectuve_clerk_on both Android (Keystore) and iOS (Keychain) - Provides simple get/set/clear API for
client_tokenandsession_id, plus a generic get/set/remove used byuseClerkNative.tsforPENDING_CONSENT_KEY(the persisted pending-legal-consent sign-up id, OBJ-2602)
Frontend API client (clerkFrontendApi.ts)
- Thin HTTP wrapper around
clerk.objectuve.com/v1/*endpoints - Injects Clerk publishable key and bearer token as headers
- Pins to Frontend API version 5 (Spike Delta #5, matches
@clerk/vue@^2.0.20) - Handles token rotation: extracts new
Authorizationheader from all responses - Retry logic: max 2 retries on 5xx, no retry on 4xx or network failures
- Sentry breadcrumbs on all API calls, errors tagged with
clerk.frontend_api.endpoint
Auth facade (useAuthStore.ts)
- Unified API for both native and web auth paths
- Routes to
useClerkNativeon Capacitor,@clerk/vueon web - On native:
isNative = Capacitor.isNativePlatform() - Web path touches zero code (pure delegation)
Stale session detection (useAuthStore.detectAndClearStale)
- Detects corruption: client token without session ID, or session ID without client token
- Clears storage and routes user to
/sign-into prevent infinite spinner
Implementation Details
The implementation was validated through Phase 59 spike testing against production Clerk:
- Client token in response header: Arrives in
Authorizationresponse header, not request body. Extracted byclerkFrontendApi.tson all responses - Bearer-token authentication: All Frontend API calls use
Authorization: Bearer <clientToken>header (the rotating client token) - Handshake exchange: Uses
POST /v1/client/handshakewith handshake parameter from OAuth callback URL - Rotating client token: Returned in
Authorizationresponse header on successful handshake. Raw JWT format (noBearerprefix) - Token refresh: Subsequent token refreshes use
POST /v1/client/sessions/{sid}/tokenswith bearer token - Frontend API version pinning: Pinned to v5 (matches
@clerk/vue@^2.0.20+@clerk/shared@^4.13.0)
Differences from Web Auth
| Aspect | Web | Native |
|---|---|---|
| OAuth flow | @clerk/vue opens OAuth in a modal (same origin, shared cookies) | Custom Tab / SFSafariViewController (separate cookie jar) |
| State mechanism | Cookies (Clerk's session) | Bearer token (Clerk's client token) |
| Sign-out | window.Clerk.signOut() | Clear encrypted storage |
| Token source | Clerk session JWT from Clerk.session.getToken() | Pre-minted JWT from Frontend API nonce exchange |
| Token refresh | Automatic (Clerk SDK handles it) | Manual via POST /v1/client/sessions/{sid}/tokens |
| Multi-tab sync | Broadcast Channel (FEUX-10) | N/A (single app instance per device) |
Files Modified
ionic_frontend/src/composables/useClerkNative.ts— Frontend API bearer-token wrapperionic_frontend/src/composables/useAuthStore.ts— Auth facade; delegates to native or webionic_frontend/src/services/clerkFrontendApi.ts— HTTP client forclerk.objectuve.com/v1/*ionic_frontend/src/services/secureStorage.ts— Encrypted persistent storage wrapperionic_frontend/src/apollo-client.ts— Auth middleware (single line change)ionic_frontend/src/views/SsoCallback.vue— OAuth redirect handlerionic_frontend/src/views/SignIn.vue— Conditionally renders native OAuth buttons or@clerk/vueionic_frontend/src/router/index.ts— Auth guard integrationionic_frontend/src/App.vue— GlobalAgreeToTermsModalmount and consent accept/decline handlers (OBJ-2602)ionic_frontend/src/components/AgreeToTermsModal.vue— The legal-consent modal itself (open/submitting/errorprops,accept/declineemits)
Check-in Token Authentication (Background Widget Delivery)
Runtime behavior: Background check-in delivery. Build setup: Mobile Builds → Background check-in delivery build setup. Shipped across v4.22 Phases 2–5 (OBJ-1862/OBJ-1863/OBJ-1864/OBJ-1865), parent OBJ-1860.
A third SessionToken sub-scheme, alongside the Clerk JWT and Fallback Authentication above. It exists because native background delivery (a widget check-in reaching the server with the app fully closed) can't rely on the user's normal Clerk session — a background process shouldn't hold a full-account credential, and a Clerk JWT's short TTL isn't a fit for opportunistic, infrequent background wake-ups. CheckinToken is a narrowly-scoped, device-bound, revocable credential minted for exactly this purpose.
Header format
SessionToken: CheckinToken <deviceId>:<token>GraphqlController#current_user (rails_api/app/controllers/graphql_controller.rb) dispatches on the SessionToken header's prefix — Fallback , MagicLink , CheckinToken , else it falls through to Clerk JWT verification. The CheckinToken branch (checkin_token_auth) splits the payload on the first : into device_id and the raw token, then calls CheckinToken.resolve(raw_token, device_id:).
Data model
rails_api/app/models/checkin_token.rb, table checkin_tokens (db/migrate/20260728204857_create_checkin_tokens.rb):
| Column | Notes |
|---|---|
user_id | owning user |
device_id | client-generated, see Device binding below |
token_digest | SHA-256 of the raw token — the raw value is never persisted |
token_scope | currently one valid value, check_in_write (CheckinToken::SCOPES) |
expires_at | set at creation, 90.days from mint (CheckinToken::EXPIRY) |
rotated_at / revoked_at | nil until rotation/revocation |
last_used_at | touched on every successful auth resolution |
CheckinToken.resolve(raw_token, device_id:) only matches an active row (revoked_at: nil AND expires_at > Time.current) whose token_digest and device_id both match, and re-checks token_scope == 'check_in_write' on read. A resolve miss (wrong/expired/revoked token, or a token replayed against the wrong device_id) returns current_user as nil; require_auth! then raises GraphQL::ExecutionError with extensions: { code: 'UNAUTHORIZED' } — the exact shape both native clients parse as "token rejected" (see the widget doc's response handling).
Lifecycle
Mint — Mutations::MintCheckInToken (rails_api/app/graphql/mutations/mint_check_in_token.rb) requires a normal authenticated (Clerk) session, takes deviceId, and calls the UserIdentity::MintCheckinToken interaction, which is gated behind the PostHog flag widget_background_sync_enabled — when off (or PostHog is unreachable), it returns token: nil rather than an error, which is the server-side half of flag-off byte-identical. When on, CheckinToken.create!(user:, device_id:) generates the raw token (SecureRandom.urlsafe_base64(32)), stores only its digest, and sets a fresh 90-day expires_at. Triggered client-side by useCheckinToken.ts#ensureCheckinToken(), called (fire-and-forget) from router/index.ts right after syncUser succeeds — i.e. on sign-in / app open with a fresh session. If a token is already cached locally, it's re-pushed to native storage rather than re-minted (see the device-id backfill note below).
Rotate-on-use — every successful checkInHabit mutation that was itself authenticated by a CheckinToken (context[:checkin_token] present — background deliveries only; a normal foreground app check-in over a Clerk session never rotates anything) calls UserIdentity::RotateCheckinToken from Mutations::CheckInHabit#rotated_checkin_token. That interaction atomically revokes the current token row and mints a fresh one (with its own new 90-day expiry) in one transaction, using a conditional UPDATE ... WHERE revoked_at IS NULL to guard against a concurrent double-rotation race — the loser rolls back and the mutation returns a nil rotated token, which both native clients treat as "a concurrent request already rotated it, keep going, don't clear anything." The new raw token rides back in the checkInHabit response's checkinToken field; both platforms write it to local secure storage and use it for the rest of any in-progress delivery drain.
Revoke — Mutations::RevokeCheckInToken requires a normal Clerk session, takes deviceId, and calls UserIdentity::RevokeCheckinToken, which bulk-revokes (revoked_at) every active token for that user_id+device_id pair. Triggered from revokeCheckinToken() (useCheckinToken.ts), called from session.js#logout() before Clerk.signOut() — it has to run while the session is still authenticated, since revoke is itself a session-authenticated mutation. Its finally block unconditionally clears the local native copy (Keychain on iOS, EncryptedSharedPreferences on Android) regardless of whether the server call succeeds, closing an offline-sign-out gap where a network failure during sign-out could otherwise leave a live, unrevoked token sitting in native storage. Currently, sign-out is the only wired revoke trigger — there is no separate "remove this device" flow in the codebase yet, despite that being part of the credential's original design intent; treat device-level revocation as not-yet-built.
A revoked or expired token isn't pushed to the client proactively — revocation is discovered lazily, the next time a background delivery attempt gets the UNAUTHORIZED response and hard-clears its local copy (see the widget doc). The client-side logout() path above is the one case where the local copy is cleared immediately, at sign-out time, rather than lazily on next use.
Device binding
device_id is a client-generated crypto.randomUUID(), created once per device by useCheckinToken.ts#getOrCreateDeviceId() and persisted in secureStorage. It's stored on every CheckinToken row and required (alongside the raw-token digest) for CheckinToken.resolve to match — a token can't authenticate under a different device id than the one it was minted for.
Device-id backfill (PR #1859 review finding): the native bridge push (WidgetBridge.setCheckinToken) gained a required deviceId parameter when Android's WorkManager delivery shipped (Phase 4). ensureCheckinToken() always re-runs the native push — even on its already-cached-token branch — specifically so a token minted under the pre-Phase-4 code path (cached locally without ever having pushed a device id to native storage) gets backfilled rather than sitting silently unusable until its 90-day expiry, since there's no proactive expiry-driven remint.
Scope
CheckinToken::SCOPES currently defines a single value, check_in_write — the model can't presently mint any other scope. This keeps the credential's intended purpose narrow at the point of issuance; it is a data-model constraint, not a per-GraphQL-operation authorization check.
Last updated: 2026-09-09 (OBJ-3555: documented staging/dev's self-healing demo-account seed path in db/seeds/development/users.rb and its DEMO_CLERK_USER_ID_DEV override, alongside production's existing DEMO_CLERK_USER_ID path)