Repository navigation
test: cover logout edge cases #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| import { test } from "tap"; | ||
| import { Hono } from "hono"; | ||
| import { getTestInstance } from "../helpers/test-instance.js"; | ||
| import { createApp } from "../../src/app/app.js"; | ||
| import { forwardCookies } from "../../src/app/utils/cookies.js"; | ||
| import { authorizeParams } from "../helpers/oauth-flow.js"; | ||
|
|
||
| const SESSION_COOKIE = "better-auth.session_token"; | ||
|
|
||
| const LOGOUT_COOKIES = [ | ||
| "better-auth.dont_remember", | ||
| "better-auth.session_data", | ||
| "better-auth.session_token", | ||
| ]; | ||
|
|
||
| function readSetCookie(headers) { | ||
| if (!headers) return []; | ||
| if (typeof headers.getSetCookie === "function") return headers.getSetCookie(); | ||
| if (typeof headers.get === "function") { | ||
| const value = headers.get("set-cookie"); | ||
| return value ? [value] : []; | ||
| } | ||
| if (Array.isArray(headers)) { | ||
| return headers | ||
| .filter(([name]) => name.toLowerCase() === "set-cookie") | ||
| .map(([, value]) => value); | ||
| } | ||
| return []; | ||
| } | ||
|
|
||
| function cookieNamed(headers, name) { | ||
| return readSetCookie(headers).find((cookie) => cookie.startsWith(`${name}=`)); | ||
| } | ||
|
|
||
| function attributes(cookie) { | ||
| return cookie | ||
| .split(";") | ||
| .slice(1) | ||
| .map((part) => part.trim()); | ||
| } | ||
|
|
||
| function attributeValue(cookie, name) { | ||
| const part = attributes(cookie).find( | ||
| (attribute) => attribute.split("=")[0].toLowerCase() === name.toLowerCase(), | ||
| ); | ||
| return part?.split("=").slice(1).join("="); | ||
| } | ||
|
|
||
| function hasLifetime(cookie) { | ||
| return Boolean( | ||
| attributeValue(cookie, "Max-Age") || attributeValue(cookie, "Expires"), | ||
| ); | ||
| } | ||
|
|
||
| async function magicLinkHeaders(auth, getMagicLinks, email) { | ||
| const callbackURL = "http://localhost:3000/profile"; | ||
| await auth.api.signInMagicLink({ | ||
| body: { email, callbackURL }, | ||
| headers: new Headers(), | ||
| }); | ||
| const magicLink = getMagicLinks().find((link) => link.email === email); | ||
| if (!magicLink) throw new Error(`No magic link found for ${email}`); | ||
|
|
||
| try { | ||
| const result = await auth.api.magicLinkVerify({ | ||
| query: { token: magicLink.token, callbackURL }, | ||
| headers: new Headers(), | ||
| returnHeaders: true, | ||
| }); | ||
| return result.headers; | ||
| } catch (error) { | ||
| if (error.statusCode !== 302) throw error; | ||
| if (!error.headers) { | ||
| throw new Error("magicLinkVerify redirected without headers", { | ||
| cause: error, | ||
| }); | ||
| } | ||
| return error.headers; | ||
| } | ||
| } | ||
|
|
||
| test("session cookie keeps its security attributes on magic-link verification", async (t) => { | ||
| const instance = await getTestInstance(t); | ||
| const headers = await magicLinkHeaders( | ||
| instance.auth, | ||
| instance.getMagicLinks, | ||
| "cookie-attributes@example.com", | ||
| ); | ||
| const cookie = cookieNamed(headers, SESSION_COOKIE); | ||
|
|
||
| t.ok(cookie, "session cookie is set"); | ||
| t.equal(attributeValue(cookie, "HttpOnly"), "", "HttpOnly is set"); | ||
| t.equal(attributeValue(cookie, "SameSite"), "Lax", "SameSite=Lax is set"); | ||
| t.equal(attributeValue(cookie, "Path"), "/", "Path=/ is set"); | ||
| t.ok(hasLifetime(cookie), "session cookie has a lifetime"); | ||
| }); | ||
|
|
||
| test("authorize response sets no cookie", async (t) => { | ||
| const instance = await getTestInstance(t); | ||
| const app = createApp(instance.auth, instance.db); | ||
| const { cookie: sessionCookie } = await instance.getAuthHeaders( | ||
| "authorize-cookie@example.com", | ||
| ); | ||
|
|
||
| // OAuth state travels in a signed query string (sig=), not in a cookie. | ||
| const params = authorizeParams({ state: "authorize-cookie" }); | ||
| const res = await app.request( | ||
| `/api/auth/oauth2/authorize?${params.toString()}`, | ||
| { headers: { cookie: sessionCookie } }, | ||
| ); | ||
|
|
||
| t.equal(res.status, 302, "authorize redirects to the callback"); | ||
| t.same(readSetCookie(res.headers), [], "no cookie is set"); | ||
| }); | ||
|
|
||
| test("logout clears every better-auth cookie", async (t) => { | ||
| const instance = await getTestInstance(t); | ||
| const app = createApp(instance.auth, instance.db); | ||
| const { cookie: sessionCookie } = await instance.getAuthHeaders( | ||
| "logout-cookies@example.com", | ||
| ); | ||
|
|
||
| const res = await app.request("/logout", { | ||
| method: "POST", | ||
| headers: { cookie: sessionCookie }, | ||
| }); | ||
|
|
||
| t.equal(res.status, 302, "redirects after logout"); | ||
| const names = readSetCookie(res.headers) | ||
| .map((cookie) => cookie.split("=")[0]) | ||
| .sort(); | ||
| t.same(names, LOGOUT_COOKIES, "forwards one clearing cookie per cookie"); | ||
| }); | ||
|
|
||
| test("logout clearing cookies keep their attributes", async (t) => { | ||
| const instance = await getTestInstance(t); | ||
| const app = createApp(instance.auth, instance.db); | ||
| const { cookie: sessionCookie } = await instance.getAuthHeaders( | ||
| "logout-attributes@example.com", | ||
| ); | ||
|
|
||
| const res = await app.request("/logout", { | ||
| method: "POST", | ||
| headers: { cookie: sessionCookie }, | ||
| }); | ||
| const cookies = readSetCookie(res.headers); | ||
|
|
||
| t.equal(cookies.length, LOGOUT_COOKIES.length, "every cookie is forwarded"); | ||
| for (const cookie of cookies) { | ||
| const name = cookie.split("=")[0]; | ||
| t.equal(attributeValue(cookie, "Max-Age"), "0", `${name} expires now`); | ||
| t.equal(attributeValue(cookie, "Path"), "/", `${name} keeps Path=/`); | ||
| t.equal(attributeValue(cookie, "HttpOnly"), "", `${name} keeps HttpOnly`); | ||
| t.equal( | ||
| attributeValue(cookie, "SameSite"), | ||
| "Lax", | ||
| `${name} keeps SameSite=Lax`, | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| test("GET /logout clears every better-auth cookie", async (t) => { | ||
| const instance = await getTestInstance(t); | ||
| const app = createApp(instance.auth, instance.db); | ||
| const { cookie: sessionCookie } = await instance.getAuthHeaders( | ||
| "logout-get@example.com", | ||
| ); | ||
|
|
||
| const res = await app.request("/logout", { | ||
| headers: { cookie: sessionCookie }, | ||
| }); | ||
|
|
||
| t.equal(res.status, 302, "redirects after logout"); | ||
| const names = readSetCookie(res.headers) | ||
| .map((cookie) => cookie.split("=")[0]) | ||
| .sort(); | ||
| t.same(names, LOGOUT_COOKIES, "forwards one clearing cookie per cookie"); | ||
| }); | ||
|
|
||
| test("forwardCookies keeps a Set-Cookie already on the response", async (t) => { | ||
| const app = new Hono(); | ||
| app.get("/forward", (c) => { | ||
| c.header("Set-Cookie", "existing=1; Path=/"); | ||
| const headers = new Headers(); | ||
| headers.append("set-cookie", "first=1; Path=/; HttpOnly"); | ||
| headers.append("set-cookie", "second=2; Path=/; HttpOnly"); | ||
| forwardCookies(headers, c); | ||
| return c.body(null); | ||
| }); | ||
|
|
||
| const cookies = readSetCookie((await app.request("/forward")).headers); | ||
|
|
||
| t.equal(cookies.length, 3, "no header is replaced or duplicated"); | ||
| t.match(cookies[0], /^existing=1/, "the existing cookie stays first"); | ||
| t.match(cookies[1], /^first=1/, "the first forwarded cookie follows"); | ||
| t.match(cookies[2], /^second=2/, "the second forwarded cookie follows"); | ||
| }); | ||
|
|
||
| test("logout redirects when signOut returns no headers", async (t) => { | ||
| const instance = await getTestInstance(t); | ||
| const auth = { | ||
| api: { | ||
| getSession: async () => null, | ||
| signOut: async () => ({}), | ||
| }, | ||
| }; | ||
| const app = createApp(auth, instance.db); | ||
|
|
||
| const res = await app.request("/logout"); | ||
|
|
||
| t.equal(res.status, 302, "redirects despite the missing headers"); | ||
| t.same(readSetCookie(res.headers), [], "sets no cookie"); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had a q about this in the other PR, I think if we really have to do this, then we should probably unify this?