diff --git a/README.md b/README.md index 65f9c02..19d7ea6 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ configurations. // server.ts import { createGitHubOAuthConfig, - getSessionId, + getSessionData, handleCallback, signIn, signOut, @@ -67,18 +67,27 @@ configurations. const oauthConfig = createGitHubOAuthConfig(); + async function getGitHubUser(accessToken: string) { + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `bearer ${accessToken}`, + }, + }); + if (!response.ok) throw new Error("Failed to fetch GitHub user profile"); + return await response.json(); + } + async function handler(request: Request) { const { pathname } = new URL(request.url); switch (pathname) { case "/oauth/signin": return await signIn(request, oauthConfig); case "/oauth/callback": - const { response } = await handleCallback(request, oauthConfig); - return response; + return await handleCallback(request, oauthConfig, getGitHubUser); case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionId(request) === undefined + return await getSessionData(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -110,7 +119,7 @@ configurations. // server.ts import { getRequiredEnv, - getSessionId, + getSessionData, handleCallback, type OAuth2ClientConfig, signIn, @@ -125,18 +134,27 @@ configurations. redirectUri: "https://my-site.com/another-dir/callback", }; + async function getGitHubUser(accessToken: string) { + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `bearer ${accessToken}`, + }, + }); + if (!response.ok) throw new Error("Failed to fetch GitHub user profile"); + return await response.json(); + } + async function handler(request: Request) { const { pathname } = new URL(request.url); switch (pathname) { case "/oauth/signin": return await signIn(request, oauthConfig); case "/another-dir/callback": - const { response } = await handleCallback(request, oauthConfig); - return response; + return await handleCallback(request, oauthConfig, getGitHubUser); case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionId(request) === undefined + return await getSessionData(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -174,7 +192,7 @@ This is required for OAuth solutions that span more than one sub-domain. signIn, handleCallback, signOut, - getSessionId, + getSessionData, } = createHelpers(createGitHubOAuthConfig(), { cookieOptions: { name: "__Secure-triple-choc", @@ -182,18 +200,27 @@ This is required for OAuth solutions that span more than one sub-domain. }, }); + async function getGitHubUser(accessToken: string) { + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `bearer ${accessToken}`, + }, + }); + if (!response.ok) throw new Error("Failed to fetch GitHub user profile"); + return await response.json(); + } + async function handler(request: Request) { const { pathname } = new URL(request.url); switch (pathname) { case "/oauth/signin": return await signIn(request); case "/oauth/callback": - const { response } = await handleCallback(request); - return response; + return await handleCallback(request, getGitHubUser); case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionId(request) === undefined + return await getSessionData(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -225,10 +252,20 @@ This is required for OAuth solutions that span more than one sub-domain. } from "https://deno.land/x/deno_kv_oauth/mod.ts"; import type { Plugin } from "$fresh/server.ts"; - const { signIn, handleCallback, signOut, getSessionId } = createHelpers( + const { signIn, handleCallback, signOut, getSessionData } = createHelpers( createGitHubOAuthConfig(), ); + async function getGitHubUser(accessToken: string) { + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `bearer ${accessToken}`, + }, + }); + if (!response.ok) throw new Error("Failed to fetch GitHub user profile"); + return await response.json(); + } + export default { name: "kv-oauth", routes: [ @@ -241,9 +278,7 @@ This is required for OAuth solutions that span more than one sub-domain. { path: "/callback", async handler(req) { - // Return object also includes `accessToken` and `sessionId` properties. - const { response } = await handleCallback(req); - return response; + return await handleCallback(req, getGitHubUser); }, }, { @@ -255,7 +290,7 @@ This is required for OAuth solutions that span more than one sub-domain. { path: "/protected", async handler(req) { - return await getSessionId(req) === undefined + return await getSessionData(req) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); }, diff --git a/demo.ts b/demo.ts index 846fc9d..df06b39 100644 --- a/demo.ts +++ b/demo.ts @@ -1,12 +1,6 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. import { STATUS_CODE } from "./deps.ts"; -import { - createGitHubOAuthConfig, - getSessionId, - handleCallback, - signIn, - signOut, -} from "./mod.ts"; +import { createGitHubOAuthConfig, createHelpers } from "./mod.ts"; /** * Modify the OAuth configuration creation function when testing for providers. @@ -19,16 +13,22 @@ import { * ``` */ const oauthConfig = createGitHubOAuthConfig(); +const { getSessionData, handleCallback, signIn, signOut } = createHelpers< + GitHubUser +>( + oauthConfig, +); async function indexHandler(request: Request) { - const sessionId = await getSessionId(request); - const hasSessionIdCookie = sessionId !== undefined; + const sessionData = await getSessionData(request); + const hasSessionIdCookie = sessionData !== null; const body = `

