Skip to content

Silent refresh writes an access token into the pre-auth cookie, so passkey login answers 401 #154

Description

@Bccorb

Summary

When a pre-auth gated route is called without its cookie, ensureCookies falls through to
the silent refresh path and writes the resulting access token under the pre-auth
cookie name. A refresh can never mint an ephemeral token, so this is always wrong for the
routes that need one: it poisons the cookie and the next attempt forwards an access token
to a route the auth API gates on ephemeral.

Found while moving passkey enrollment behind the access session. Enrollment no longer hits
this because it now wants an access token anyway, but /webAuthn/login/start and
/webAuthn/login/finish still do.

Mechanism

  1. /webAuthn/login/start and /webAuthn/login/finish require preAuthCookieName
    (packages/core/src/ensureCookies.ts:61-62).
  2. meHandler clears the pre-auth cookie on every /users/me
    (packages/core/src/handlers/me.ts:30), and nothing else clears it. Any client that
    polls the session, which @seamless-auth/react does on provider mount, routinely
    arrives at these routes without one.
  3. The cookie is missing and required, so refreshRequiredCookie runs
    (ensureCookies.ts:310-315). It spends the refresh token and writes the new access
    token back under cookieName, which here is seamless-ephemeral
    (ensureCookies.ts:242).
  4. checkProxyIdentity reads the incoming cookies, so that request still answers
    401 "pre-auth session required" (packages/core/src/proxyRequest.ts:105-109).
  5. The retry now finds the cookie present, holding an access token, and forwards
    Bearer <access> upstream. The auth API answers
    JWT typ mismatch: expected 'ephemeral', got 'access'.

/webAuthn/login/finish is a handler rather than proxyWithIdentity, so it has no
checkProxyIdentity and reaches step 5 on the first attempt.

Why it is not caught today

The second refreshRequiredCookie call site is already guarded on the cookie being the
access one (ensureCookies.ts:343). The first is not, so it applies the same refresh to
every required cookie regardless of which token type the route can use.

Suggested fix

Guard the first call site the way the second one is: only silent-refresh when
cookieName === opts.accessCookieName. For a pre-auth gated route with no pre-auth cookie
the honest answer is 401, because nothing the adapter holds can produce the token that
route needs.

A test that a pre-auth gated route with only a refresh cookie answers 401 and asks
upstream nothing would pin it, in both adapters.

Context

This is the "probably its own bug" the reporter of
fells-code/seamless-auth-api#278 split out, and it is what produced the typ mismatch log
line in that report.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity-relevant issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions