From 0ccb472c059b87bfa8bd4c8cc4a0054c3006bc8b Mon Sep 17 00:00:00 2001 From: Christian Wyglendowski Date: Fri, 16 Oct 2026 18:22:23 -0400 Subject: [PATCH] Make isAuthenticated require a live session The core's `isAuthenticated` query now answers through `getAuthUserId`, so it is `true` only while the access token's session (its subject) exists, not merely while the token verifies. A subscriber re-runs the moment the session is deleted, so revocation is observable before the token expires, and the SSR cookie check becomes revocation-aware too. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5.5 --- packages/core/src/server/setup.test.ts | 43 ++++++++++++++++++++++++++ packages/core/src/server/setup.ts | 9 ++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/core/src/server/setup.test.ts b/packages/core/src/server/setup.test.ts index be02262ce..6d0fd4cc8 100644 --- a/packages/core/src/server/setup.test.ts +++ b/packages/core/src/server/setup.test.ts @@ -98,6 +98,9 @@ const testApp = { signIn, revokeOtherSessions, revokeAllSessions, + // The core's own registered functions, exercised as an app would export them. + isAuthenticated: core.isAuthenticated, + signOut: core.signOut, }; /** @@ -149,6 +152,18 @@ async function sessionIdsOf(t: ReturnType, userId: string) { return sessions.map((s) => s.sessionId); } +const isAuthenticatedRef = makeFunctionReference< + "query", + Record, + boolean +>("testApp:isAuthenticated"); + +const signOutRef = makeFunctionReference< + "mutation", + { refreshToken: string }, + null +>("testApp:signOut"); + describe("ctx.convexAuth.revokeOtherSessions", () => { test("revokes every session of the user but the caller's", async () => { const t = setup(); @@ -288,3 +303,31 @@ describe("ctx.convexAuth.revokeAllSessions", () => { ); }); }); + +describe("isAuthenticated", () => { + test("is false for a caller with no identity", async () => { + const t = setup(); + expect(await t.query(isAuthenticatedRef, {})).toBe(false); + }); + + test("is false for a subject that is no session", async () => { + const t = setup(); + expect( + await t + .withIdentity({ subject: "not-a-session-id" }) + .query(isAuthenticatedRef, {}), + ).toBe(false); + }); + + test("is true while the session is live, and false once it is revoked", async () => { + const t = setup(); + const bundle = await t.mutation(signUpRef, { accountId: "alice" }); + const caller = t.withIdentity(sessionIdentity(bundle)); + expect(await caller.query(isAuthenticatedRef, {})).toBe(true); + + await t.mutation(signOutRef, { refreshToken: bundle.refreshToken }); + + // The JWT is untouched and still verifies; the session is what died. + expect(await caller.query(isAuthenticatedRef, {})).toBe(false); + }); +}); diff --git a/packages/core/src/server/setup.ts b/packages/core/src/server/setup.ts index cbe87bf83..eadc6fd61 100644 --- a/packages/core/src/server/setup.ts +++ b/packages/core/src/server/setup.ts @@ -17,6 +17,7 @@ import { type ConvexAuthCtx, type UserCallbacks, } from "../lib/types.ts"; +import { getAuthUserId } from "./userId.ts"; /** * A type for a factory that returns a `RegisteredMutation` with a handler that @@ -105,13 +106,17 @@ export type AuthCore = { Promise >; /** - * Reports whether the caller's access token identifies a signed-in user. + * Reports whether the caller's access token identifies a live session. * * Convex verifies the token's signature (against the deployment's JWKS) * before this query runs, so the result is a trustworthy authentication * verdict. SSR hosts call it with the token from the auth cookie to decide * authentication state, rather than trusting the (client-controlled) cookie * by merely decoding it. + * + * The query reads the session, so it returns `false` once the session is + * gone (signed out elsewhere, revoked, or expired) even while the token + * itself is still valid, and a subscription re-runs when that happens. */ isAuthenticated: RegisteredQuery< "public", @@ -247,7 +252,7 @@ export function setupConvexAuth(options: { args: {}, returns: v.boolean(), handler: async (ctx): Promise => { - return (await ctx.auth.getUserIdentity()) !== null; + return (await getAuthUserId(ctx, component)) !== null; }, });