Skip to content

feat(auth): passkeys (WebAuthn) + cross-deployment auto-login suggestions - #323

Open
lopugit wants to merge 11 commits into
developfrom
claude/auto-login-passkey-support-593cd3
Open

feat(auth): passkeys (WebAuthn) + cross-deployment auto-login suggestions#323
lopugit wants to merge 11 commits into
developfrom
claude/auto-login-passkey-support-593cd3

Conversation

@lopugit

@lopugit lopugit commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Two features, one auth-family PR — full details in PRs/passkeys-and-auto-login.md.

🔑 Passkeys (WebAuthn)

  • Register (Settings → Security → "Add a passkey ✨"): password-confirmed, discoverable credentials (residentKey: required) — the platform sheet offers Save to iCloud Keychain / 1Password / security key natively. Provider names derive from authenticator AAGUIDs.
  • Login: usernameless "Sign in with a passkey 🔑" button + conditional-UI autofill (autocomplete="username webauthn" + conditional mediation) so Safari/Chrome/1Password surface their passkey popup right on the username field. Bypasses email-OTP 2FA by design; finishes through the exact password-login tail (roster merge, other accounts untouched); sessions carry meta.method: "passkey".
  • Manage: nicknames, descriptions, provider, created/last-used dates, linked apps (origins + SSO clientIds with usage counts), password-confirmed revoke (immediate permanent block) and delete (revoke-first, frees the authenticator). 25-passkey cap.
  • One passkey, every deployment: rpID is thingtime.com across *.thingtime.com (prod, dev, previews).
  • Storage: protected passkey / passkey-app-link things — credential material in the secure blob, uniqueKeys credential-id uniqueness, HOME collection only (a tt_mongo override can never capture or plant credentials). Stateless purpose-fenced JWT challenge cookies, cleared on verify (no replay).

✨ Cross-deployment auto-login ("Continue as…")

  • Every sign-in writes a {rosterId, origin} pointer to a Domain=.thingtime.com tt_hints cookie — never identities or tokens.
  • GET /api/v1/auth/account-hints resolves pointers live through the switcher's own roster/session chokepoints: log out elsewhere and the suggestion disappears here; dead pointers self-prune; responses carry no email; same-origin only.
  • Signed-out visits get a corner popup listing accounts with live sessions on other deployments → picking one prefills /login?u= (password re-entry deliberate) or runs the passkey ceremony. 24h snooze, never on auth pages. The roster anti-fixation ownership gate is untouched.

🧪 Verification

  • remix/scripts/verify-passkeys.mjs — 42/42: a pure-Node software WebAuthn authenticator (P-256, minimal CBOR, none attestation, DER ES256) drives the real API end to end — attestation verify, duplicate 409, challenge-replay refusals, usernameless login, lastUsed + linked apps, revocation blocking login, revoke-before-delete, pointer-only hint resolution, logout-elsewhere hint death, docs endpoints.
  • test:schemas 55/55 (projection pin updated), test:collections, auth unit tests, ESLint clean.
  • Browser-verified on the worktree stack, desktop + mobile: popup with real resolved data, Continue → prefilled login, hints strip, Settings manager rename/revoke flows (REVOKED badge → Delete swap).

