Skip to content

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

  1. User visits /sign-in or /sign-upweb renders Clerk's <SignIn> / <SignUp> components; native (Capacitor) renders neither — see Native Sign-In & Sign-Up Entry Surface below
  2. User authenticates (email/password, Google OAuth, etc.)
  3. Clerk redirects to /dashboard
  4. Router guard waits for Clerk to load, obtains a session token
  5. Router calls syncUser GraphQL mutation to create/find the local DB user
  6. Session.setPublicId() stores the user's public_id in localStorage
  7. Reactive refs (useClerkSync) update → all component queries fire

Backend Flow

  1. Every GraphQL request includes a SessionToken header with the Clerk JWT
  2. GraphqlController#current_user calls ClerkJwtVerifier.verify(token)
  3. The verifier decodes the RS256 JWT using Clerk's JWKS endpoint (cached)
  4. The sub claim contains the Clerk user ID → User.find_by(clerk_user_id:)
  5. If no local user exists, fetch_clerk_user calls Clerk's API and ClerkUserSync creates one
  6. context[:current_user] is set for GraphQL resolvers

Key Files

FilePurpose
ionic_frontend/src/views/SignIn.vueWeb: 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.vueWeb: 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.tsShared native OAuth logic (handleNativeOAuth, enabled strategies) — consumed by both SignIn.vue and SignUp.vue
ionic_frontend/src/router/index.tsAuth guard, Clerk token wait, syncUser call
ionic_frontend/src/composables/useClerkSync.tsShared reactive refs for synced user state
ionic_frontend/src/apollo-client.tsApollo auth middleware (injects Clerk token)
ionic_frontend/src/helpers/session.jslocalStorage utilities + decoded() compat shim
rails_api/app/services/clerk_jwt_verifier.rbRS256 JWT verification via JWKS
rails_api/app/interactions/user_identity/clerk_user_sync.rbFind-or-create local user from Clerk profile
rails_api/app/graphql/mutations/sync_user.rbReturns user + firstSignIn for onboarding
rails_api/app/controllers/graphql_controller.rbExtracts current_user from Clerk JWT

Demo Account

