From fb8577839e8b8b79075b602781979b8e2bc8c647 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 22 Jan 2024 22:05:49 +1100 Subject: [PATCH 01/14] BREAKING: add `getSessionObject()` --- README.md | 29 ++++----- demo.ts | 9 ++- lib/_kv.ts | 14 +++-- lib/_kv_test.ts | 21 ++++++- lib/create_helpers.ts | 25 +++----- lib/get_session_id_test.ts | 36 ----------- ...et_session_id.ts => get_session_object.ts} | 19 +++--- lib/handle_callback.ts | 61 +++++++++++++------ lib/handle_callback_test.ts | 43 ++++++++----- lib/sign_out_test.ts | 4 +- lib/types.ts | 2 +- mod.ts | 9 +-- 12 files changed, 140 insertions(+), 132 deletions(-) delete mode 100644 lib/get_session_id_test.ts rename lib/{get_session_id.ts => get_session_object.ts} (63%) diff --git a/README.md b/README.md index 3e40f0b..62171bc 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ configurations. // server.ts import { createGitHubOAuthConfig, - getSessionId, + getSessionObject, handleCallback, signIn, signOut, @@ -73,12 +73,11 @@ configurations. case "/oauth/signin": return await signIn(request, oauthConfig); case "/oauth/callback": - const { response } = await handleCallback(request, oauthConfig); - return response; + return await handleCallback(request, oauthConfig); case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionId(request) === undefined + return await getSessionObject(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -110,7 +109,7 @@ configurations. // server.ts import { getRequiredEnv, - getSessionId, + getSessionObject, handleCallback, type OAuth2ClientConfig, signIn, @@ -131,12 +130,11 @@ configurations. 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); case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionId(request) === undefined + return await getSessionObject(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -174,7 +172,7 @@ This is required for OAuth solutions that span more than one sub-domain. signIn, handleCallback, signOut, - getSessionId, + getSessionObject, } = createHelpers(createGitHubOAuthConfig(), { cookieOptions: { name: "__Secure-triple-choc", @@ -188,12 +186,11 @@ This is required for OAuth solutions that span more than one sub-domain. case "/oauth/signin": return await signIn(request); case "/oauth/callback": - const { response } = await handleCallback(request); - return response; + return await handleCallback(request); case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionId(request) === undefined + return await getSessionObject(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -225,7 +222,7 @@ This is required for OAuth solutions that span more than one sub-domain. } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; import type { Plugin } from "$fresh/server.ts"; - const { signIn, handleCallback, signOut, getSessionId } = createHelpers( + const { signIn, handleCallback, signOut, getSessionObject } = createHelpers( createGitHubOAuthConfig(), ); @@ -241,9 +238,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); }, }, { @@ -255,7 +250,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 getSessionObject(req) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); }, diff --git a/demo.ts b/demo.ts index 846fc9d..b70786c 100644 --- a/demo.ts +++ b/demo.ts @@ -2,7 +2,7 @@ import { STATUS_CODE } from "./deps.ts"; import { createGitHubOAuthConfig, - getSessionId, + getSessionObject, handleCallback, signIn, signOut, @@ -21,8 +21,8 @@ import { const oauthConfig = createGitHubOAuthConfig(); async function indexHandler(request: Request) { - const sessionId = await getSessionId(request); - const hasSessionIdCookie = sessionId !== undefined; + const sesionObject = await getSessionObject(request); + const hasSessionIdCookie = sesionObject !== null; const body = `

Authorization endpoint URI: ${oauthConfig.authorizationEndpointUri}