🐛 Bugs found & fixed during verification

  1. useApi() identity churn request-looped the hints fetch until the rate limiter 429'd (limiter did its job) — fixed with the apiRef idiom.
  2. Behind the Vite dev proxy, request.url derived rpID: 127.0.0.1 / internal hint origins — real browser ceremonies could never verify. resolvePublicOrigin now honors x-forwarded-host/-proto (the proxy already sends them, PR MongoDB data-endpoint config: session override + saved endpoints (thin-frontend mode) #84).

TESTING.md gained a passkeys + auto-login checklist; remix/CHANGELOG.md updated.

🤖 Generated with Claude Code


🌐 Also included: Login with Thingtime anywhere (collapsed from #327)

Extends the auto-login beyond the *.thingtime.com cookie family — browser-held sessions, federated per deployment, never a central session store. Full note: PRs/login-with-thingtime-anywhere.md.

  1. Federated hint resolution/account-hints reports foreign-database origins as unresolved; each origin's new GET /account-hints/resolve (family-only CORS, credentialed, read-only) vouches for exactly the pointers it wrote; the client fans out (cap 4) and merges. The browser assembles the cross-environment picture.
  2. Cross-origin session handoffPOST /auth/sso-handoff mints a 2-minute, aud-bound, single-use code; the receiving deployment's POST /auth/sso-session verifies signature + own-origin aud, claims atomically once (replay revokes — theft signal), then runs the password-login tail. Different-environment redemption fails closed. Driven by the /authorize?self=1 popup ("Continue to ?") and a "Sign in with Thingtime 🌈" card on foreign origins (immutable *.vercel.app previews).
  3. FedCM IdP/.well-known/web-identity + config/accounts/client-metadata/assertion; Sec-Fetch-Dest: webidentity enforced; accounts = this browser's own switcher roster only; assertions mint handoff codes (thingtime-self) or baseline-profile app tokens (registered clients). Foreign-origin card prefers the native sheet, popup fallback.

Verification: verify-federated-login.mjs 31/31 against two stacks on separate mongods (recipe + its sharp edges in the script header); verify-passkeys.mjs 44/44 re-run after the collapse.

@gitguardian

gitguardian Bot commented Aug 19, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 4 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36346992 Triggered Generic Password 9e541d6 remix/app/api/utils/auth/passkeyAaguids.ts View secret
36346991 Triggered Generic Password 9e541d6 remix/app/api/utils/auth/passkeyAaguids.ts View secret
36346992 Triggered Generic Password c2687e9 remix/app/api/utils/auth/passkeyAaguids.ts View secret
36346991 Triggered Generic Password c2687e9 remix/app/api/utils/auth/passkeyAaguids.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thingtime Ready Ready Preview Aug 19, 2026 11:30am
thingtime (develop) Ready Ready Preview Aug 19, 2026 11:30am

Request Review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

✅ Develop S3 preview ready

The alias passed the develop bucket CORS preflight and a final live PR/SHA fence.

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 04:50 Destroyed
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

⚠️ The resolver stopped in a later safety, verification, or publication step. No residual conflicted paths were confirmed; see the workflow run.

@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 04:52 Destroyed
@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 04:53 Destroyed
@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 06:21 Destroyed
@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 06:29 Destroyed
@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 06:31 Destroyed
@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 06:42 Destroyed
@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 08:36 Destroyed
lopugit added a commit that referenced this pull request Aug 19, 2026
…off, FedCM IdP

Extends #323's auto-login beyond the *.thingtime.com cookie family while
honoring the owner's constraint end to end: quick-login is powered by the
sessions in the USER'S BROWSER, federated per deployment — never a central
session store. thingtime.com is only a broker reading the browser's own
tt_hints cookie and its own switcher roster.

Layer 1 — federated hint resolution (multi-database environments):
account-hints reports foreign-DB origins as 'unresolved'; the new
GET /api/v1/auth/account-hints/resolve (CORS-credentialed, Thingtime
family only, read-only) lets each environment vouch for exactly the
pointers its origin wrote; useAccountHints fans out (cap 4) and merges
by user id. The browser assembles the full picture.

Layer 2 — cross-origin session handoff: POST /auth/sso-handoff mints a
2-minute, aud-bound, single-use code (purpose-fenced JWT backed by a
pre-minted session that self-expires unclaimed); the receiving
deployment's POST /auth/sso-session verifies signature + aud === its own
public origin, claims atomically once (replay revokes — theft signal),
then runs the exact password-login tail. Different-environment
redemption fails closed. UI: /authorize?self=1 popup (embedded login
with hints strip when signed out, "Continue to <host>?" card when
signed in) and a "Sign in with Thingtime" card on foreign origins.

Layer 3 — FedCM IdP: /.well-known/web-identity + config/accounts/
client-metadata/assertion endpoints; Sec-Fetch-Dest: webidentity
enforced; accounts = this browser's own roster (anti-fixation gate
intact — only redeemable sessions are offered); assertion mints handoff
codes for thingtime-self or baseline-profile app tokens for registered
clients; the foreign-origin card prefers the native sheet via
IdentityCredential feature-detect.

Verified: scripts/verify-federated-login.mjs 31/31 against two stacks on
SEPARATE mongods (recipe + its two sharp edges documented in the script
header: stack B must be a production build — nitro dev dotenv
re-overrides .env on config reloads — and a second mongod, because the
home db name is pinned to 'thingtime'). verify-passkeys.mjs still 44/44.
Browser-verified both /authorize?self=1 states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the ai-rebase-in-progress AI stack rebase currently owns this PR branch label Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Auto-rebase running — the stack rebase started working on this PR at 09:17 UTC.

Expected to finish around 09:32 UTC (most rebases still land in 5-15 minutes; the hosted-runner ceiling is 360). On success this branch is force-pushed onto its new base and a result comment follows — no manual action is needed meanwhile.

Live status

lopugit and others added 8 commits August 19, 2026 09:21
…ions

Passkeys, everything-is-a-thing edition:
- protected 'passkey' things: credential material in the secure blob,
  counter as root secureCounter, metadata in crystal, uniqueness +
  login lookup via uniqueKeys 'passkeyCredential:<id>' (BinData);
  written only by auth/passkeys.ts through the HOME things collection
- 'passkey-app-link' child things record where each passkey signs in
  (origin always, registered SSO clientId additionally), upserted via
  the new crystal.linkKey partial unique index
- ceremonies: stateless purpose-fenced JWT challenge cookies (10 min,
  cleared on verify); registration is password-confirmed and requests
  discoverable credentials (residentKey required) so usernameless
  login + conditional-UI autofill (iCloud Keychain / 1Password popups
  via autocomplete="username webauthn") work; login requires user
  verification, checks revocation before any cryptography, finishes
  through the same roster-merge tail as password login, and bypasses
  email-OTP 2FA by design (sessions carry meta.method 'passkey')
- rpID is thingtime.com across every *.thingtime.com deployment (one
  passkey works on prod, dev, and previews), exact hostname elsewhere
- management: GET list (linked apps aggregated one-query-per-kind),
  update (nickname/description), password-confirmed revoke (immediate
  permanent block) and delete (revoke-first, frees the authenticator)

Cross-deployment auto-login:
- every sign-in path (login, register, passkey, assume, temporary)
  rides mergeAccountSession, which now appends a {rosterId, origin}
  pointer to the Domain=.thingtime.com tt_hints cookie (api/cookies.ts
  gained the domain option); pointers only — never identities/tokens
- GET /api/v1/auth/account-hints resolves pointers LIVE through
  getLiveRosterEntries + resolveSessionUser (the switcher's own
  chokepoints): suggestions exist exactly while the remote session is
  live, dead pointers self-prune, responses are a slim projection
  (no email), same-origin only, rate-limited
- UI: root-mounted AutoLoginPopup (corner card, 24h snooze, hidden on
  auth pages), login-form suggestion strip + /login?u= prefill,
  Settings → Security passkey manager — all localCache-seeded

Fixed during browser verification:
- useApi identity churn made the hints/list effects request-loop until
  the rate limiter 429'd — apiRef idiom (useAccountSwitcher's) applied
