From 69acb7a3db3cd148a9cd1db5063dbdc69909165a Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Wed, 22 Jul 2026 06:09:55 -0400 Subject: [PATCH] fix(oauth-provider): defer logout effects until commit (#10472) --- .changeset/tidy-ravens-revoke.md | 5 + packages/oauth-provider/package.json | 1 + .../src/backchannel-logout.test.ts | 300 +++++++++++++++++- packages/oauth-provider/src/logout.ts | 139 ++++---- packages/oauth-provider/src/oauth.ts | 55 +++- pnpm-lock.yaml | 3 + 6 files changed, 432 insertions(+), 71 deletions(-) create mode 100644 .changeset/tidy-ravens-revoke.md diff --git a/.changeset/tidy-ravens-revoke.md b/.changeset/tidy-ravens-revoke.md new file mode 100644 index 0000000000..7caeecea08 --- /dev/null +++ b/.changeset/tidy-ravens-revoke.md @@ -0,0 +1,5 @@ +--- +"@better-auth/oauth-provider": patch +--- + +Revoke session-bound OAuth tokens and deliver back-channel logout only after the related session deletion succeeds. diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index 4dfa48ad37..383e0edb2d 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -71,6 +71,7 @@ }, "devDependencies": { "@better-auth/core": "workspace:*", + "@better-auth/memory-adapter": "workspace:*", "better-auth": "workspace:*", "listhen": "^1.9.0", "tsdown": "catalog:" diff --git a/packages/oauth-provider/src/backchannel-logout.test.ts b/packages/oauth-provider/src/backchannel-logout.test.ts index a144f8e948..8915c27cfb 100644 --- a/packages/oauth-provider/src/backchannel-logout.test.ts +++ b/packages/oauth-provider/src/backchannel-logout.test.ts @@ -1,9 +1,16 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; +import { createAuthEndpoint } from "@better-auth/core/api"; +import { + getCurrentAdapter, + runWithAdapter, + runWithTransaction, +} from "@better-auth/core/context"; import { authorizationCodeRequest, createAuthorizationURL, } from "@better-auth/core/oauth2"; +import { memoryAdapter } from "@better-auth/memory-adapter"; import { createAuthClient } from "better-auth/client"; import { generateRandomString } from "better-auth/crypto"; import { toNodeHandler } from "better-auth/node"; @@ -21,6 +28,7 @@ import { expect, it, } from "vitest"; +import * as z from "zod"; import { oauthProviderClient } from "./client"; import { oauthProvider } from "./oauth"; @@ -74,18 +82,46 @@ async function startMockRp( type MakeRequired = Omit & Required>; describe("oauth back-channel logout", async () => { + const database = { + account: [], + jwks: [], + oauthAccessToken: [], + oauthClient: [], + oauthClientAssertion: [], + oauthClientResource: [], + oauthConsent: [], + oauthRefreshToken: [], + oauthResource: [], + session: [], + user: [], + verification: [], + }; const port = 3010; const baseUrl = `http://localhost:${port}`; const state = "123"; const scopes = ["openid", "email", "profile", "offline_access"]; + let shouldVetoSessionDeletion = false; const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ baseURL: baseUrl, + database: memoryAdapter(database), + databaseHooks: { + session: { + delete: { + async before() { + if (!shouldVetoSessionDeletion) return; + shouldVetoSessionDeletion = false; + return false; + }, + }, + }, + }, plugins: [ oauthProvider({ loginPage: "/login", consentPage: "/consent", allowDynamicClientRegistration: true, + pairwiseSecret: "test-backchannel-pairwise-secret-32-chars", silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true, @@ -93,6 +129,91 @@ describe("oauth back-channel logout", async () => { scopes, }), jwt(), + { + id: "transactional-session-revocation-test", + endpoints: { + testTransactionalSessionRevocation: createAuthEndpoint( + "/test/transactional-session-revocation", + { + method: "POST", + body: z.object({ + clientId: z.string().optional(), + rollback: z.boolean(), + sessionToken: z.string(), + trackActiveAdapterWrites: z.boolean().optional(), + transactionAccessTokenId: z.string().optional(), + }), + }, + async (ctx) => { + const rollbackMarker = Symbol("rollback session revocation"); + const updatedModelsThroughActiveAdapter: string[] = []; + const revokeSession = async () => { + try { + await runWithTransaction(ctx.context.adapter, async () => { + if ( + ctx.body.clientId && + ctx.body.transactionAccessTokenId + ) { + const activeSession = + await ctx.context.internalAdapter.findSession( + ctx.body.sessionToken, + ); + if (!activeSession) throw new Error("Session not found"); + const adapter = await getCurrentAdapter( + ctx.context.adapter, + ); + await adapter.create({ + model: "oauthAccessToken", + forceAllowId: true, + data: { + id: ctx.body.transactionAccessTokenId, + token: ctx.body.transactionAccessTokenId, + clientId: ctx.body.clientId, + sessionId: activeSession.session.id, + userId: activeSession.user.id, + scopes: ["openid"], + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + revoked: null, + }, + }); + } + await ctx.context.internalAdapter.deleteSession( + ctx.body.sessionToken, + ); + if (ctx.body.rollback) throw rollbackMarker; + }); + } catch (error) { + if (error !== rollbackMarker) throw error; + return false; + } + return true; + }; + const committed = ctx.body.trackActiveAdapterWrites + ? await (async () => { + const baseAdapter = await getCurrentAdapter( + ctx.context.adapter, + ); + const activeAdapter = { + ...baseAdapter, + async updateMany(input) { + updatedModelsThroughActiveAdapter.push(input.model); + return baseAdapter.updateMany(input); + }, + } satisfies typeof baseAdapter; + return runWithAdapter(activeAdapter, revokeSession); + })() + : await revokeSession(); + return ctx.json({ + committed, + ...(ctx.body.trackActiveAdapterWrites + ? { updatedModelsThroughActiveAdapter } + : {}), + }); + }, + ), + }, + }, ], }); let { headers } = await signInWithTestUser(); @@ -112,6 +233,7 @@ describe("oauth back-channel logout", async () => { if (server) await server.close(); }); beforeEach(async () => { + shouldVetoSessionDeletion = false; rp = await startMockRp(); const signed = await signInWithTestUser(); headers = signed.headers; @@ -125,6 +247,7 @@ describe("oauth back-channel logout", async () => { enable_end_session: boolean; backchannel_logout_uri: string | undefined; backchannel_logout_session_required: boolean; + subject_type: "pairwise" | "public"; }> = {}, ) { const response = await auth.api.adminCreateOAuthClient({ @@ -210,6 +333,53 @@ describe("oauth back-channel logout", async () => { } } + async function revokeSessionOverHTTP(input: { + clientId?: string; + rollback: boolean; + sessionToken: string; + trackActiveAdapterWrites?: boolean; + transactionAccessTokenId?: string; + }) { + const response = await fetch( + `${baseUrl}/api/auth/test/transactional-session-revocation`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!response.ok) { + throw new Error( + `Transactional session revocation failed: ${await response.text()}`, + ); + } + return (await response.json()) as { + committed: boolean; + updatedModelsThroughActiveAdapter?: string[]; + }; + } + + async function readClientTokenRevocation(clientId: string) { + const ctx = await auth.$context; + const [accessTokens, refreshTokens] = await Promise.all([ + ctx.adapter.findMany<{ revoked?: Date | null }>({ + model: "oauthAccessToken", + where: [{ field: "clientId", value: clientId }], + }), + ctx.adapter.findMany<{ revoked?: Date | null }>({ + model: "oauthRefreshToken", + where: [{ field: "clientId", value: clientId }], + }), + ]); + return { accessTokens, refreshTokens }; + } + + async function getSessionToken(requestHeaders: Headers) { + const session = await auth.api.getSession({ headers: requestHeaders }); + if (!session) throw new Error("Expected an active test session"); + return session.session.token; + } + it("dispatches a conformant logout token when the session is signed out", async () => { const oauthClient = await registerClient(); const tokens = await issueTokens({ client: oauthClient }); @@ -251,6 +421,103 @@ describe("oauth back-channel logout", async () => { expect(payload.sid).toBe(idTokenPayload.sid); }); + it("dispatches logout only after a transactional session revocation commits", async () => { + const signedIn = await signInWithTestUser(); + headers = signedIn.headers; + const oauthClient = await registerClient(); + await issueTokens({ client: oauthClient }); + + const rolledBack = await revokeSessionOverHTTP({ + rollback: true, + sessionToken: await getSessionToken(headers), + }); + expect(rolledBack).toEqual({ committed: false }); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(rp.received).toHaveLength(0); + expect(await auth.api.getSession({ headers })).not.toBeNull(); + const tokensAfterRollback = await readClientTokenRevocation( + oauthClient.client_id, + ); + expect(tokensAfterRollback.accessTokens.length).toBeGreaterThan(0); + expect(tokensAfterRollback.refreshTokens.length).toBeGreaterThan(0); + for (const token of tokensAfterRollback.accessTokens) { + expect(token.revoked ?? null).toBeNull(); + } + for (const token of tokensAfterRollback.refreshTokens) { + expect(token.revoked ?? null).toBeNull(); + } + + const committed = await revokeSessionOverHTTP({ + clientId: oauthClient.client_id, + rollback: false, + sessionToken: await getSessionToken(headers), + transactionAccessTokenId: "transaction-only-access-token", + }); + expect(committed).toEqual({ committed: true }); + await waitForDispatches(); + expect(rp.received).toHaveLength(1); + expect(await auth.api.getSession({ headers })).toBeNull(); + const tokensAfterCommit = await readClientTokenRevocation( + oauthClient.client_id, + ); + for (const token of tokensAfterCommit.accessTokens) { + expect(token.revoked).toBeInstanceOf(Date); + } + for (const token of tokensAfterCommit.refreshTokens) { + expect(token.revoked ?? null).toBeNull(); + } + const transactionOnlyToken = await (await auth.$context).adapter.findOne<{ + revoked?: Date | null; + }>({ + model: "oauthAccessToken", + where: [{ field: "id", value: "transaction-only-access-token" }], + }); + expect(transactionOnlyToken?.revoked).toBeInstanceOf(Date); + }); + + it("applies committed token revocation through the active adapter", async () => { + const signedIn = await signInWithTestUser(); + headers = signedIn.headers; + const oauthClient = await registerClient(); + await issueTokens({ + client: oauthClient, + requestScopes: ["openid", "email", "profile"], + }); + + const committed = await revokeSessionOverHTTP({ + rollback: false, + sessionToken: await getSessionToken(headers), + trackActiveAdapterWrites: true, + }); + + expect(committed).toEqual({ + committed: true, + updatedModelsThroughActiveAdapter: ["oauthAccessToken"], + }); + }); + + it("keeps the session and tokens active when a later hook vetoes deletion", async () => { + const signedIn = await signInWithTestUser(); + headers = signedIn.headers; + const oauthClient = await registerClient(); + await issueTokens({ client: oauthClient }); + shouldVetoSessionDeletion = true; + + const result = await revokeSessionOverHTTP({ + rollback: false, + sessionToken: await getSessionToken(headers), + }); + expect(result).toEqual({ committed: true }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(await auth.api.getSession({ headers })).not.toBeNull(); + expect(rp.received).toHaveLength(0); + const tokens = await readClientTokenRevocation(oauthClient.client_id); + for (const token of [...tokens.accessTokens, ...tokens.refreshTokens]) { + expect(token.revoked ?? null).toBeNull(); + } + }); + it("dispatches when RP-Initiated Logout tears down the session", async () => { const oauthClient = await registerClient(); const tokens = await issueTokens({ client: oauthClient }); @@ -371,6 +638,35 @@ describe("oauth back-channel logout", async () => { await waitForDispatches(); expect(rp.received).toHaveLength(1); }); + + it("isolates a malformed pairwise client from revocation and healthy RP delivery", async () => { + const healthyClient = await registerClient(); + const malformedClient = await registerClient({ subject_type: "pairwise" }); + await issueTokens({ client: healthyClient }); + await issueTokens({ client: malformedClient }); + + const ctx = await auth.$context; + await ctx.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: malformedClient.client_id }], + update: { redirectUris: [] }, + }); + + await client.signOut({ fetchOptions: { headers } }); + await waitForDispatches(); + + expect(rp.received).toHaveLength(1); + for (const clientId of [ + healthyClient.client_id, + malformedClient.client_id, + ]) { + const tokens = await readClientTokenRevocation(clientId); + expect(tokens.accessTokens.length).toBeGreaterThan(0); + for (const token of tokens.accessTokens) { + expect(token.revoked).toBeInstanceOf(Date); + } + } + }); }); describe("oauth back-channel logout (jwt plugin disabled)", async () => { @@ -466,8 +762,8 @@ describe("oauth back-channel logout (jwt plugin disabled)", async () => { expect(before.length).toBeGreaterThan(0); for (const t of before) expect(t.revoked ?? null).toBeNull(); - // Revocation runs inline in `session.delete.before`; with no jwt plugin - // there is no async delivery to wait for. + // With no JWT plugin, the post-delete phase revokes tokens without + // attempting Logout Token delivery. await client.signOut({ fetchOptions: { headers } }); const after = await ctx.adapter.findMany<{ revoked?: Date | null }>({ diff --git a/packages/oauth-provider/src/logout.ts b/packages/oauth-provider/src/logout.ts index e9e699f5d6..b3705cfeff 100644 --- a/packages/oauth-provider/src/logout.ts +++ b/packages/oauth-provider/src/logout.ts @@ -1,4 +1,5 @@ import type { GenericEndpointContext } from "@better-auth/core"; +import { getCurrentAdapter } from "@better-auth/core/context"; import { generateRandomString } from "better-auth/crypto"; import { getJwks } from "better-auth/oauth2"; import { resolveSigningKey, signJWT } from "better-auth/plugins"; @@ -51,7 +52,9 @@ interface BackchannelLogoutTarget { * always present because every session-end path that reaches here carries the * id of the session being terminated. */ -interface BackchannelLogoutPlan { +export interface BackchannelLogoutPlan { + accessTokenIds: string[]; + refreshTokenIds: string[]; sessionId: string; targets: BackchannelLogoutTarget[]; } @@ -96,10 +99,10 @@ async function signLogoutToken( } /** - * Synchronous phase: enumerate tokens for the session being terminated, revoke - * them, and return a plan for the asynchronous delivery phase. Runs inline in - * the `session.delete.before` hook so the DB state is consistent before the - * session row disappears. + * Build the immutable work plan before a session is deleted. This phase only + * reads database state because a later delete hook may still veto the mutation. + * Token identifiers must be captured here because their `sessionId` foreign key + * is cleared when the session row disappears. * * Revocation is the stored backstop, not the primary enforcement: introspection * and `/userinfo` already treat a token whose session has ended as inactive @@ -110,14 +113,14 @@ async function signLogoutToken( * revoked; `offline_access` refresh tokens survive so long-lived API access can * outlive the browser session. * - * Revocation runs regardless of the JWT plugin (refresh-token revocation has no - * dependency on signing). Only the Logout Token delivery plan needs the JWT - * plugin, so when it is disabled we still revoke but never build a plan. + * Token revocation runs regardless of the JWT plugin (refresh-token revocation + * has no dependency on signing). Only Logout Token delivery needs the JWT + * plugin, so a plan may contain token identifiers with no delivery targets. * * Returns `null` when there is nothing to do, so the caller can skip the * background handoff entirely. */ -async function revokeAndPlanBackchannelLogout( +async function prepareBackchannelLogoutPlan( ctx: GenericEndpointContext, opts: OAuthOptions, input: { sessionId: string; userId: string }, @@ -127,14 +130,15 @@ async function revokeAndPlanBackchannelLogout( const logger = ctx.context.logger; try { + const adapter = await getCurrentAdapter(ctx.context.adapter); const where = [{ field: "sessionId", value: sessionId }]; const [accessTokens, refreshTokens] = await Promise.all([ - ctx.context.adapter.findMany({ + adapter.findMany({ model: "oauthAccessToken", where, }), - ctx.context.adapter.findMany({ + adapter.findMany({ model: "oauthRefreshToken", where, }), @@ -145,7 +149,7 @@ async function revokeAndPlanBackchannelLogout( for (const t of refreshTokens) affectedClientIds.add(t.clientId); if (affectedClientIds.size === 0) return null; - const clients = await ctx.context.adapter.findMany>({ + const clients = await adapter.findMany>({ model: "oauthClient", where: [ { @@ -159,7 +163,6 @@ async function revokeAndPlanBackchannelLogout( // Access tokens are always revoked (OP hardening). Refresh tokens follow // ยง2.7: revoke unless `offline_access` was granted. The non-offline_access // branch is reachable via refresh-token scope narrowing, so it must stay. - const revokedAt = new Date(); const accessToRevokeIds = accessTokens .filter((t) => !t.revoked) .map((t) => t.id); @@ -167,57 +170,87 @@ async function revokeAndPlanBackchannelLogout( .filter((t) => !t.revoked && !t.scopes?.includes("offline_access")) .map((t) => t.id); - const revocations = await Promise.allSettled([ - accessToRevokeIds.length > 0 - ? ctx.context.adapter.updateMany({ - model: "oauthAccessToken", - where: [{ field: "id", operator: "in", value: accessToRevokeIds }], - update: { revoked: revokedAt }, - }) - : Promise.resolve(), - refreshToRevokeIds.length > 0 - ? ctx.context.adapter.updateMany({ - model: "oauthRefreshToken", - where: [{ field: "id", operator: "in", value: refreshToRevokeIds }], - update: { revoked: revokedAt }, - }) - : Promise.resolve(), - ]); - // Surface a failed revocation write at error level. Dispatch still - // proceeds (RP notification is independent and the session-liveness - // checks in introspection remain authoritative), but operators need a - // signal that the stored `revoked` backstop drifted from session state. - for (const result of revocations) { - if (result.status === "rejected") { - logger.error( - "back-channel logout: token revocation update failed", - result.reason, - ); - } - } - // Logout Tokens are signed through the JWT plugin's JWKS, so skip the // delivery plan when it is disabled. Registration already rejects // `backchannel_logout_uri` in that mode; this also guards stale clients. const eligibleClients = opts.disableJwtPlugin ? [] : clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled); - if (eligibleClients.length === 0) return null; - const targets: BackchannelLogoutTarget[] = await Promise.all( - eligibleClients.map(async (client) => ({ - client, - sub: await resolveSubjectIdentifier(userId, client, opts), - })), - ); + const targets = ( + await Promise.all( + eligibleClients.map(async (client) => { + try { + return { + client, + sub: await resolveSubjectIdentifier(userId, client, opts), + } satisfies BackchannelLogoutTarget; + } catch (error) { + logger.warn( + `back-channel logout: unable to resolve subject for client ${client.clientId}`, + error, + ); + return null; + } + }), + ) + ).filter((target): target is BackchannelLogoutTarget => target !== null); - return { sessionId, targets }; + return { + accessTokenIds: accessToRevokeIds, + refreshTokenIds: refreshToRevokeIds, + sessionId, + targets, + }; } catch (error) { - logger.error("back-channel logout revocation failed", error); + logger.error("back-channel logout planning failed", error); return null; } } +/** + * Apply a prepared logout plan after the session deletion succeeds. Core runs + * `session.delete.after` after the row is consumed and, for transactional + * callers, only after the transaction commits. + */ +async function applyBackchannelLogoutPlan( + ctx: GenericEndpointContext, + plan: BackchannelLogoutPlan, +): Promise { + const revokedAt = new Date(); + const adapter = await getCurrentAdapter(ctx.context.adapter); + const revocations = await Promise.allSettled([ + plan.accessTokenIds.length > 0 + ? adapter.updateMany({ + model: "oauthAccessToken", + where: [{ field: "id", operator: "in", value: plan.accessTokenIds }], + update: { revoked: revokedAt }, + }) + : Promise.resolve(), + plan.refreshTokenIds.length > 0 + ? adapter.updateMany({ + model: "oauthRefreshToken", + where: [{ field: "id", operator: "in", value: plan.refreshTokenIds }], + update: { revoked: revokedAt }, + }) + : Promise.resolve(), + ]); + // Session liveness remains authoritative, but operators still need a signal + // if the stored revocation backstop drifts from the committed session state. + for (const result of revocations) { + if (result.status === "rejected") { + ctx.context.logger.error( + "back-channel logout: token revocation update failed", + result.reason, + ); + } + } + + if (plan.targets.length > 0) { + await deliverBackchannelLogoutTokens(ctx, plan); + } +} + /** * Asynchronous phase: sign one Logout Token per target client and POST it to * the registered `backchannel_logout_uri`. The caller hands this to @@ -290,7 +323,7 @@ async function deliverBackchannelLogoutTokens( ); } -export { deliverBackchannelLogoutTokens, revokeAndPlanBackchannelLogout }; +export { applyBackchannelLogoutPlan, prepareBackchannelLogoutPlan }; /** * RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed @@ -298,7 +331,7 @@ export { deliverBackchannelLogoutTokens, revokeAndPlanBackchannelLogout }; * and optionally redirects to `post_logout_redirect_uri`. * * Session termination goes through `internalAdapter.deleteSession`, which fires - * `session.delete.before` so the hook drives revocation and back-channel + * `session.delete.after` so the hook drives revocation and back-channel * notifications to every RP with tokens on the session. * * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index d18f8d774b..ec90b9b2e3 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -23,9 +23,10 @@ import { consentEndpoint } from "./consent"; import { continueEndpoint } from "./continue"; import { validateOAuthProviderExtensions } from "./extensions"; import { introspectEndpoint } from "./introspect"; +import type { BackchannelLogoutPlan } from "./logout"; import { - deliverBackchannelLogoutTokens, - revokeAndPlanBackchannelLogout, + applyBackchannelLogoutPlan, + prepareBackchannelLogoutPlan, rpInitiatedLogoutEndpoint, } from "./logout"; import { @@ -93,6 +94,10 @@ export const oAuthState = defineRequestState<{ postLoginClearedForSession?: string; } | null>(() => null); export const getOAuthProviderState = oAuthState.get; +const backchannelLogoutPlansByContext = new WeakMap< + GenericEndpointContext, + Map +>(); const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs"; function getServerContextSignedQueryIssuedAt(value: unknown) { @@ -523,12 +528,8 @@ export const oauthProvider = >(options: O) => { } // The hook must register for every configuration path (including - // dynamic baseURL). Revocation runs inline because it mutates DB - // state we rely on. The HTTP fan-out goes through - // `runInBackgroundOrAwait`: with a background handler configured - // (Vercel `waitUntil`, CF `ctx.waitUntil`) it runs after the - // response; without one it is awaited inline so delivery is not lost - // on request teardown. Awaiting here keeps both paths reliable. + // dynamic baseURL). Core runs delete.after only after the session row is + // consumed and queues it until the surrounding transaction commits. return { options: { databaseHooks: { @@ -536,7 +537,7 @@ export const oauthProvider = >(options: O) => { delete: { async before(session, hookCtx) { if (!hookCtx) return; - const plan = await revokeAndPlanBackchannelLogout( + const plan = await prepareBackchannelLogoutPlan( hookCtx, opts, { @@ -545,15 +546,37 @@ export const oauthProvider = >(options: O) => { }, ); if (!plan) return; - // TODO: re-evaluate this await. It makes delivery reliable on - // every runtime, but without an `advanced.backgroundTasks.handler` - // a hung RP can add up to the per-RP timeout to sign-out latency. - // Alternative to weigh: keep delivery non-blocking and instead - // hard-require a background handler when back-channel logout is on. - await hookCtx.context.runInBackgroundOrAwait( - deliverBackchannelLogoutTokens(hookCtx, plan), + const logoutPlanBySessionId = + backchannelLogoutPlansByContext.get(hookCtx) ?? + new Map(); + logoutPlanBySessionId.set(session.id, plan); + backchannelLogoutPlansByContext.set( + hookCtx, + logoutPlanBySessionId, ); }, + async after(session, hookCtx) { + if (!hookCtx) return; + const logoutPlanBySessionId = + backchannelLogoutPlansByContext.get(hookCtx); + if (!logoutPlanBySessionId) return; + const plan = logoutPlanBySessionId.get(session.id); + logoutPlanBySessionId.delete(session.id); + if (logoutPlanBySessionId.size === 0) { + backchannelLogoutPlansByContext.delete(hookCtx); + } + if (!plan) return; + const logoutTask = applyBackchannelLogoutPlan( + hookCtx, + plan, + ).catch((error) => { + hookCtx.context.logger.error( + "Back-channel logout failed after session deletion", + error, + ); + }); + await hookCtx.context.runInBackgroundOrAwait(logoutTask); + }, }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b40a6403af..9aaf2ff1c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1472,6 +1472,9 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core + '@better-auth/memory-adapter': + specifier: workspace:* + version: link:../memory-adapter better-auth: specifier: workspace:* version: link:../better-auth