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

feat: cleanupExpiredEntries() #150

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,19 @@ provider you like.
import {
createGitHubOAuth2Client,
getSessionAccessToken,
getSessionId,
getSessionKey,
} from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts";

const oauth2Client = createGitHubOAuth2Client();

async function handleAccountPage(request: Request) {
const sessionId = getSessionId(request);
const isSignedIn = sessionId !== undefined;
const sessionKey = getSessionKey(request);
const hasSessionKeyCookie = sessionKey !== undefined;

if (!isSignedIn) return new Response(null, { status: 404 });
if (!hasSessionKeyCookie) return new Response(null, { status: 404 });

const accessToken = await getSessionAccessToken(oauth2Client, sessionId);
return Response.json({ isSignedIn, accessToken });
const accessToken = await getSessionAccessToken(oauth2Client, sessionKey);
return Response.json({ hasSessionKeyCookie, accessToken });
}
```

Expand All @@ -128,6 +128,16 @@ provider you like.
GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=xxx deno run --unstable --allow-env --allow-net server.ts
```

1. Clean-up expired KV entries as part of a cron job, if possible.

```ts
import { cleanupExpiredEntries } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts";

async function cronJob() {
await cleanupExpiredEntries();
}
```

> Check out a full implementation in the [demo source code](./demo.ts).