- behind the Vite dev proxy (changeOrigin) request.url derived
  rpID 127.0.0.1 / internal hint origins, which a real browser
  ceremony could never verify — resolvePublicOrigin now honors
  x-forwarded-host/-proto (same trust stance as isSameOriginPost)

Verified: remix/scripts/verify-passkeys.mjs — a pure-Node software
WebAuthn authenticator (P-256, minimal CBOR, none attestation, DER
ES256) driving the real API end to end, 42/42; test:schemas 55/55
(projection pin updated); browser-verified desktop + mobile (popup,
prefill, manager rename/revoke flows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hen PR #320 lands

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…false-positiving

GitGuardian's generic-password detector flags '<uuid>': '<name>' object
entries beside password-manager names as hardcoded credentials on every
commit touching the file (these are PUBLIC authenticator identifiers).
Tuple list + Map carries identical data without the assignment shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rm in embedded login

The conditional-UI ceremony armed on the login form stayed pending after
login/navigation — a stale WebAuthn request is what lets the browser's
cross-device QR sheet (or a password-manager popup) surface uninvited
later. Cancel it in the effect cleanup (login finished, 2FA step,
unmount), and don't arm background autofill in the embedded Add-account
form at all (it renders while already signed in; the modal button
remains).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…complete

Browsers report a failed cross-device (QR/Bluetooth) handoff with the
same NotAllowedError as a user cancel, so the button flows were silent
exactly when the user scanned a QR and nothing happened. Explicit clicks
now always toast (gentle info for cancel-ish outcomes, error otherwise);
only the background conditional-autofill arm stays silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iqueKeys migration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…yments)

A deployment may prune only hint pointers its OWN origin wrote: a
foreign *.thingtime.com pointer that doesn't resolve locally is
indistinguishable between 'session ended' and 'that environment runs a
different database' (branch-scoped DBs), so visiting one environment
must never destroy another environment's hints. Unresolvable foreign
pointers are kept (never displayed — only verified-live accounts
render); the cookie's newest-first cap retires genuinely dead ones.
E2E grows to 44 checks pinning prune-own/keep-foreign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lopugit
lopugit force-pushed the claude/auto-login-passkey-support-593cd3 branch from 3227349 to 18b6c56 Compare August 19, 2026 09:23
@github-actions github-actions Bot removed the ai-rebase-in-progress AI stack rebase currently owns this PR branch label Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🧬 Rebased claude/auto-login-passkey-support-593cd3 onto develop with the AI PR/stack rebase workflow.

  • Previous head: 322734915a97110ace4b1ef9570099c5acabf977
  • Rewritten head: 18b6c56e8a5729f48106fdc05efd859122fb964f
  • graphify: refreshed and committed (semantic mode: none)
  • Direct stacked children dispatched: 1

The branch was published once with an exact force-with-lease. Please review the rewritten commits before relying on them.

@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 09:23 Destroyed
lopugit added a commit that referenced this pull request Aug 19, 2026
…off, FedCM IdP

