Skip to content
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

BREAKING: add getSessionData() #295

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
69 changes: 52 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,35 @@ configurations.
// server.ts
import {
createGitHubOAuthConfig,
getSessionId,
getSessionData,
handleCallback,
signIn,
signOut,
} 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();
}

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:
Expand Down Expand Up @@ -110,7 +119,7 @@ configurations.
// server.ts
import {
getRequiredEnv,
getSessionId,
getSessionData,
handleCallback,
type OAuth2ClientConfig,
signIn,
Expand All @@ -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:
Expand Down Expand Up @@ -174,26 +192,35 @@ 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",
domain: "news.site",
},
});

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:
Expand Down Expand Up @@ -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: [
Expand All @@ -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);
},
},
{
Expand All @@ -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");
},
Expand Down
49 changes: 37 additions & 12 deletions demo.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 = `
<p>Authorization endpoint URI: ${oauthConfig.authorizationEndpointUri}</p>
<p>Token URI: ${oauthConfig.tokenUri}</p>
<p>Scope: ${oauthConfig.defaults?.scope}</p>
<p>Signed in: ${hasSessionIdCookie}</p>
<pre>${JSON.stringify(sessionData, null, 2)}</pre>
<p>
<a href="/signin">Sign in</a>
</p>
Expand All @@ -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<Response> {
if (request.method !== "GET") {
return new Response(null, { status: STATUS_CODE.NotFound });
Expand All @@ -55,12 +78,14 @@ export async function handler(request: Request): Promise<Response> {
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 });
}
Expand Down
21 changes: 9 additions & 12 deletions lib/_kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const res = await kv.get<SiteSession>(siteSessionKey(id));
return res.value !== null;
export async function getSiteSession<T>(id: string): Promise<T | null> {
const res = await kv.get<T>(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) {
Expand Down
21 changes: 20 additions & 1 deletion lib/_kv_test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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);
});
Loading
Loading