### Pre-configured OAuth 2.0 Clients
Expand Down
12 changes: 6 additions & 6 deletions demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
createSpotifyOAuth2Client,
createTwitterOAuth2Client,
getSessionAccessToken,
getSessionId,
getSessionKey,
handleCallback,
signIn,
signOut,
Expand Down Expand Up @@ -68,10 +68,10 @@ const additionalOAuth2ClientConfig: Partial<OAuth2ClientConfig> = {
const oauth2Client = createOAuth2ClientFn(additionalOAuth2ClientConfig);

async function indexHandler(request: Request) {
const sessionId = getSessionId(request);
const hasSessionIdCookie = sessionId !== undefined;
const accessToken = hasSessionIdCookie
? await getSessionAccessToken(oauth2Client, sessionId)
const sessionKey = getSessionKey(request);
const hasSessionKeyCookie = sessionKey !== undefined;
const accessToken = hasSessionKeyCookie
? await getSessionAccessToken(oauth2Client, sessionKey)
: null;

const accessTokenInnerText = accessToken !== null
Expand All @@ -80,7 +80,7 @@ async function indexHandler(request: Request) {
const body = `
<p>Provider: ${provider}</p>
<p>Scope: ${oauth2Client.config.defaults?.scope}</p>
<p>Signed in: ${hasSessionIdCookie}</p>
<p>Signed in: ${hasSessionKeyCookie}</p>
<p>Your access token: ${accessTokenInnerText}</p>
<p>
<a href="/signin">Sign in</a>
Expand Down
13 changes: 9 additions & 4 deletions demo_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
isRedirectStatus,
} from "./dev_deps.ts";
import { Status } from "./deps.ts";
import { setTokensBySession, SITE_COOKIE_NAME } from "./src/core.ts";
import {
SessionKey,
setTokensBySession,
SITE_COOKIE_NAME,
stringifySessionKeyCookie,
} from "./src/core.ts";

const baseUrl = "http://localhost";

Expand All @@ -32,15 +37,15 @@ Deno.test("demo", async (test) => {
});

await test.step("GET / serves a signed-in web page", async () => {
const sessionId = crypto.randomUUID();
const sessionKey: SessionKey = [Date.now(), crypto.randomUUID()];
const accessToken = crypto.randomUUID();
await setTokensBySession(sessionId, {
await setTokensBySession(sessionKey, {
accessToken,
tokenType: crypto.randomUUID(),
});
const request = new Request(baseUrl, {
headers: {
cookie: `${SITE_COOKIE_NAME}=${sessionId}`,
cookie: `${SITE_COOKIE_NAME}=${stringifySessionKeyCookie(sessionKey)}`,
},
});
const response = await handler(request);
Expand Down
1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions dev_deps.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
export {
assert,
assertArrayIncludes,
assertEquals,
assertNotEquals,
assertRejects,
Expand All @@ -10,4 +11,5 @@ export {
export { walk } from "https://deno.land/std@0.196.0/fs/walk.ts";
export { globToRegExp } from "https://deno.land/std@0.196.0/path/glob.ts";
export { loadSync } from "https://deno.land/std@0.196.0/dotenv/mod.ts";
export { delay } from "https://deno.land/std@0.196.0/async/delay.ts";
export * from "./deps.ts";
3 changes: 2 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
export * from "./src/providers.ts";
export * from "./src/cleanup_expired_entries.ts";
export * from "./src/get_session_access_token.ts";
export * from "./src/handle_callback.ts";
export * from "./src/get_session_id.ts";
export * from "./src/get_session_key.ts";
export * from "./src/sign_in.ts";
export * from "./src/sign_out.ts";
40 changes: 40 additions & 0 deletions src/cleanup_expired_entries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import {
deleteOAuthSession,
deleteStoredTokensBySession,
listExpiredOAuthSessions,
listExpiredTokens,
SessionKey,
} from "./core.ts";

/**
* Deletes stored KV entries such as OAuth 2.0 session and token that are expired, exclusively.
*
* Expired entries are defined as those whose expiry timestamps lie between the epoch and now.
*
* It is recommended to run this function regularly as a cron job, if possible.
*
* It does this by:
* 1. Listing expired OAuth 2.0 session entries and asynchronously deleting them.
* 2. Listing expired tokens entries and asynchronously deleting them.
* 3. Waiting for all deletion tasks to complete.
*
* @example
* ```ts
* import { cleanupExpiredEntries } from "https://deno.land/x/deno_kv_oauth@$VERSION/mod.ts";
*
* await cleanupExpiredEntries();
* ```
*/
export async function cleanupExpiredEntries() {
const expiredOAuthSessionsIter = listExpiredOAuthSessions();
const expiredTokensIter = listExpiredTokens();
const promises = [];
for await (const { key } of expiredOAuthSessionsIter) {
promises.push(deleteOAuthSession(key.slice(1) as SessionKey));
}
for await (const { key } of expiredTokensIter) {
promises.push(deleteStoredTokensBySession(key.slice(1) as SessionKey));
}
await Promise.all(promises);
}
34 changes: 34 additions & 0 deletions src/cleanup_expired_entries_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import {
getOAuthSession,
getTokensBySession,
setOAuthSession,
setTokensBySession,
} from "./core.ts";
import { cleanupExpiredEntries } from "./cleanup_expired_entries.ts";
import { assertEquals, assertNotEquals, SECOND } from "../dev_deps.ts";
import { genOAuthSession, genSessionKey, genTokens } from "./test_utils.ts";

Deno.test("cleanupExpiredEntries()", async () => {
const expiredOAuthSessionKey = genSessionKey(-10 * SECOND);
const expiredTokensKey = genSessionKey(-10 * SECOND);
const validOAuthSessionKey = genSessionKey(10 * SECOND);
const validTokensKey = genSessionKey(10 * SECOND);

await setOAuthSession(expiredOAuthSessionKey, genOAuthSession());
await setTokensBySession(expiredTokensKey, genTokens());
await setOAuthSession(validOAuthSessionKey, genOAuthSession());
await setTokensBySession(validTokensKey, genTokens());

assertNotEquals(await getOAuthSession(expiredOAuthSessionKey), null);
assertNotEquals(await getTokensBySession(expiredTokensKey), null);
assertNotEquals(await getOAuthSession(validOAuthSessionKey), null);
assertNotEquals(await getTokensBySession(validTokensKey), null);

await cleanupExpiredEntries();

assertEquals(await getOAuthSession(expiredOAuthSessionKey), null);
assertEquals(await getTokensBySession(expiredTokensKey), null);
assertNotEquals(await getOAuthSession(validOAuthSessionKey), null);
assertNotEquals(await getTokensBySession(validTokensKey), null);
});
92 changes: 64 additions & 28 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { type Cookie, SECOND, Status, type Tokens } from "../deps.ts";
import { assert, type Cookie, SECOND, Status, type Tokens } from "../deps.ts";

export const OAUTH_COOKIE_NAME = "oauth-session";
export const SITE_COOKIE_NAME = "site-session";
Expand All @@ -21,7 +21,7 @@ export const COOKIE_BASE = {
// 90 days
maxAge: 7776000,
sameSite: "Lax",
} as Partial<Cookie>;
} as Required<Pick<Cookie, "path" | "httpOnly" | "maxAge" | "sameSite">>;

const KV_PATH_KEY = "KV_PATH";
let path = undefined;
Expand All @@ -38,6 +38,33 @@ addEventListener("beforeunload", async () => {
await kv.close();
});

export type SessionKey = [
// Expiry as number of milliseconds elapsed since the epoch
number,
// ID generated by `crypto.randomUUID()`
string,
];

export function stringifySessionKeyCookie(key: SessionKey) {
return encodeURIComponent(JSON.stringify(key));
}

export function parseJsonCookie(text: string) {
return JSON.parse(decodeURIComponent(text));
}

export function assertIsSessionKey(
// deno-lint-ignore no-explicit-any
value: any,
msg?: string,
): asserts value is SessionKey {
assert(
Array.isArray(value) && typeof value[0] === "number" &&
typeof value[1] === "string" && value.length === 2,
msg,
);
}

// OAuth 2.0 session
export interface OAuthSession {
state: string;
Expand All @@ -46,26 +73,28 @@ export interface OAuthSession {

const OAUTH_SESSION_PREFIX = "oauth_sessions";

// Retrieves the OAuth 2.0 session object for the given OAuth 2.0 session ID.
export async function getOAuthSession(oauthSessionId: string) {
const result = await kv.get<OAuthSession>([
OAUTH_SESSION_PREFIX,
oauthSessionId,
]);
// Retrieves the OAuth 2.0 session object for the given OAuth 2.0 session key.
export async function getOAuthSession(key: SessionKey) {
const result = await kv.get<OAuthSession>([OAUTH_SESSION_PREFIX, ...key]);
return result.value;
}

// Stores the OAuth 2.0 session object for the given OAuth 2.0 session ID.
export async function setOAuthSession(
oauthSessionId: string,
oauthSession: OAuthSession,
) {
await kv.set([OAUTH_SESSION_PREFIX, oauthSessionId], oauthSession);
// Stores the OAuth 2.0 session object for the given OAuth 2.0 session key.
export async function setOAuthSession(key: SessionKey, value: OAuthSession) {
await kv.set([OAUTH_SESSION_PREFIX, ...key], value);
}

// Deletes the OAuth 2.0 session object for the given OAuth 2.0 session key.
export async function deleteOAuthSession(key: SessionKey) {
await kv.delete([OAUTH_SESSION_PREFIX, ...key]);
}

// Deletes the OAuth 2.0 session object for the given OAuth 2.0 session ID.
export async function deleteOAuthSession(oauthSessionId: string) {
await kv.delete([OAUTH_SESSION_PREFIX, oauthSessionId]);
// Lists OAuth 2.0 session entries up until the current time.
export function listExpiredOAuthSessions() {
return kv.list<OAuthSession>({
prefix: [OAUTH_SESSION_PREFIX],
end: [OAUTH_SESSION_PREFIX, Date.now()],
});
}

// Tokens by session
Expand Down Expand Up @@ -101,44 +130,51 @@ export function toStoredTokens(tokens: Tokens): StoredTokens {
export function toTokens(storedTokens: StoredTokens): Tokens {
if (storedTokens.expiresAt === undefined) return storedTokens;

const expiresIn =
(Date.now() - Date.parse(storedTokens.expiresAt.toString())) / SECOND;
const expiresIn = (Date.now() - storedTokens.expiresAt.getTime()) / SECOND;

const tokens = { ...storedTokens };
delete tokens.expiresAt;
return { ...tokens, expiresIn };
}

/**
* Retrieves the token for the given session ID.
* Retrieves the token for the given session key.
* Before retrieval, the stored token is converted to a normal token using {@linkcode toTokens}.
*/
export async function getTokensBySession(
sessionId: string,
key: SessionKey,
consistency?: Deno.KvConsistencyLevel,
) {
const result = await kv.get<StoredTokens>([
STORED_TOKENS_BY_SESSION_PREFIX,
sessionId,
...key,
], { consistency });
return result.value !== null ? toTokens(result.value) : null;
}

/**
* Stores the token for the given session ID.
* Stores the token for the given session key.
* Before storage, the token is converted to a stored token using {@linkcode toStoredTokens}.
*/
export async function setTokensBySession(
sessionId: string,
key: SessionKey,
tokens: Tokens,
) {
const storedTokens = toStoredTokens(tokens);
await kv.set([STORED_TOKENS_BY_SESSION_PREFIX, sessionId], storedTokens);
await kv.set([STORED_TOKENS_BY_SESSION_PREFIX, ...key], storedTokens);
}

// Deletes the token for the given session ID.
export async function deleteStoredTokensBySession(sessionId: string) {
await kv.delete([STORED_TOKENS_BY_SESSION_PREFIX, sessionId]);
// Deletes the token for the given session key.
export async function deleteStoredTokensBySession(key: SessionKey) {
await kv.delete([STORED_TOKENS_BY_SESSION_PREFIX, ...key]);
}

// Lists tokens entries up until the current time.
export function listExpiredTokens() {
return kv.list<StoredTokens>({
prefix: [STORED_TOKENS_BY_SESSION_PREFIX],
end: [STORED_TOKENS_BY_SESSION_PREFIX, Date.now()],
});
}

/**
Expand Down
Loading