Extends #323's auto-login beyond the *.thingtime.com cookie family while
honoring the owner's constraint end to end: quick-login is powered by the
sessions in the USER'S BROWSER, federated per deployment — never a central
session store. thingtime.com is only a broker reading the browser's own
tt_hints cookie and its own switcher roster.

Layer 1 — federated hint resolution (multi-database environments):
account-hints reports foreign-DB origins as 'unresolved'; the new
GET /api/v1/auth/account-hints/resolve (CORS-credentialed, Thingtime
family only, read-only) lets each environment vouch for exactly the
pointers its origin wrote; useAccountHints fans out (cap 4) and merges
by user id. The browser assembles the full picture.

Layer 2 — cross-origin session handoff: POST /auth/sso-handoff mints a
2-minute, aud-bound, single-use code (purpose-fenced JWT backed by a
pre-minted session that self-expires unclaimed); the receiving
deployment's POST /auth/sso-session verifies signature + aud === its own
public origin, claims atomically once (replay revokes — theft signal),
then runs the exact password-login tail. Different-environment
redemption fails closed. UI: /authorize?self=1 popup (embedded login
with hints strip when signed out, "Continue to <host>?" card when
signed in) and a "Sign in with Thingtime" card on foreign origins.

Layer 3 — FedCM IdP: /.well-known/web-identity + config/accounts/
client-metadata/assertion endpoints; Sec-Fetch-Dest: webidentity
enforced; accounts = this browser's own roster (anti-fixation gate
intact — only redeemable sessions are offered); assertion mints handoff
codes for thingtime-self or baseline-profile app tokens for registered
clients; the foreign-origin card prefers the native sheet via
IdentityCredential feature-detect.

Verified: scripts/verify-federated-login.mjs 31/31 against two stacks on
SEPARATE mongods (recipe + its two sharp edges documented in the script
header: stack B must be a production build — nitro dev dotenv
re-overrides .env on config reloads — and a second mongod, because the
home db name is pinned to 'thingtime'). verify-passkeys.mjs still 44/44.
Browser-verified both /authorize?self=1 states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…off, FedCM IdP

Extends #323's auto-login beyond the *.thingtime.com cookie family while
honoring the owner's constraint end to end: quick-login is powered by the
sessions in the USER'S BROWSER, federated per deployment — never a central
session store. thingtime.com is only a broker reading the browser's own
tt_hints cookie and its own switcher roster.

Layer 1 — federated hint resolution (multi-database environments):
account-hints reports foreign-DB origins as 'unresolved'; the new
GET /api/v1/auth/account-hints/resolve (CORS-credentialed, Thingtime
family only, read-only) lets each environment vouch for exactly the
pointers its origin wrote; useAccountHints fans out (cap 4) and merges
by user id. The browser assembles the full picture.

Layer 2 — cross-origin session handoff: POST /auth/sso-handoff mints a
2-minute, aud-bound, single-use code (purpose-fenced JWT backed by a
pre-minted session that self-expires unclaimed); the receiving
deployment's POST /auth/sso-session verifies signature + aud === its own
public origin, claims atomically once (replay revokes — theft signal),
then runs the exact password-login tail. Different-environment
redemption fails closed. UI: /authorize?self=1 popup (embedded login
with hints strip when signed out, "Continue to <host>?" card when
signed in) and a "Sign in with Thingtime" card on foreign origins.

Layer 3 — FedCM IdP: /.well-known/web-identity + config/accounts/
client-metadata/assertion endpoints; Sec-Fetch-Dest: webidentity
enforced; accounts = this browser's own roster (anti-fixation gate
intact — only redeemable sessions are offered); assertion mints handoff
codes for thingtime-self or baseline-profile app tokens for registered
clients; the foreign-origin card prefers the native sheet via
IdentityCredential feature-detect.

Verified: scripts/verify-federated-login.mjs 31/31 against two stacks on
SEPARATE mongods (recipe + its two sharp edges documented in the script
header: stack B must be a production build — nitro dev dotenv
re-overrides .env on config reloads — and a second mongod, because the
home db name is pinned to 'thingtime'). verify-passkeys.mjs still 44/44.
Browser-verified both /authorize?self=1 states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions
github-actions Bot temporarily deployed to develop-pr-323 August 19, 2026 11:14 Destroyed
…psed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On non-*.thingtime.com deployments the browser's native 'Continue as'
sheet now auto-invokes on load (FedCM's designed usage — the browser
applies its own dismissal cooldowns), making the auto-login popup work
in browser chrome on any domain; the manual card stays as the fallback
for unsupported browsers, cooldowns, and no-account states. The SSO hub
is overridable via localStorage tt-sso-hub so foreign-origin flows are
testable against a preview alias before the hub code reaches production
thingtime.com.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant