Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/auth/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
42 changes: 41 additions & 1 deletion packages/auth/tests/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
61 changes: 61 additions & 0 deletions tests/example-app-auth-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('; ')
},
}
}

Expand Down Expand Up @@ -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,
Expand Down