Authorization endpoint URI: ${oauthConfig.authorizationEndpointUri}

Token URI: ${oauthConfig.tokenUri}

Scope: ${oauthConfig.defaults?.scope}

Signed in: ${hasSessionIdCookie}

+
${JSON.stringify(sessionData, null, 2)}

Sign in

@@ -45,6 +45,29 @@ async function indexHandler(request: Request) { }); } +interface GitHubUser { + login: string; + avatarUrl: string; +} + +async function getGitHubUser(accessToken: string) { + const response = await fetch("https://api.github.com/user", { + headers: { + authorization: `token ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to get GitHub user"); + } + + const data = await response.json(); + return { + login: data.login, + avatarUrl: data.avatar_url, + } as GitHubUser; +} + export async function handler(request: Request): Promise { if (request.method !== "GET") { return new Response(null, { status: STATUS_CODE.NotFound }); @@ -55,12 +78,14 @@ export async function handler(request: Request): Promise { return await indexHandler(request); } case "/signin": { - return await signIn(request, oauthConfig); + return await signIn(request); } case "/callback": { try { - const { response } = await handleCallback(request, oauthConfig); - return response; + return await handleCallback( + request, + getGitHubUser, + ); } catch { return new Response(null, { status: STATUS_CODE.InternalServerError }); } diff --git a/lib/_kv.ts b/lib/_kv.ts index c73cf57..a689562 100644 --- a/lib/_kv.ts +++ b/lib/_kv.ts @@ -56,24 +56,21 @@ export async function setOAuthSession( await kv.set(oauthSessionKey(id), value, options); } -/** - * The site session is created on the server. It is stored in the database to - * later validate that a session was created on the server. It has no purpose - * beyond that. Hence, the value of the site session entry is arbitrary. - */ -type SiteSession = true; - function siteSessionKey(id: string): [string, string] { return ["site_sessions", id]; } -export async function isSiteSession(id: string): Promise { - const res = await kv.get(siteSessionKey(id)); - return res.value !== null; +export async function getSiteSession(id: string): Promise { + const res = await kv.get(siteSessionKey(id)); + return res.value; } -export async function setSiteSession(id: string, expireIn?: number) { - await kv.set(siteSessionKey(id), true, { expireIn }); +export async function setSiteSession( + id: string, + value?: unknown, + expireIn?: number, +) { + await kv.set(siteSessionKey(id), value, { expireIn }); } export async function deleteSiteSession(id: string) { diff --git a/lib/_kv_test.ts b/lib/_kv_test.ts index dfc4ca9..00232cb 100644 --- a/lib/_kv_test.ts +++ b/lib/_kv_test.ts @@ -1,6 +1,12 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. import { assertEquals, assertRejects } from "std/assert/mod.ts"; -import { getAndDeleteOAuthSession, setOAuthSession } from "./_kv.ts"; +import { + deleteSiteSession, + getAndDeleteOAuthSession, + getSiteSession, + setOAuthSession, + setSiteSession, +} from "./_kv.ts"; import { randomOAuthSession } from "./_test_utils.ts"; Deno.test("(getAndDelete/set)OAuthSession()", async () => { @@ -22,3 +28,16 @@ Deno.test("(getAndDelete/set)OAuthSession()", async () => { "OAuth session not found", ); }); + +Deno.test("(get/set/delete)SiteSession()", async () => { + const id = crypto.randomUUID(); + + assertEquals(await getSiteSession(id), null); + + const siteSession = { foo: "bar" }; + await setSiteSession(id, siteSession); + assertEquals(await getSiteSession(id), siteSession); + + await deleteSiteSession(id); + assertEquals(await getSiteSession(id), null); +}); diff --git a/lib/create_helpers.ts b/lib/create_helpers.ts index 24395ff..30ac558 100644 --- a/lib/create_helpers.ts +++ b/lib/create_helpers.ts @@ -1,7 +1,7 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. -import { type Cookie, OAuth2ClientConfig, type Tokens } from "../deps.ts"; -import { getSessionId } from "./get_session_id.ts"; +import { type Cookie, OAuth2ClientConfig } from "../deps.ts"; +import { getSessionData } from "./get_session_data.ts"; import { handleCallback } from "./handle_callback.ts"; import { signIn, type SignInOptions } from "./sign_in.ts"; import { signOut } from "./sign_out.ts"; @@ -31,7 +31,7 @@ export interface CreateHelpersOptions { * signIn, * handleCallback, * signOut, - * getSessionId, + * getSessionData, * } = createHelpers(createGitHubOAuthConfig(), { * cookieOptions: { * name: "__Secure-triple-choc", @@ -39,18 +39,27 @@ export interface CreateHelpersOptions { * }, * }); * + * async function getGitHubUser(accessToken: string) { + * const response = await fetch("https://api.github.com/user", { + * headers: { + * Authorization: `bearer ${accessToken}`, + * }, + * }); + * if (!response.ok) throw new Error("Failed to fetch GitHub user profile"); + * return await response.json(); + * } + * * async function handler(request: Request) { * const { pathname } = new URL(request.url); * switch (pathname) { * case "/oauth/signin": * return await signIn(request); * case "/oauth/callback": - * const { response } = await handleCallback(request); - * return response; + * return await handleCallback(request, getGitHubUser); * case "/oauth/signout": * return await signOut(request); * case "/protected-route": - * return await getSessionId(request) === undefined + * return await getSessionData(request) === null * ? new Response("Unauthorized", { status: 401 }) * : new Response("You are allowed"); * default: @@ -61,33 +70,36 @@ export interface CreateHelpersOptions { * Deno.serve(handler); * ``` */ -export function createHelpers( +// deno-lint-ignore ban-types +export function createHelpers = {}>( oauthConfig: OAuth2ClientConfig, options?: CreateHelpersOptions, ): { signIn(request: Request, options?: SignInOptions): Promise; - handleCallback(request: Request): Promise<{ - response: Response; - sessionId: string; - tokens: Tokens; - }>; + handleCallback( + request: Request, + tokenHandler: (accessToken: string) => T | Promise, + ): Promise; signOut(request: Request): Promise; - getSessionId(request: Request): Promise; + getSessionData(request: Request): Promise; } { return { async signIn(request: Request, options?: SignInOptions) { return await signIn(request, oauthConfig, options); }, - async handleCallback(request: Request) { - return await handleCallback(request, oauthConfig, { + async handleCallback( + request: Request, + tokenHandler: (accessToken: string) => T | Promise, + ) { + return await handleCallback(request, oauthConfig, tokenHandler, { cookieOptions: options?.cookieOptions, }); }, async signOut(request: Request) { return await signOut(request, { cookieOptions: options?.cookieOptions }); }, - async getSessionId(request: Request) { - return await getSessionId(request, { + async getSessionData(request: Request) { + return await getSessionData(request, { cookieName: options?.cookieOptions?.name, }); }, diff --git a/lib/get_session_data.ts b/lib/get_session_data.ts new file mode 100644 index 0000000..d75cbf2 --- /dev/null +++ b/lib/get_session_data.ts @@ -0,0 +1,42 @@ +// Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. +import { getSessionIdCookie } from "./_http.ts"; +import { getSiteSession } from "./_kv.ts"; + +/** Options for {@linkcode getSessionData}. */ +export interface GetSessionDataOptions { + /** + * The name of the cookie in the request. This must match the cookie name + * used in {@linkcode handleCallback} and {@linkcode signOut}. + */ + cookieName?: string; +} + +/** + * Gets the session ID from the cookie header of a request. This can be used to + * check whether the client is signed-in and whether the session ID was created + * on the server by checking if the return value is defined. + * + * @returns The session data object returned from {@linkcode sessionDataGetter} + * in {@linkcode handleCallback} or `null` if the session cookie either doesn't + * exist or the session entry doesn't exist in the database. + * + * @example + * ```ts + * import { getSessionData } from "https://deno.land/x/deno_kv_oauth/mod.ts"; + * + * export async function handler(request: Request) { + * const sessionData = await getSessionData(request); + * return sessionData === null + * ? new Response("Unauthorized", { status: 401 }) + * : Response.json(sessionData); + * } + * ``` + */ +export async function getSessionData( + request: Request, + options?: GetSessionDataOptions, +): Promise { + const sessionId = getSessionIdCookie(request, options?.cookieName); + if (sessionId === undefined) return null; + return await getSiteSession(sessionId); +} diff --git a/lib/get_session_id.ts b/lib/get_session_id.ts deleted file mode 100644 index 6eed9aa..0000000 --- a/lib/get_session_id.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. -import { getSessionIdCookie } from "./_http.ts"; -import { isSiteSession } from "./_kv.ts"; - -/** Options for {@linkcode getSessionId}. */ -export interface GetSessionIdOptions { - /** - * The name of the cookie in the request. This must match the cookie name - * used in {@linkcode handleCallback} and {@linkcode signOut}. - */ - cookieName?: string; -} - -/** - * Gets the session ID from the cookie header of a request. This can be used to - * check whether the client is signed-in and whether the session ID was created - * on the server by checking if the return value is defined. - * - * @example - * ```ts - * import { getSessionId } from "https://deno.land/x/deno_kv_oauth/mod.ts"; - * - * export async function handler(request: Request) { - * const sessionId = await getSessionId(request); - * const hasSessionIdCookie = sessionId !== undefined; - * - * return Response.json({ sessionId, hasSessionIdCookie }); - * } - * ``` - */ -export async function getSessionId( - request: Request, - options?: GetSessionIdOptions, -): Promise { - const sessionId = getSessionIdCookie(request, options?.cookieName); - return (sessionId !== undefined && await isSiteSession(sessionId)) - ? sessionId - : undefined; -} diff --git a/lib/get_session_id_test.ts b/lib/get_session_id_test.ts deleted file mode 100644 index bd8051f..0000000 --- a/lib/get_session_id_test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. -import { assertEquals } from "std/assert/assert_equals.ts"; -import { SITE_COOKIE_NAME } from "./_http.ts"; -import { getSessionId } from "./get_session_id.ts"; -import { setSiteSession } from "./_kv.ts"; - -Deno.test("getSessionId() returns undefined when cookie is not defined", async () => { - const request = new Request("http://example.com"); - - assertEquals(await getSessionId(request), undefined); -}); - -Deno.test("getSessionId() returns valid session ID", async () => { - const sessionId = crypto.randomUUID(); - await setSiteSession(sessionId); - const request = new Request("http://example.com", { - headers: { - cookie: `${SITE_COOKIE_NAME}=${sessionId}`, - }, - }); - - assertEquals(await getSessionId(request), sessionId); -}); - -Deno.test("getSessionId() returns valid session ID when cookie name is defined", async () => { - const sessionId = crypto.randomUUID(); - await setSiteSession(sessionId); - const cookieName = "triple-choc"; - const request = new Request("http://example.com", { - headers: { - cookie: `${cookieName}=${sessionId}`, - }, - }); - - assertEquals(await getSessionId(request, { cookieName }), sessionId); -}); diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index 3effc2c..19b8517 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -6,7 +6,6 @@ import { type OAuth2ClientConfig, SECOND, setCookie, - Tokens, } from "../deps.ts"; import { COOKIE_BASE, @@ -19,8 +18,9 @@ import { import { getAndDeleteOAuthSession, setSiteSession } from "./_kv.ts"; /** Options for {@linkcode handleCallback}. */ -export interface HandleCallbackOptions { - /** Overwrites cookie properties set in the response. These must match the +export interface HandleCallbackOptions { + /** + * Overwrites cookie properties set in the response. These must match the * cookie properties used in {@linkcode getSessionId} and * {@linkcode signOut}. */ @@ -32,33 +32,45 @@ export interface HandleCallbackOptions { * then redirects the client to the success URL set in {@linkcode signIn}. The * request URL must match the redirect URL of the OAuth application. * + * @param tokenCallback Function that uses the access token to get the session + * object. This is used for fetching the user's profile from the OAuth + * provider. An {@linkcode Error} will be thrown if this function resolves to a + * `null` or `undefined` value. This is because `null` represents a non-existent + * session object and `undefined` is too similar, and may be confusing, from + * {@linkcode getSessionData}. + * * @example * ```ts * import { handleCallback, createGitHubOAuthConfig } from "https://deno.land/x/deno_kv_oauth/mod.ts"; * * const oauthConfig = createGitHubOAuthConfig(); * + * async function getGitHubUser(accessToken: string) { + * const response = await fetch("https://api.github.com/user", { + * headers: { + * Authorization: `bearer ${accessToken}`, + * }, + * }); + * if (!response.ok) throw new Error("Failed to fetch GitHub user profile"); + * return await response.json(); + * } + * * export async function handleOAuthCallback(request: Request) { - * const { response, tokens, sessionId } = await handleCallback( + * return await handleCallback( * request, * oauthConfig, - * ); - * - * // Perform some actions with the `tokens` and `sessionId`. - * - * return response; + * getGitHubUser, + * ); * } * ``` */ -export async function handleCallback( +// deno-lint-ignore ban-types +export async function handleCallback = {}>( request: Request, oauthConfig: OAuth2ClientConfig, - options?: HandleCallbackOptions, -): Promise<{ - response: Response; - sessionId: string; - tokens: Tokens; -}> { + tokenCallback: (accessToken: string) => T | Promise, + options?: HandleCallbackOptions, +): Promise { const oauthCookieName = getCookieName( OAUTH_COOKIE_NAME, isHttps(request.url), @@ -80,14 +92,17 @@ export async function handleCallback( ...options?.cookieOptions, }; setCookie(response.headers, cookie); + + const sessionData = await tokenCallback(tokens.accessToken); + if (sessionData === null || sessionData === undefined) { + throw new Error("tokenCallback() must resolve to a non-nullable value"); + } + await setSiteSession( sessionId, + sessionData, cookie.maxAge ? cookie.maxAge * SECOND : undefined, ); - return { - response, - sessionId, - tokens, - }; + return response; } diff --git a/lib/handle_callback_test.ts b/lib/handle_callback_test.ts index 3db7f5e..c16d191 100644 --- a/lib/handle_callback_test.ts +++ b/lib/handle_callback_test.ts @@ -1,8 +1,18 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. import { handleCallback } from "./handle_callback.ts"; -import { assertEquals, assertRejects } from "std/assert/mod.ts"; +import { + assert, + assertEquals, + assertNotEquals, + assertRejects, +} from "std/assert/mod.ts"; +import { getSetCookies } from "std/http/cookie.ts"; import { returnsNext, stub } from "std/testing/mock.ts"; -import { getAndDeleteOAuthSession, setOAuthSession } from "./_kv.ts"; +import { + getAndDeleteOAuthSession, + getSiteSession, + setOAuthSession, +} from "./_kv.ts"; import { OAUTH_COOKIE_NAME } from "./_http.ts"; import { assertRedirect, @@ -15,7 +25,7 @@ import type { Cookie } from "../deps.ts"; Deno.test("handleCallback() rejects for no OAuth cookie", async () => { const request = new Request("http://example.com"); await assertRejects( - async () => await handleCallback(request, randomOAuthConfig()), + async () => await handleCallback(request, randomOAuthConfig(), () => true), Error, "OAuth cookie not found", ); @@ -26,7 +36,18 @@ Deno.test("handleCallback() rejects for non-existent OAuth session", async () => headers: { cookie: `${OAUTH_COOKIE_NAME}=xxx` }, }); await assertRejects( - async () => await handleCallback(request, randomOAuthConfig()), + async () => await handleCallback(request, randomOAuthConfig(), () => true), + Deno.errors.NotFound, + "OAuth session not found", + ); +}); + +Deno.test("handleCallback() rejects for non-existent OAuth session", async () => { + const request = new Request("http://example.com", { + headers: { cookie: `${OAUTH_COOKIE_NAME}=xxx` }, + }); + await assertRejects( + async () => await handleCallback(request, randomOAuthConfig(), () => true), Deno.errors.NotFound, "OAuth session not found", ); @@ -39,7 +60,9 @@ Deno.test("handleCallback() deletes the OAuth session KV entry", async () => { const request = new Request("http://example.com", { headers: { cookie: `${OAUTH_COOKIE_NAME}=${oauthSessionId}` }, }); - await assertRejects(() => handleCallback(request, randomOAuthConfig())); + await assertRejects(async () => + await handleCallback(request, randomOAuthConfig(), () => true) + ); await assertRejects( async () => await getAndDeleteOAuthSession(oauthSessionId), Deno.errors.NotFound, @@ -67,19 +90,24 @@ Deno.test("handleCallback() correctly handles the callback response", async () = const request = new Request(`http://example.com/callback?${searchParams}`, { headers: { cookie: `${OAUTH_COOKIE_NAME}=${oauthSessionId}` }, }); - const { response, tokens, sessionId } = await handleCallback( + const response = await handleCallback( request, randomOAuthConfig(), + (accessToken) => accessToken, ); fetchStub.restore(); assertRedirect(response, oauthSession.successUrl); - assertEquals( - response.headers.get("set-cookie"), - `site-session=${sessionId}; HttpOnly; Max-Age=7776000; SameSite=Lax; Path=/`, - ); - assertEquals(tokens.accessToken, tokensBody.access_token); - assertEquals(typeof sessionId, "string"); + const [cookie] = getSetCookies(response.headers); + assert(cookie !== undefined); + assertEquals(cookie.name, "site-session"); + assertEquals(cookie.maxAge, 7776000); + assertEquals(cookie.sameSite, "Lax"); + assertEquals(cookie.path, "/"); + assertEquals(await getSiteSession(cookie.value), tokensBody.access_token); + + const sessionId = cookie.value; + assertNotEquals(sessionId, undefined); await assertRejects( async () => await getAndDeleteOAuthSession(oauthSessionId), Deno.errors.NotFound, @@ -112,23 +140,91 @@ Deno.test("handleCallback() correctly handles the callback response with options maxAge: 420, domain: "example.com", }; - const { response, tokens, sessionId } = await handleCallback( + const response = await handleCallback( request, randomOAuthConfig(), + (accessToken) => accessToken, { cookieOptions }, ); fetchStub.restore(); assertRedirect(response, oauthSession.successUrl); - assertEquals( - response.headers.get("set-cookie"), - `${cookieOptions.name}=${sessionId}; HttpOnly; Max-Age=${cookieOptions.maxAge}; Domain=${cookieOptions.domain}; SameSite=Lax; Path=/`, - ); - assertEquals(tokens.accessToken, tokensBody.access_token); - assertEquals(typeof sessionId, "string"); + const [cookie] = getSetCookies(response.headers); + assert(cookie !== undefined); + assertEquals(cookie.name, cookieOptions.name); + assertEquals(cookie.maxAge, cookieOptions.maxAge); + assertEquals(cookie.domain, cookieOptions.domain); + assertEquals(cookie.sameSite, "Lax"); + assertEquals(cookie.path, "/"); + assertEquals(await getSiteSession(cookie.value), tokensBody.access_token); + + const sessionId = cookie.value; + assertNotEquals(sessionId, undefined); await assertRejects( async () => await getAndDeleteOAuthSession(oauthSessionId), Deno.errors.NotFound, "OAuth session not found", ); }); + +Deno.test("handleCallback() throws when `tokenCallback()` resolves to `undefined`", async () => { + const tokensBody = randomTokensBody(); + const fetchStub = stub( + window, + "fetch", + returnsNext([Promise.resolve(Response.json(tokensBody))]), + ); + const oauthSessionId = crypto.randomUUID(); + const oauthSession = randomOAuthSession(); + await setOAuthSession(oauthSessionId, oauthSession, { expireIn: 1_000 }); + const searchParams = new URLSearchParams({ + "response_type": "code", + "client_id": "clientId", + "code_challenge_method": "S256", + code: "code", + state: oauthSession.state, + }); + const request = new Request(`http://example.com/callback?${searchParams}`, { + headers: { cookie: `${OAUTH_COOKIE_NAME}=${oauthSessionId}` }, + }); + + await assertRejects( + async () => + // @ts-ignore Just for the test + await handleCallback(request, randomOAuthConfig(), () => undefined), + Error, + "tokenCallback() must resolve to a non-nullable value", + ); + fetchStub.restore(); +}); + +Deno.test("handleCallback() throws when `tokenCallback()` resolves to `null`", async () => { + const tokensBody = randomTokensBody(); + const fetchStub = stub( + window, + "fetch", + returnsNext([Promise.resolve(Response.json(tokensBody))]), + ); + const oauthSessionId = crypto.randomUUID(); + const oauthSession = randomOAuthSession(); + await setOAuthSession(oauthSessionId, oauthSession, { expireIn: 1_000 }); + const searchParams = new URLSearchParams({ + "response_type": "code", + "client_id": "clientId", + "code_challenge_method": "S256", + code: "code", + state: oauthSession.state, + }); + const request = new Request(`http://example.com/callback?${searchParams}`, { + headers: { cookie: `${OAUTH_COOKIE_NAME}=${oauthSessionId}` }, + }); + + await assertRejects( + async () => + // @ts-ignore Just for the test + await handleCallback(request, randomOAuthConfig(), () => null), + Error, + "tokenCallback() must resolve to a non-nullable value", + ); + fetchStub.restore(); +}); diff --git a/lib/sign_out_test.ts b/lib/sign_out_test.ts index 7183903..c077f05 100644 --- a/lib/sign_out_test.ts +++ b/lib/sign_out_test.ts @@ -14,7 +14,7 @@ Deno.test("signOut() returns a redirect response if the user is not signed-in", Deno.test("signOut() returns a response that signs out the signed-in user", async () => { const sessionId = crypto.randomUUID(); - await setSiteSession(sessionId); + await setSiteSession(sessionId, undefined); const request = new Request("http://example.com/signout", { headers: { cookie: `${SITE_COOKIE_NAME}=${sessionId}`, @@ -36,7 +36,7 @@ Deno.test("signOut() returns a response that signs out the signed-in user with c path: "/path", }; const sessionId = crypto.randomUUID(); - await setSiteSession(sessionId); + await setSiteSession(sessionId, undefined); const request = new Request("http://example.com/signout", { headers: { cookie: `${cookieOptions.name}=${sessionId}`, diff --git a/lib/types.ts b/lib/types.ts index d602759..92932e2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,2 +1,2 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. -export type { Cookie, OAuth2ClientConfig, Tokens } from "../deps.ts"; +export type { Cookie, OAuth2ClientConfig } from "../deps.ts"; diff --git a/mod.ts b/mod.ts index 29c24b6..ddc7909 100644 --- a/mod.ts +++ b/mod.ts @@ -1,8 +1,5 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. -export * from "./lib/handle_callback.ts"; -export * from "./lib/get_session_id.ts"; -export * from "./lib/sign_in.ts"; -export * from "./lib/sign_out.ts"; + export * from "./lib/create_auth0_oauth_config.ts"; export * from "./lib/create_azure_ad_oauth_config.ts"; export * from "./lib/create_azure_adb2c_oauth_config.ts"; @@ -20,4 +17,8 @@ export * from "./lib/create_spotify_oauth_config.ts"; export * from "./lib/create_twitter_oauth_config.ts"; export * from "./lib/create_helpers.ts"; export * from "./lib/get_required_env.ts"; +export * from "./lib/handle_callback.ts"; +export * from "./lib/sign_in.ts"; +export * from "./lib/sign_out.ts"; +export * from "./lib/get_session_data.ts"; export * from "./lib/types.ts";