A demo account is available for testing and previews:

  • Email: demo@objectuve.com

  • Password: Objectuve-Demo-2026!

  • Clerk user ID: differs per Clerk instance — IDs are not portable between them:

    Clerk instanceID
    Development / staging (prime-rooster-38, ins_3BB6SHq3xvAVWFg2CT6HCd8zmNZ)user_3BDXP3qf0JbHXzxJS4fHNRxUw5S
    Production (ins_3BFNbcIZ5fIRl9gR9yu8dBnAL9M)user_3BFX1kMjFVMmED4xfEfFquPZtxK

    Two independent seed paths provision this account, one per environment tier:

    • Productiondb/seeds/production.rb creates the account (skipping with a warning, not raising, if unset) and reads the ID from DEMO_CLERK_USER_ID (set in deploy/service.production.yaml).
    • Development / stagingdb/seeds/development/users.rb (loaded by both db/seeds/development.rb and db/seeds/staging.rb, and re-run on every rake 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 from DEMO_CLERK_USER_ID_DEV, falling back to the prime-rooster-38 literal 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.

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:

text
/sign-in?demo

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

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

shell
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_PROCESS path below) additionally depends on a client-side credential — a Google Cloud Console iOS OAuth client ID, wired through ionic_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 *_DEFAULT with an unwired *_OVERRIDE ships 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 native aud trust list Clerk validates the Google sign-in token against. The one Google-adjacent field /v1/environment does 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 in scripts/clerk-auth-config.expected.json would 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.
  • 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_enabled is true on 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:40Z2026-08-19T00:35:27Z (~3.4 days). The closing bound is RESTORE-1: username.required last observed false at 00:34:33Z, first observed true at 00: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:36Z00:34:33Z (RESTORE-6).

  • Blast radius: exactly 1 production account (RESTORE-5). count=1 total=1 — checked against User.unscoped for soft-deletes too (0; User uses acts_as_paranoid, so a plain .count alone would silently omit soft-deleted rows). total and count are 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 Rails User rows, not Clerk identities — a Clerk identity created but never synced through ClerkUserSync would not appear here.

  • User impact: zero. The one affected account needs no correction — no backfill, no manual fix. username.required is enforced by Clerk on the Backend API's createUser/updateUser calls, 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=complete on 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 = true and 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=/dashboard by 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 (evaluateUsernameGate redirects a username-less session) had closed on unit tests alone, with no live-session observation.
  • password.required stays false on 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-adds password to the OAuth missing_requirements set, 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 read password.required: false too, same as production, both before and after this incident.

  • A separate, unrelated Clerk attribute — password.used_for_first_factor — is also false on both instances, and it's the reason a stuck /factor-one screen with no password field can look exactly like a lockout when it isn't. Clerk's hosted <SignIn> auto-selects email_link as 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 checking supported_first_factors / GET /v1/environment directly is still the right immediate diagnostic for a stuck /factor-one screen right now. See gotchas.md § A stuck /factor-one with no password field isn't necessarily a lockout for the full detail and recovery path. (OBJ-2729, OBJ-2769)

  • scripts/check-clerk-auth-config.mjs compares live production against that snapshot, and live production against staging (modulo the snapshot's staging_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, and scripts/check-clerk-auth-config.test.mjs pins 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 permanent staging_accepted_divergences above. A divergence matching an open, unexpired window reports as informational rather than an unaccepted failure; once expected_close_by passes, 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_divergences is 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.yml runs it daily (0 13 * * * UTC ≈ 08:00 America/Chicago) against both instances' live GET /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:

  1. 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.
  2. 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's createSignInWithAppleToken/createSignUpWithAppleToken calls 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's username from the Apollo cache on every protected navigation and redirects to /profile/complete/about whenever it's blank, regardless of which path the account came from.
    • Feature-flagged via username_gate_enabled (default off) in ionic_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 outer try/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 beforeEach after the invite-resume hop and before the onboarding (/welcome) gate.

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 themebaseTheme 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 (#177bbb light / #4eaeea dark)
  • colorBackground, colorInputBackground, colorText, colorInputText — card and input surface/text colors
  • colorNeutral — pinned equal to colorText (#0f172a light / #f1f5f9 dark). See "The colorNeutral trap" below.
  • colorTextSecondary — muted text (#64748b light / #94a3b8 dark)

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-formFieldInput box-shadow — Clerk's CSS-in-JS wins the cascade over elements.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::placeholder and .cl-footerItem (dark mode) — the identifier-input placeholder and the "Secured by Clerk" footer badge both render from a Clerk-internal color that no variables/elements key reaches. The footer badge has no stable class of its own (only build-hashed cl-internal-*), so the rule targets its nearest stable ancestor, excluding the orange dev-mode notice that shares it.
  • .cl-header — fallback for the elements.header hide, in case header isn'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 colorTextuseClerkAppearance.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 through variables/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:

bash
# 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_users

What 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_SECRET env var is set
  • Secure comparison — uses ActiveSupport::SecurityUtils.secure_compare to 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:

typescript
// 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

shell
# 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_SECRET to 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 most MAX_REQUESTS_PER_WINDOW = 3 requests per 10-minute window per user (MagicCode.rate_limited?).
  • RequestMagicCode always returns success: 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:

PathiOSAndroid
Sign in with AppleWorking, 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 OAuthNot 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 / passwordNot 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 fallbackn/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:

#StanceTriggerUser-facing copyWhy
1Fail 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.
2Fail 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.
3Fail 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.
4Fail openAnything 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:

  1. Client token acquisition (secureStorage.getClientToken)

    • On first OAuth: POST /v1/client?_clerk_js_version=5 → returns client token in Authorization response 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
  2. OAuth initiation (useClerkNative.signInWithOAuth)

    • Call POST /v1/client/sign_ins with bearer token and strategy (e.g. oauth_google)
    • API returns external_verification_redirect_url pointing to the OAuth provider
    • Browser.open() launches the provider in a Custom Tab (Android) or SFSafariViewController (iOS)
  3. Deep-link return (appUrlOpen handler → 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
  4. Session exchange (useClerkNative.completeOAuthRedirect) — prefers rotating_token_nonce, falls back to handshake

    • If rotating_token_nonce is present: call POST /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: call POST /v1/client/handshake with 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. completeOAuthRedirect reloads the client (GET /v1/client, clerkFrontendApi.getClient) and checks client.sign_in.first_factor_verification.status === 'transferable' (clerkFrontendApi.isSignInTransferable); on a hit, it promotes the identity with POST /v1/client/sign_ups {transfer: 'true'} (clerkFrontendApi.createSignUpFromTransfer, clerkFrontendApi.ts:703-708) — no legal_accepted on this call. Fail-closed by design: getClient throwing, an ambiguous/non-transferable state, or a sign-up response that isn't status: '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_enabled on, the transfer POST above answers status: 'missing_requirements' naming legal_accepted as 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(), and signInWithGoogle() (both iOS in-process) each call isLegalConsentPending() on the result; on a hit they set module-scope pendingConsentSignUpId and persist it to secureStorage under PENDING_CONSENT_KEY (useClerkNative.ts), then return without completing the sign-in — completeOAuthRedirect resolves { redirectTo: null }, telling the caller not to navigate. A single AgreeToTermsModal mounted globally in App.vue (not view-local — it has to reach iOS's in-process paths, which never render /sso-callback) reacts to pendingConsentSignUpId, gated on ready && (!isSignedIn || pendingConsentOrigin === 'in-session') (App.vue's :open binding) — see the OBJ-2660 paragraph below for the second clause. Accepting calls useClerkNative.completeSignUpConsent(), which PATCHes /v1/client/sign_ups/{id} with legal_accepted: 'true' (clerkFrontendApi.completeSignUpWithLegalConsent, clerkFrontendApi.ts:723-731) — Clerk's documented ID-scoped completion endpoint, not a second POST /v1/client/sign_ups. Declining calls declineSignUpConsent(), which clears the pending state and arms a decline notice NativeOAuthButtons renders on the next sign-in attempt. Persisted, not in-memory: because pendingConsentSignUpId survives to secureStorage, 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 from PENDING_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-up and legitimately start a native sign-up for a different account — which reaches the identical pendingConsentSignUpId && signedIn state 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 call isLegalConsentPending(), 'rehydrated' is set only when init() restores the pending id from secureStorage, and App.vue's :open gate 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-scope sessionId and the secureStorage session 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
  5. 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}/tokens with 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 sets window.Clerk.session but never writes clientToken or sessionId to secureStorage. useClerkNative's own _doRefresh() just returns null in that case (!clientToken || !sessionId); the fallback to window.Clerk?.session?.getToken() ?? null lives one layer up, in useAuthStore.resolveSessionToken(), which calls the native getSessionToken() first and only reads window.Clerk directly if that comes back empty. The OAuth path is unaffected — OAuth login populates both secureStorage values via completeOAuthRedirect() before any native refresh call, so the fallback is never needed there.

  6. Sign-out (useAuthStore.signOut)

    • Native: clear secureStorage (client token, session ID, cached JWT); set signedIn = false
    • Web: call window.Clerk.signOut(); catch and Sentry-tag errors on the web path

Key Implementation Details

Storage layer (secureStorage.ts)

  • Uses @aparajita/capacitor-secure-storage for encrypted persistence
  • Keys are prefixed with objectuve_clerk_ on both Android (Keystore) and iOS (Keychain)
  • Provides simple get/set/clear API for client_token and session_id, plus a generic get/set/remove used by useClerkNative.ts for PENDING_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 Authorization header 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 useClerkNative on Capacitor, @clerk/vue on 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-in to prevent infinite spinner

Implementation Details

The implementation was validated through Phase 59 spike testing against production Clerk:

  • Client token in response header: Arrives in Authorization response header, not request body. Extracted by clerkFrontendApi.ts on all responses
  • Bearer-token authentication: All Frontend API calls use Authorization: Bearer <clientToken> header (the rotating client token)
  • Handshake exchange: Uses POST /v1/client/handshake with handshake parameter from OAuth callback URL
  • Rotating client token: Returned in Authorization response header on successful handshake. Raw JWT format (no Bearer prefix)
  • Token refresh: Subsequent token refreshes use POST /v1/client/sessions/{sid}/tokens with bearer token
  • Frontend API version pinning: Pinned to v5 (matches @clerk/vue@^2.0.20 + @clerk/shared@^4.13.0)

Differences from Web Auth

AspectWebNative
OAuth flow@clerk/vue opens OAuth in a modal (same origin, shared cookies)Custom Tab / SFSafariViewController (separate cookie jar)
State mechanismCookies (Clerk's session)Bearer token (Clerk's client token)
Sign-outwindow.Clerk.signOut()Clear encrypted storage
Token sourceClerk session JWT from Clerk.session.getToken()Pre-minted JWT from Frontend API nonce exchange
Token refreshAutomatic (Clerk SDK handles it)Manual via POST /v1/client/sessions/{sid}/tokens
Multi-tab syncBroadcast Channel (FEUX-10)N/A (single app instance per device)

Files Modified

  • ionic_frontend/src/composables/useClerkNative.ts — Frontend API bearer-token wrapper
  • ionic_frontend/src/composables/useAuthStore.ts — Auth facade; delegates to native or web
  • ionic_frontend/src/services/clerkFrontendApi.ts — HTTP client for clerk.objectuve.com/v1/*
  • ionic_frontend/src/services/secureStorage.ts — Encrypted persistent storage wrapper
  • ionic_frontend/src/apollo-client.ts — Auth middleware (single line change)
  • ionic_frontend/src/views/SsoCallback.vue — OAuth redirect handler
  • ionic_frontend/src/views/SignIn.vue — Conditionally renders native OAuth buttons or @clerk/vue
  • ionic_frontend/src/router/index.ts — Auth guard integration
  • ionic_frontend/src/App.vue — Global AgreeToTermsModal mount and consent accept/decline handlers (OBJ-2602)
  • ionic_frontend/src/components/AgreeToTermsModal.vue — The legal-consent modal itself (open/submitting/error props, accept/decline emits)

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

ColumnNotes
user_idowning user
device_idclient-generated, see Device binding below
token_digestSHA-256 of the raw token — the raw value is never persisted
token_scopecurrently one valid value, check_in_write (CheckinToken::SCOPES)
expires_atset at creation, 90.days from mint (CheckinToken::EXPIRY)
rotated_at / revoked_atnil until rotation/revocation
last_used_attouched 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

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

RevokeMutations::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)

Loading…