@@ -59,8 +59,7 @@ export async function handler(request: Request): Promise { } case "/callback": { try { - const { response } = await handleCallback(request, oauthConfig); - return response; + return await handleCallback(request, oauthConfig); } catch { return new Response(null, { status: STATUS_CODE.InternalServerError }); } diff --git a/lib/_kv.ts b/lib/_kv.ts index c73cf57..7b0e5a0 100644 --- a/lib/_kv.ts +++ b/lib/_kv.ts @@ -67,13 +67,17 @@ 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 b4de568..b7688a3 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 { getSessionObject } from "./get_session_object.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, + * getSessionObject, * } = createHelpers(createGitHubOAuthConfig(), { * cookieOptions: { * name: "__Secure-triple-choc", @@ -45,12 +45,11 @@ export interface CreateHelpersOptions { * case "/oauth/signin": * return await signIn(request); * case "/oauth/callback": - * const { response } = await handleCallback(request); - * return response; + * return await handleCallback(request); * case "/oauth/signout": * return await signOut(request); * case "/protected-route": - * return await getSessionId(request) === undefined + * return await getSessionObject(request) === null * ? new Response("Unauthorized", { status: 401 }) * : new Response("You are allowed"); * default: @@ -61,18 +60,14 @@ export interface CreateHelpersOptions { * Deno.serve(handler); * ``` */ -export function createHelpers( +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): Promise; signOut(request: Request): Promise; - getSessionId(request: Request): Promise; + getSessionObject(request: Request): Promise; } { return { async signIn(request: Request, options?: SignInOptions) { @@ -86,8 +81,8 @@ export function createHelpers( async signOut(request: Request) { return await signOut(request, { cookieOptions: options?.cookieOptions }); }, - async getSessionId(request: Request) { - return await getSessionId(request, { + async getSessionObject(request: Request) { + return await getSessionObject(request, { cookieName: options?.cookieOptions?.name, }); }, 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/get_session_id.ts b/lib/get_session_object.ts similarity index 63% rename from lib/get_session_id.ts rename to lib/get_session_object.ts index 3479433..29f4fc0 100644 --- a/lib/get_session_id.ts +++ b/lib/get_session_object.ts @@ -1,6 +1,6 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. import { getSessionIdCookie } from "./_http.ts"; -import { isSiteSession } from "./_kv.ts"; +import { getSiteSession } from "./_kv.ts"; /** Options for {@linkcode getSessionId}. */ export interface GetSessionIdOptions { @@ -18,22 +18,19 @@ export interface GetSessionIdOptions { * * @example * ```ts - * import { getSessionId } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; + * import { getSessionObject } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; * * export async function handler(request: Request) { - * const sessionId = await getSessionId(request); - * const hasSessionIdCookie = sessionId !== undefined; - * - * return Response.json({ sessionId, hasSessionIdCookie }); + * const sessionObject = await getSessionObject(request); + * return Response.json(sessionObject); * } * ``` */ -export async function getSessionId( +export async function getSessionObject( request: Request, options?: GetSessionIdOptions, -): Promise { +): Promise { const sessionId = getSessionIdCookie(request, options?.cookieName); - return (sessionId !== undefined && await isSiteSession(sessionId)) - ? sessionId - : undefined; + if (sessionId === undefined) return null; + return await getSiteSession(sessionId); } diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index 918cd19..ca59d1d 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,12 +18,44 @@ 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}. */ cookieOptions?: Partial; + /** + * Function that uses the access token to get the session object. This is + * useful for fetching the user's profile from the OAuth provider. If + * undefined, the session object will be set to `undefined`. + * + * @example + * ```ts + * import { handleCallback, createGitHubOAuthConfig } from "https://deno.land/x/deno_kv_oauth@$VERSION/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) { + * return await handleCallback( + * request, + * oauthConfig, + * { sessionObjectGetter: getGitHubUser}, + * ); + * } + * ``` + */ + sessionObjectGetter?: (accessToken: string) => Promise; } /** @@ -39,26 +70,18 @@ export interface HandleCallbackOptions { * const oauthConfig = createGitHubOAuthConfig(); * * 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; * } * ``` */ -export async function handleCallback( +export async function handleCallback( request: Request, oauthConfig: OAuth2ClientConfig, - options?: HandleCallbackOptions, -): Promise<{ - response: Response; - sessionId: string; - tokens: Tokens; -}> { + options?: HandleCallbackOptions, +): Promise { const oauthCookieName = getCookieName( OAUTH_COOKIE_NAME, isHttps(request.url), @@ -80,14 +103,12 @@ export async function handleCallback( ...options?.cookieOptions, }; setCookie(response.headers, cookie); + const session = await options?.sessionObjectGetter?.(tokens.accessToken); await setSiteSession( sessionId, + session, 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..b733978 100644 --- a/lib/handle_callback_test.ts +++ b/lib/handle_callback_test.ts @@ -1,6 +1,12 @@ // 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 { OAUTH_COOKIE_NAME } from "./_http.ts"; @@ -67,19 +73,22 @@ 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(), ); 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, "/"); + + const sessionId = cookie.value; + assertNotEquals(sessionId, undefined); await assertRejects( async () => await getAndDeleteOAuthSession(oauthSessionId), Deno.errors.NotFound, @@ -112,7 +121,7 @@ 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(), { cookieOptions }, @@ -120,12 +129,16 @@ Deno.test("handleCallback() correctly handles the callback response with options 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, "/"); + + const sessionId = cookie.value; + assertNotEquals(sessionId, undefined); await assertRejects( async () => await getAndDeleteOAuthSession(oauthSessionId), Deno.errors.NotFound, 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 6d4dcad..5162d49 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_discord_oauth_config.ts"; export * from "./lib/create_dropbox_oauth_config.ts"; @@ -18,4 +15,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_object.ts"; export * from "./lib/types.ts"; From a0b8ce8748a77eefdda78438f003d81849494d6d Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Wed, 31 Jan 2024 16:25:28 +1100 Subject: [PATCH 02/14] refactor: rename to `getSessionData()` --- README.md | 16 ++++++++-------- demo.ts | 4 ++-- lib/_kv.ts | 7 ------- lib/create_helpers.ts | 12 ++++++------ ...get_session_object.ts => get_session_data.ts} | 6 +++--- mod.ts | 2 +- 6 files changed, 20 insertions(+), 27 deletions(-) rename lib/{get_session_object.ts => get_session_data.ts} (84%) diff --git a/README.md b/README.md index 62171bc..52c6bf0 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ configurations. // server.ts import { createGitHubOAuthConfig, - getSessionObject, + getSessionData, handleCallback, signIn, signOut, @@ -77,7 +77,7 @@ configurations. case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionObject(request) === null + return await getSessionData(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -109,7 +109,7 @@ configurations. // server.ts import { getRequiredEnv, - getSessionObject, + getSessionData, handleCallback, type OAuth2ClientConfig, signIn, @@ -134,7 +134,7 @@ configurations. case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionObject(request) === null + return await getSessionData(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -172,7 +172,7 @@ This is required for OAuth solutions that span more than one sub-domain. signIn, handleCallback, signOut, - getSessionObject, + getSessionData, } = createHelpers(createGitHubOAuthConfig(), { cookieOptions: { name: "__Secure-triple-choc", @@ -190,7 +190,7 @@ This is required for OAuth solutions that span more than one sub-domain. case "/oauth/signout": return await signOut(request); case "/protected-route": - return await getSessionObject(request) === null + return await getSessionData(request) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); default: @@ -222,7 +222,7 @@ This is required for OAuth solutions that span more than one sub-domain. } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; import type { Plugin } from "$fresh/server.ts"; - const { signIn, handleCallback, signOut, getSessionObject } = createHelpers( + const { signIn, handleCallback, signOut, getSessionData } = createHelpers( createGitHubOAuthConfig(), ); @@ -250,7 +250,7 @@ This is required for OAuth solutions that span more than one sub-domain. { path: "/protected", async handler(req) { - return await getSessionObject(req) === null + return await getSessionData(req) === null ? new Response("Unauthorized", { status: 401 }) : new Response("You are allowed"); }, diff --git a/demo.ts b/demo.ts index b70786c..6bd7349 100644 --- a/demo.ts +++ b/demo.ts @@ -2,7 +2,7 @@ import { STATUS_CODE } from "./deps.ts"; import { createGitHubOAuthConfig, - getSessionObject, + getSessionData, handleCallback, signIn, signOut, @@ -21,7 +21,7 @@ import { const oauthConfig = createGitHubOAuthConfig(); async function indexHandler(request: Request) { - const sesionObject = await getSessionObject(request); + const sesionObject = await getSessionData(request); const hasSessionIdCookie = sesionObject !== null; const body = ` diff --git a/lib/_kv.ts b/lib/_kv.ts index 7b0e5a0..a689562 100644 --- a/lib/_kv.ts +++ b/lib/_kv.ts @@ -56,13 +56,6 @@ 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]; } diff --git a/lib/create_helpers.ts b/lib/create_helpers.ts index b7688a3..2fb30ad 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 } from "../deps.ts"; -import { getSessionObject } from "./get_session_object.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, - * getSessionObject, + * getSessionData, * } = createHelpers(createGitHubOAuthConfig(), { * cookieOptions: { * name: "__Secure-triple-choc", @@ -49,7 +49,7 @@ export interface CreateHelpersOptions { * case "/oauth/signout": * return await signOut(request); * case "/protected-route": - * return await getSessionObject(request) === null + * return await getSessionData(request) === null * ? new Response("Unauthorized", { status: 401 }) * : new Response("You are allowed"); * default: @@ -67,7 +67,7 @@ export function createHelpers( signIn(request: Request, options?: SignInOptions): Promise; handleCallback(request: Request): Promise; signOut(request: Request): Promise; - getSessionObject(request: Request): Promise; + getSessionData(request: Request): Promise; } { return { async signIn(request: Request, options?: SignInOptions) { @@ -81,8 +81,8 @@ export function createHelpers( async signOut(request: Request) { return await signOut(request, { cookieOptions: options?.cookieOptions }); }, - async getSessionObject(request: Request) { - return await getSessionObject(request, { + async getSessionData(request: Request) { + return await getSessionData(request, { cookieName: options?.cookieOptions?.name, }); }, diff --git a/lib/get_session_object.ts b/lib/get_session_data.ts similarity index 84% rename from lib/get_session_object.ts rename to lib/get_session_data.ts index 29f4fc0..65daa24 100644 --- a/lib/get_session_object.ts +++ b/lib/get_session_data.ts @@ -18,15 +18,15 @@ export interface GetSessionIdOptions { * * @example * ```ts - * import { getSessionObject } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; + * import { getSessionData } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; * * export async function handler(request: Request) { - * const sessionObject = await getSessionObject(request); + * const sessionObject = await getSessionData(request); * return Response.json(sessionObject); * } * ``` */ -export async function getSessionObject( +export async function getSessionData( request: Request, options?: GetSessionIdOptions, ): Promise { diff --git a/mod.ts b/mod.ts index 5162d49..fbe4b70 100644 --- a/mod.ts +++ b/mod.ts @@ -18,5 +18,5 @@ 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_object.ts"; +export * from "./lib/get_session_data.ts"; export * from "./lib/types.ts"; From 20a2ae27ebfe407914e5cd87abecafb4401107cf Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 5 Feb 2024 16:15:19 +1100 Subject: [PATCH 03/14] fix --- demo.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo.ts b/demo.ts index 6bd7349..6ee4ddc 100644 --- a/demo.ts +++ b/demo.ts @@ -21,8 +21,8 @@ import { const oauthConfig = createGitHubOAuthConfig(); async function indexHandler(request: Request) { - const sesionObject = await getSessionData(request); - const hasSessionIdCookie = sesionObject !== null; + const sessionData = await getSessionData(request); + const hasSessionIdCookie = sessionData !== null; const body = `

Authorization endpoint URI: ${oauthConfig.authorizationEndpointUri}

From d9692973c58658fed2b18216728ce4bb9d2ec2cc Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 5 Feb 2024 16:21:30 +1100 Subject: [PATCH 04/14] tweak --- lib/handle_callback.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index ca59d1d..4389051 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -50,12 +50,12 @@ export interface HandleCallbackOptions { * return await handleCallback( * request, * oauthConfig, - * { sessionObjectGetter: getGitHubUser}, + * { sessionDataGetter: getGitHubUser}, * ); * } * ``` */ - sessionObjectGetter?: (accessToken: string) => Promise; + sessionDataGetter?: (accessToken: string) => Promise; } /** @@ -103,7 +103,7 @@ export async function handleCallback( ...options?.cookieOptions, }; setCookie(response.headers, cookie); - const session = await options?.sessionObjectGetter?.(tokens.accessToken); + const session = await options?.sessionDataGetter?.(tokens.accessToken); await setSiteSession( sessionId, session, From d4e0f451fdabd7020733c9128966257101614c4f Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 5 Feb 2024 16:49:03 +1100 Subject: [PATCH 05/14] tweaks --- lib/_kv.ts | 7 +++++-- lib/get_session_data.ts | 24 ++++++++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/_kv.ts b/lib/_kv.ts index a689562..5710fba 100644 --- a/lib/_kv.ts +++ b/lib/_kv.ts @@ -60,8 +60,11 @@ function siteSessionKey(id: string): [string, string] { return ["site_sessions", id]; } -export async function getSiteSession(id: string): Promise { - const res = await kv.get(siteSessionKey(id)); +export async function getSiteSession( + id: string, + consistency?: Deno.KvConsistencyLevel, +): Promise { + const res = await kv.get(siteSessionKey(id), { consistency }); return res.value; } diff --git a/lib/get_session_data.ts b/lib/get_session_data.ts index 65daa24..bdf6101 100644 --- a/lib/get_session_data.ts +++ b/lib/get_session_data.ts @@ -2,13 +2,19 @@ import { getSessionIdCookie } from "./_http.ts"; import { getSiteSession } from "./_kv.ts"; -/** Options for {@linkcode getSessionId}. */ -export interface GetSessionIdOptions { +/** 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; + /** + * Consistency level when reading the session data from the database. + * + * @default {"strong"} + */ + consistency?: Deno.KvConsistencyLevel; } /** @@ -16,21 +22,27 @@ export interface GetSessionIdOptions { * 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@$VERSION/mod.ts"; * * export async function handler(request: Request) { - * const sessionObject = await getSessionData(request); - * return Response.json(sessionObject); + * const sessionData = await getSessionData(request); + * return sessionData === null + * ? new Response("Unauthorized", { status: 401 }) + * : Response.json(sessionData); * } * ``` */ export async function getSessionData( request: Request, - options?: GetSessionIdOptions, + options?: GetSessionDataOptions, ): Promise { const sessionId = getSessionIdCookie(request, options?.cookieName); if (sessionId === undefined) return null; - return await getSiteSession(sessionId); + return await getSiteSession(sessionId, options?.consistency); } From f71adccb39c1ea61cbfe60299bc1f0fb03aba5fc Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 5 Feb 2024 16:50:05 +1100 Subject: [PATCH 06/14] remove consistency option --- lib/_kv.ts | 7 ++----- lib/get_session_data.ts | 8 +------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/_kv.ts b/lib/_kv.ts index 5710fba..a689562 100644 --- a/lib/_kv.ts +++ b/lib/_kv.ts @@ -60,11 +60,8 @@ function siteSessionKey(id: string): [string, string] { return ["site_sessions", id]; } -export async function getSiteSession( - id: string, - consistency?: Deno.KvConsistencyLevel, -): Promise { - const res = await kv.get(siteSessionKey(id), { consistency }); +export async function getSiteSession(id: string): Promise { + const res = await kv.get(siteSessionKey(id)); return res.value; } diff --git a/lib/get_session_data.ts b/lib/get_session_data.ts index bdf6101..7431d3d 100644 --- a/lib/get_session_data.ts +++ b/lib/get_session_data.ts @@ -9,12 +9,6 @@ export interface GetSessionDataOptions { * used in {@linkcode handleCallback} and {@linkcode signOut}. */ cookieName?: string; - /** - * Consistency level when reading the session data from the database. - * - * @default {"strong"} - */ - consistency?: Deno.KvConsistencyLevel; } /** @@ -44,5 +38,5 @@ export async function getSessionData( ): Promise { const sessionId = getSessionIdCookie(request, options?.cookieName); if (sessionId === undefined) return null; - return await getSiteSession(sessionId, options?.consistency); + return await getSiteSession(sessionId); } From 00db0d43d2a619d50e3b8b23bf7a706683bfc96c Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 5 Feb 2024 17:02:06 +1100 Subject: [PATCH 07/14] tweaks --- demo.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/demo.ts b/demo.ts index 6ee4ddc..7a6d8b4 100644 --- a/demo.ts +++ b/demo.ts @@ -29,6 +29,7 @@ async function indexHandler(request: Request) {

Token URI: ${oauthConfig.tokenUri}

Scope: ${oauthConfig.defaults?.scope}

Signed in: ${hasSessionIdCookie}

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

Sign in

@@ -45,6 +46,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 }); @@ -59,7 +83,9 @@ export async function handler(request: Request): Promise { } case "/callback": { try { - return await handleCallback(request, oauthConfig); + return await handleCallback(request, oauthConfig, { + sessionDataGetter: getGitHubUser, + }); } catch { return new Response(null, { status: STATUS_CODE.InternalServerError }); } From b09e3528861a7df9c480f8a98a51e4174d588bf6 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 12 Feb 2024 14:50:25 +1100 Subject: [PATCH 08/14] fix --- lib/get_session_data.ts | 2 +- lib/handle_callback.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/get_session_data.ts b/lib/get_session_data.ts index 7431d3d..d75cbf2 100644 --- a/lib/get_session_data.ts +++ b/lib/get_session_data.ts @@ -22,7 +22,7 @@ export interface GetSessionDataOptions { * * @example * ```ts - * import { getSessionData } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; + * import { getSessionData } from "https://deno.land/x/deno_kv_oauth/mod.ts"; * * export async function handler(request: Request) { * const sessionData = await getSessionData(request); diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index 98f55da..d7f0ff5 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -32,7 +32,7 @@ export interface HandleCallbackOptions { * * @example * ```ts - * import { handleCallback, createGitHubOAuthConfig } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts"; + * import { handleCallback, createGitHubOAuthConfig } from "https://deno.land/x/deno_kv_oauth/mod.ts"; * * const oauthConfig = createGitHubOAuthConfig(); * From f5195bf4b2b2b5dda3384255eb83982df1d5ca03 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 12 Feb 2024 17:26:19 +1100 Subject: [PATCH 09/14] work --- lib/handle_callback.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index d7f0ff5..bff0fdc 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -104,6 +104,10 @@ export async function handleCallback( }; setCookie(response.headers, cookie); const session = await options?.sessionDataGetter?.(tokens.accessToken); + if (session === null) { + throw new Error("options.sessionDataGetter() must return a non-null value"); + } + await setSiteSession( sessionId, session, From da7adddb4ff0252792e8b5aaf7b05c3bd9c38b15 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 12 Feb 2024 17:30:16 +1100 Subject: [PATCH 10/14] work --- lib/handle_callback.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index bff0fdc..0d6ff98 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -30,6 +30,10 @@ export interface HandleCallbackOptions { * useful for fetching the user's profile from the OAuth provider. If * undefined, the session object will be set to `undefined`. * + * An {@linkcode Error} will be thrown if this function resolves to a `null` + * value. `null` is a value reserved for checking for the existence of a + * session data object in {@linkcode getSessionData}. + * * @example * ```ts * import { handleCallback, createGitHubOAuthConfig } from "https://deno.land/x/deno_kv_oauth/mod.ts"; From 2d8f04b862e7780a1cc183181d9404ce1afd9375 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 12 Feb 2024 18:47:11 +1100 Subject: [PATCH 11/14] work --- demo.ts | 22 ++++++------ lib/create_helpers.ts | 12 +++++-- lib/handle_callback.ts | 68 +++++++++++++++---------------------- lib/handle_callback_test.ts | 24 +++++++++++-- 4 files changed, 68 insertions(+), 58 deletions(-) diff --git a/demo.ts b/demo.ts index 7a6d8b4..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, - getSessionData, - handleCallback, - signIn, - signOut, -} from "./mod.ts"; +import { createGitHubOAuthConfig, createHelpers } from "./mod.ts"; /** * Modify the OAuth configuration creation function when testing for providers. @@ -19,6 +13,11 @@ import { * ``` */ const oauthConfig = createGitHubOAuthConfig(); +const { getSessionData, handleCallback, signIn, signOut } = createHelpers< + GitHubUser +>( + oauthConfig, +); async function indexHandler(request: Request) { const sessionData = await getSessionData(request); @@ -79,13 +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 { - return await handleCallback(request, oauthConfig, { - sessionDataGetter: getGitHubUser, - }); + return await handleCallback( + request, + getGitHubUser, + ); } catch { return new Response(null, { status: STATUS_CODE.InternalServerError }); } diff --git a/lib/create_helpers.ts b/lib/create_helpers.ts index 05d0fec..d988089 100644 --- a/lib/create_helpers.ts +++ b/lib/create_helpers.ts @@ -65,7 +65,10 @@ export function createHelpers( options?: CreateHelpersOptions, ): { signIn(request: Request, options?: SignInOptions): Promise; - handleCallback(request: Request): Promise; + handleCallback( + request: Request, + tokenHandler: (accessToken: string) => T | Promise, + ): Promise; signOut(request: Request): Promise; getSessionData(request: Request): Promise; } { @@ -73,8 +76,11 @@ export function createHelpers( 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, }); }, diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index 0d6ff98..773cbce 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -25,41 +25,6 @@ export interface HandleCallbackOptions { * {@linkcode signOut}. */ cookieOptions?: Partial; - /** - * Function that uses the access token to get the session object. This is - * useful for fetching the user's profile from the OAuth provider. If - * undefined, the session object will be set to `undefined`. - * - * An {@linkcode Error} will be thrown if this function resolves to a `null` - * value. `null` is a value reserved for checking for the existence of a - * session data object in {@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) { - * return await handleCallback( - * request, - * oauthConfig, - * { sessionDataGetter: getGitHubUser}, - * ); - * } - * ``` - */ - sessionDataGetter?: (accessToken: string) => Promise; } /** @@ -67,23 +32,43 @@ 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. * + * Function that uses the access token to get the session object. This is + * useful for fetching the user's profile from the OAuth provider. If + * undefined, the session object will be set to `undefined`. + * + * An {@linkcode Error} will be thrown if this function resolves to a `null` + * value. `null` is a value reserved for checking for the existence of a + * session data object in {@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) { * return await handleCallback( * request, * oauthConfig, - * ); + * getGitHubUser, + * ); * } * ``` */ -export async function handleCallback( +export async function handleCallback( request: Request, oauthConfig: OAuth2ClientConfig, + tokenCallback: (accessToken: string) => T | Promise, options?: HandleCallbackOptions, ): Promise { const oauthCookieName = getCookieName( @@ -107,14 +92,15 @@ export async function handleCallback( ...options?.cookieOptions, }; setCookie(response.headers, cookie); - const session = await options?.sessionDataGetter?.(tokens.accessToken); - if (session === null) { - throw new Error("options.sessionDataGetter() must return a non-null value"); + + const sessionData = await tokenCallback(tokens.accessToken); + if (sessionData === null) { + throw new Error("tokenCallback() must resolve to a non-null value"); } await setSiteSession( sessionId, - session, + sessionData, cookie.maxAge ? cookie.maxAge * SECOND : undefined, ); diff --git a/lib/handle_callback_test.ts b/lib/handle_callback_test.ts index b733978..4558b67 100644 --- a/lib/handle_callback_test.ts +++ b/lib/handle_callback_test.ts @@ -21,7 +21,8 @@ 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(), () => undefined), Error, "OAuth cookie not found", ); @@ -32,7 +33,20 @@ 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(), () => undefined), + 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(), () => undefined), Deno.errors.NotFound, "OAuth session not found", ); @@ -45,7 +59,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(), () => undefined) + ); await assertRejects( async () => await getAndDeleteOAuthSession(oauthSessionId), Deno.errors.NotFound, @@ -76,6 +92,7 @@ Deno.test("handleCallback() correctly handles the callback response", async () = const response = await handleCallback( request, randomOAuthConfig(), + () => undefined, ); fetchStub.restore(); @@ -124,6 +141,7 @@ Deno.test("handleCallback() correctly handles the callback response with options const response = await handleCallback( request, randomOAuthConfig(), + () => undefined, { cookieOptions }, ); fetchStub.restore(); From 9e812b4bb0d28fc7990aee815f2e91cc5dcb45f8 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 19 Feb 2024 10:48:11 +1100 Subject: [PATCH 12/14] update docs --- README.md | 48 +++++++++++++++++++++++++++++++++++++++---- lib/create_helpers.ts | 12 ++++++++++- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d88ff18..19d7ea6 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,23 @@ 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": - return await handleCallback(request, oauthConfig); + return await handleCallback(request, oauthConfig, getGitHubUser); case "/oauth/signout": return await signOut(request); case "/protected-route": @@ -124,13 +134,23 @@ 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": - return await handleCallback(request, oauthConfig); + return await handleCallback(request, oauthConfig, getGitHubUser); case "/oauth/signout": return await signOut(request); case "/protected-route": @@ -180,13 +200,23 @@ 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": - return await handleCallback(request); + return await handleCallback(request, getGitHubUser); case "/oauth/signout": return await signOut(request); case "/protected-route": @@ -226,6 +256,16 @@ This is required for OAuth solutions that span more than one sub-domain. 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: [ @@ -238,7 +278,7 @@ This is required for OAuth solutions that span more than one sub-domain. { path: "/callback", async handler(req) { - return await handleCallback(req); + return await handleCallback(req, getGitHubUser); }, }, { diff --git a/lib/create_helpers.ts b/lib/create_helpers.ts index d988089..8e6ec03 100644 --- a/lib/create_helpers.ts +++ b/lib/create_helpers.ts @@ -39,13 +39,23 @@ 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": - * return await handleCallback(request); + * return await handleCallback(request, getGitHubUser); * case "/oauth/signout": * return await signOut(request); * case "/protected-route": From 37c702875595a67791cdc554a176f2e87e3209c2 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 19 Feb 2024 10:49:00 +1100 Subject: [PATCH 13/14] tweak --- lib/handle_callback.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index 773cbce..b888ece 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -32,9 +32,8 @@ 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. * - * Function that uses the access token to get the session object. This is - * useful for fetching the user's profile from the OAuth provider. If - * undefined, the session object will be set to `undefined`. + * Function that uses the access token to get the session object. This is + * useful for fetching the user's profile from the OAuth provider. * * An {@linkcode Error} will be thrown if this function resolves to a `null` * value. `null` is a value reserved for checking for the existence of a From 077c57b5feab30b6a4f29b36f7d1a1c1f37bfd29 Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Mon, 19 Feb 2024 20:51:34 +1100 Subject: [PATCH 14/14] further work --- lib/create_helpers.ts | 3 +- lib/handle_callback.ts | 19 +++++---- lib/handle_callback_test.ts | 85 ++++++++++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/lib/create_helpers.ts b/lib/create_helpers.ts index 8e6ec03..30ac558 100644 --- a/lib/create_helpers.ts +++ b/lib/create_helpers.ts @@ -70,7 +70,8 @@ export interface CreateHelpersOptions { * Deno.serve(handler); * ``` */ -export function createHelpers( +// deno-lint-ignore ban-types +export function createHelpers = {}>( oauthConfig: OAuth2ClientConfig, options?: CreateHelpersOptions, ): { diff --git a/lib/handle_callback.ts b/lib/handle_callback.ts index b888ece..19b8517 100644 --- a/lib/handle_callback.ts +++ b/lib/handle_callback.ts @@ -32,12 +32,12 @@ 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. * - * Function that uses the access token to get the session object. This is - * useful for fetching the user's profile from the OAuth provider. - * - * An {@linkcode Error} will be thrown if this function resolves to a `null` - * value. `null` is a value reserved for checking for the existence of a - * session data object in {@linkcode getSessionData}. + * @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 @@ -64,7 +64,8 @@ export interface HandleCallbackOptions { * } * ``` */ -export async function handleCallback( +// deno-lint-ignore ban-types +export async function handleCallback = {}>( request: Request, oauthConfig: OAuth2ClientConfig, tokenCallback: (accessToken: string) => T | Promise, @@ -93,8 +94,8 @@ export async function handleCallback( setCookie(response.headers, cookie); const sessionData = await tokenCallback(tokens.accessToken); - if (sessionData === null) { - throw new Error("tokenCallback() must resolve to a non-null value"); + if (sessionData === null || sessionData === undefined) { + throw new Error("tokenCallback() must resolve to a non-nullable value"); } await setSiteSession( diff --git a/lib/handle_callback_test.ts b/lib/handle_callback_test.ts index 4558b67..c16d191 100644 --- a/lib/handle_callback_test.ts +++ b/lib/handle_callback_test.ts @@ -8,7 +8,11 @@ import { } 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, @@ -21,8 +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(), () => undefined), + async () => await handleCallback(request, randomOAuthConfig(), () => true), Error, "OAuth cookie not found", ); @@ -33,8 +36,7 @@ Deno.test("handleCallback() rejects for non-existent OAuth session", async () => headers: { cookie: `${OAUTH_COOKIE_NAME}=xxx` }, }); await assertRejects( - async () => - await handleCallback(request, randomOAuthConfig(), () => undefined), + async () => await handleCallback(request, randomOAuthConfig(), () => true), Deno.errors.NotFound, "OAuth session not found", ); @@ -45,8 +47,7 @@ Deno.test("handleCallback() rejects for non-existent OAuth session", async () => headers: { cookie: `${OAUTH_COOKIE_NAME}=xxx` }, }); await assertRejects( - async () => - await handleCallback(request, randomOAuthConfig(), () => undefined), + async () => await handleCallback(request, randomOAuthConfig(), () => true), Deno.errors.NotFound, "OAuth session not found", ); @@ -60,7 +61,7 @@ Deno.test("handleCallback() deletes the OAuth session KV entry", async () => { headers: { cookie: `${OAUTH_COOKIE_NAME}=${oauthSessionId}` }, }); await assertRejects(async () => - await handleCallback(request, randomOAuthConfig(), () => undefined) + await handleCallback(request, randomOAuthConfig(), () => true) ); await assertRejects( async () => await getAndDeleteOAuthSession(oauthSessionId), @@ -92,7 +93,7 @@ Deno.test("handleCallback() correctly handles the callback response", async () = const response = await handleCallback( request, randomOAuthConfig(), - () => undefined, + (accessToken) => accessToken, ); fetchStub.restore(); @@ -103,6 +104,7 @@ Deno.test("handleCallback() correctly handles the callback response", async () = 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); @@ -141,7 +143,7 @@ Deno.test("handleCallback() correctly handles the callback response with options const response = await handleCallback( request, randomOAuthConfig(), - () => undefined, + (accessToken) => accessToken, { cookieOptions }, ); fetchStub.restore(); @@ -154,6 +156,7 @@ Deno.test("handleCallback() correctly handles the callback response with options 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); @@ -163,3 +166,65 @@ Deno.test("handleCallback() correctly handles the callback response with options "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(); +});