Skip to content

fix(auth): close three account-security holes (0.20.5) - #45

Merged
jsnyder10 merged 2 commits into
mainfrom
fix/auth-2fa-revocation-and-oauth-email
Sep 13, 2026
Merged

jsnyder10 merged 2 commits into
mainfrom
fix/auth-2fa-revocation-and-oauth-email

Conversation

@jsnyder10

Copy link
Copy Markdown
Contributor

Summary

Three account-security holes in @factiii/auth, all present in every 0.x:

  • A revoked session's 2FA secret no longer answers the login challenge. The secret now moves to the replacement session on every re-login, atomically (new optional moveTwoFaSecret adapter method), so closing the hole locks nobody out.
  • An OAuth sign-in trusts only an email the provider vouches for. Apple no longer falls back to a client-supplied email; Google's email counts only when email_verified === true. A linked user whose token omits the email still signs in by sub.
  • Attaching an OAuth identity to an existing passwordless account requires that account's email to be VERIFIED. Closes account pre-hijacking; fails closed if an adapter omits the field.

Release

Patch. changeset status plans @factiii/auth -> 0.20.5 only.
Public type changes: OAuthResult.email becomes optional; DeviceAuthAdapter gains an optional moveTwoFaSecret.

Testing

  • packages/auth: 15 files, 214 tests pass; check-types clean. The new tests fail against the old code.
  • Consumer QA against a packed build of this branch:
    • factiii: regression tests for all three takeovers fail on 0.20.4 and pass on the fix; full gates green.
    • Oakbox: covered through the factiii server; its web suite passes.
    • Chop-Shop (standard mode, Drizzle, OAuth off): per-test results byte-identical to 0.20.4.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EFfQ2y8w3U1jRLZrmwZUwR

… challenge

`findTwoFaSecretsByUserId` returned every session row holding a secret, revoked
or not, so a TOTP secret from a device the user had deliberately revoked — an old
phone, a sold one, "log out everywhere" after a compromise — still passed the
device-mode 2FA challenge. Present in every 0.x. Both adapters now filter
`revokedAt`.

The filter alone would have been a lockout. Every ordinary sign-in retires the
session that device already held, and nothing carried the secret across, so a
re-logged-in user would have had no live secret at all.
`carryDeviceTwoFaSecret` now hands it to the replacement in the password, OAuth
and magic-link paths.

It moves rather than copies, because `Session.twoFaSecret` is unique: a copy
throws, and a helper that swallows its own errors turns that throw into a silent
lockout. The move is atomic through a new optional `moveTwoFaSecret` adapter
method — optional, so an adapter written against 0.20.4 still satisfies the
interface — with a clear-then-set fallback that fails recoverably. A failed
carry now logs instead of vanishing.

The unit fake enforces the unique index itself. A mock that cannot fail the way
production fails is how the first version of this passed every unit test.

Also repairs push approval after a re-login for a consumer that already filtered
revoked rows, which found no live secret until the vault re-minted one.
Two holes in `oAuthLogin`'s attach-by-email step, one class: an email that nobody
proved decided which existing account a Google or Apple sign-in landed in. Both
present since the initial integration.

1. The client could name the email. The Apple verifier fell back to
   `extra.email` — a value the client sends — whenever the signed token had no
   email claim, so anyone with a valid Apple token for their own Apple ID could
   name a victim's address and be signed into the victim's passwordless account.
   Google's `email_verified: false` was trusted too. Only a provider-vouched email
   counts now: Apple's token email, and Google's only when
   `email_verified === true`. A missing email is not an error, so a user already
   linked by `sub` keeps signing in; `OAuthResult.email` becomes optional.

2. The account's own email did not have to be proven. A consumer can let a user
   store an unclaimed address unverified, so an attacker could create a
   passwordless account under a victim's address and wait for the victim's first
   genuine Google sign-in — a real token, nothing forged — to land in an account
   the attacker still held a passkey for. Attaching now requires the existing
   account's email to be VERIFIED. An unverified match is refused with a way
   forward — link the provider from Settings, bound to the signed-in session —
   and an adapter that omits the field refuses every attach.

The password-account refusal is unchanged and still wins. A verified passwordless
account still attaches, so nobody who proved their address is locked out.

The tests run the real `oAuthLogin` through in-memory adapters that return full
rows and can produce the dangerous inputs; the new ones fail against the old code.
@jsnyder10
jsnyder10 merged commit a3de9f1 into main Sep 13, 2026
1 check passed
@jsnyder10
jsnyder10 deleted the fix/auth-2fa-revocation-and-oauth-email branch September 13, 2026 15:56
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