diff --git a/packages/auth/src/runtime.ts b/packages/auth/src/runtime.ts index 0f31a6f..e7bc869 100644 --- a/packages/auth/src/runtime.ts +++ b/packages/auth/src/runtime.ts @@ -882,6 +882,13 @@ function buildLogoutCookies( return Object.freeze([...new Set(cookies)]) } +function forgetDefaultRememberCookie(bindings: RuntimeBindings): string | undefined { + const rememberCookie = parseSetCookieDefinition(bindings.session.rememberMeCookie('')) + return rememberCookie + ? forgetCookie(bindings, rememberCookie.name, rememberCookie.options) + : undefined +} + async function resolveRequestCookie( bindings: RuntimeBindings, name: string, @@ -1745,6 +1752,7 @@ async function loginForGuard(guardName: string, credentials: AuthCredentials): P } const serialized = serializeUser(adapter, user, guard.provider) + await hydrateGuardContextFromRequest(guardName) return establishSessionForUser(serialized, { guard: guardName, provider: guard.provider, @@ -1971,6 +1979,7 @@ async function loginUsingForGuard( const resolved = await resolveTrustedUserForGuard(guardName, user) const serialized = serializeUser(resolved.adapter, resolved.user, resolved.provider) + await hydrateGuardContextFromRequest(guardName) return establishSessionForUser(serialized, { guard: guardName, provider: resolved.provider, @@ -2346,6 +2355,14 @@ async function establishSessionForUser( && options.preserveRemember && !options.remember ) + const shouldClearRememberCookie = !!( + !options.remember + && !preserveRememberSession + && ( + existingSession?.rememberTokenHash + || bindings.context.getRememberToken?.(options.guard) + ) + ) const session = rotateCurrentGuardSession ? await bindings.session.create({ data: nextSessionData, @@ -2379,6 +2396,9 @@ async function establishSessionForUser( const cookies = [ bindings.session.sessionCookie(session.id), ...(rememberToken ? [bindings.session.rememberMeCookie(rememberToken)] : []), + ...(!rememberToken && shouldClearRememberCookie + ? [forgetDefaultRememberCookie(bindings)].filter((cookie): cookie is string => typeof cookie === 'string') + : []), ] return Object.freeze({ diff --git a/packages/auth/tests/package.test.ts b/packages/auth/tests/package.test.ts index 7a05538..f96ed56 100644 --- a/packages/auth/tests/package.test.ts +++ b/packages/auth/tests/package.test.ts @@ -1151,12 +1151,52 @@ describe('@holo-js/auth package runtime', () => { })) const nextRecord = runtime.sessionStore.records.get(loggedInAgain.sessionId) - expect(loggedInAgain.cookies).toHaveLength(1) + expect(loggedInAgain.cookies).toHaveLength(2) expect(loggedInAgain.rememberToken).toBeUndefined() + expect(loggedInAgain.cookies).toContainEqual(expect.stringContaining('holo_session_remember=;')) expect(runtime.context.getRememberToken?.('web')).toBeUndefined() expect(nextRecord?.rememberTokenHash).toBeUndefined() }) + it('hydrates remember-me cookies before normal login and clears remembered state when opting out', async () => { + const runtime = configureRuntime() + const hasher = authRuntimeInternals.createDefaultPasswordHasher() + await runtime.usersProvider.create({ + name: 'Ava', + email: 'ava@example.com', + password: await hasher.hash('secret-secret'), + email_verified_at: new Date(), + }) + + const remembered = unwrapAuthResult(await login({ + email: 'ava@example.com', + password: 'secret-secret', + remember: true, + })) + expect(remembered.rememberToken).toBeTypeOf('string') + + const currentBindings = authRuntimeInternals.getRuntimeBindings() + configureAuthRuntime({ + ...currentBindings, + context: { + ...authRuntimeInternals.createMemoryAuthContext(), + getRequestCookie(name: string) { + return name === 'holo_session_remember' ? remembered.rememberToken : undefined + }, + }, + }) + + const loggedInAgain = unwrapAuthResult(await login({ + email: 'ava@example.com', + password: 'secret-secret', + })) + + expect(runtime.sessionStore.records.has(remembered.sessionId)).toBe(false) + expect(runtime.sessionStore.records.get(loggedInAgain.sessionId)?.rememberTokenHash).toBeUndefined() + expect(loggedInAgain.rememberToken).toBeUndefined() + expect(loggedInAgain.cookies).toContainEqual(expect.stringContaining('holo_session_remember=;')) + }) + it('supports impersonation across guards and removes the impersonated guard on stop', async () => { const runtime = configureRuntime() const admin = await runtime.adminsProvider.create({ diff --git a/tests/example-app-auth-flow.mjs b/tests/example-app-auth-flow.mjs index bee96bc..8ba94ab 100644 --- a/tests/example-app-auth-flow.mjs +++ b/tests/example-app-auth-flow.mjs @@ -45,6 +45,13 @@ function createCookieJar() { header() { return [...cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ') }, + headerExcept(excludedNames) { + const excluded = new Set(excludedNames) + return [...cookies.entries()] + .filter(([name]) => !excluded.has(name)) + .map(([name, value]) => `${name}=${value}`) + .join('; ') + }, } } @@ -345,6 +352,60 @@ export async function assertExampleAppAuthFlow({ assert.ok(authenticatedSessionCookie.length > 0) assert.match(authenticatedSessionCookie, new RegExp(`(?:^|;\\s*)${escapeRegExp(sessionCookieName)}=`)) + const rememberCookieName = `${sessionCookieName}_remember` + const rememberedJar = createCookieJar() + const rememberedLogin = await fetchAuthJson('/api/login', { + fields: { + email, + password, + remember: true, + }, + headers: { + 'x-forwarded-for': '127.0.0.222', + 'x-real-ip': '127.0.0.222', + }, + jar: rememberedJar, + }) + assert.equal(rememberedLogin.json.ok, true) + assert.equal(rememberedLogin.json.data?.redirectTo, '/admin') + assert.ok(listSetCookieHeaders(rememberedLogin.response).some(cookie => cookie.startsWith(`${rememberCookieName}=`))) + + const rememberOnlyCookie = rememberedJar.headerExcept([sessionCookieName]) + assert.match(rememberOnlyCookie, new RegExp(`(?:^|;\\s*)${escapeRegExp(rememberCookieName)}=`)) + + const rememberedUser = await fetchAuthJson('/api/auth/user', { + headers: { + cookie: rememberOnlyCookie, + }, + }) + assert.equal(rememberedUser.json.authenticated, true) + assert.equal(rememberedUser.json.guard, 'web') + assert.equal(rememberedUser.json.user?.email, email) + + const optOutLogin = await fetchAuthJson('/api/login', { + fields: { + email, + password, + }, + headers: { + 'x-forwarded-for': '127.0.0.223', + 'x-real-ip': '127.0.0.223', + }, + jar: rememberedJar, + }) + assert.equal(optOutLogin.json.ok, true) + assert.ok(listSetCookieHeaders(optOutLogin.response).some(cookie => cookie.startsWith(`${rememberCookieName}=;`))) + assert.doesNotMatch(rememberedJar.header(), new RegExp(`(?:^|;\\s*)${escapeRegExp(rememberCookieName)}=`)) + + const staleRememberUser = await fetchAuthJson('/api/auth/user', { + headers: { + cookie: rememberOnlyCookie, + }, + }) + assert.equal(staleRememberUser.json.authenticated, false) + assert.equal(staleRememberUser.json.guard, 'web') + assert.equal(staleRememberUser.json.user, null) + const loggedOut = await fetchAuthJson('/api/logout', { method: 'POST', jar: authenticatedJar,