From bdebc5007511a77e2969cd78d2e66e871fd14f42 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 9 Jul 2026 20:14:23 -0400 Subject: [PATCH] move JWT validation to package --- apps/accounts/package.json | 1 + apps/accounts/src/accounts.app.ts | 3 +- apps/accounts/src/jwt.ts | 57 ------------------- apps/api/package.json | 1 + apps/api/src/http.ts | 2 +- apps/api/src/jwt.ts | 57 ------------------- apps/auth/package.json | 1 + apps/auth/src/auth.app.ts | 2 +- apps/cdn/package.json | 1 + apps/cdn/src/cdn.app.ts | 2 +- apps/cdn/src/jwt.ts | 57 ------------------- apps/clubs/package.json | 1 + apps/clubs/src/clubs.app.ts | 3 +- apps/clubs/src/jwt.ts | 57 ------------------- apps/econ/package.json | 1 + apps/econ/src/econ.app.ts | 2 +- apps/econ/src/jwt.ts | 57 ------------------- apps/match/package.json | 1 + apps/match/src/jwt.ts | 57 ------------------- apps/match/src/match.app.ts | 2 +- apps/playersettings/package.json | 1 + apps/playersettings/src/jwt.ts | 57 ------------------- apps/playersettings/src/playersettings.app.ts | 2 +- apps/rooms/package.json | 1 + apps/rooms/src/jwt.ts | 57 ------------------- apps/rooms/src/rooms.app.ts | 2 +- apps/storage/package.json | 1 + apps/storage/src/jwt.ts | 57 ------------------- apps/storage/src/storage.app.ts | 3 +- packages/jwt/README.md | 9 +++ packages/jwt/package.json | 17 ++++++ packages/jwt/src/index.ts | 1 + {apps/auth => packages/jwt}/src/jwt.ts | 9 +-- packages/jwt/tsconfig.json | 8 +++ pnpm-lock.yaml | 42 ++++++++++++++ 35 files changed, 102 insertions(+), 530 deletions(-) delete mode 100644 apps/accounts/src/jwt.ts delete mode 100644 apps/api/src/jwt.ts delete mode 100644 apps/cdn/src/jwt.ts delete mode 100644 apps/clubs/src/jwt.ts delete mode 100644 apps/econ/src/jwt.ts delete mode 100644 apps/match/src/jwt.ts delete mode 100644 apps/playersettings/src/jwt.ts delete mode 100644 apps/rooms/src/jwt.ts delete mode 100644 apps/storage/src/jwt.ts create mode 100644 packages/jwt/README.md create mode 100644 packages/jwt/package.json create mode 100644 packages/jwt/src/index.ts rename {apps/auth => packages/jwt}/src/jwt.ts (91%) create mode 100644 packages/jwt/tsconfig.json diff --git a/apps/accounts/package.json b/apps/accounts/package.json index 29e2b2b..d914286 100644 --- a/apps/accounts/package.json +++ b/apps/accounts/package.json @@ -17,6 +17,7 @@ "dependencies": { "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 3efa360..fa9ae22 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -11,8 +11,7 @@ import { updateAccount, } from '@repo/domain' import { logger, withNotFound, withOnError } from '@repo/hono-helpers' - -import { validateAndGetAccountId } from './jwt' +import { validateAndGetAccountId } from '@repo/jwt' import type { Context } from 'hono' import type { Account } from '@repo/domain' diff --git a/apps/accounts/src/jwt.ts b/apps/accounts/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/accounts/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/api/package.json b/apps/api/package.json index dab7dbc..f196077 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index 56b6c6e..ca03ea2 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -1,4 +1,4 @@ -import { validateAndGetAccountId } from './jwt' +import { validateAndGetAccountId } from '@repo/jwt' import type { Context } from 'hono' import type { App } from './context' diff --git a/apps/api/src/jwt.ts b/apps/api/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/api/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/auth/package.json b/apps/auth/package.json index 0f39996..e665603 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -18,6 +18,7 @@ "dependencies": { "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 7e644b6..99394d5 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -9,8 +9,8 @@ import { setPasswordHash, } from '@repo/domain' import { logger, withNotFound, withOnError } from '@repo/hono-helpers' +import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' -import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt' import { hashPassword, verifyPassword } from './password' import { consumeRefreshToken, issueRefreshToken } from './refresh-db' diff --git a/apps/cdn/package.json b/apps/cdn/package.json index 913fbdc..cd8f8d3 100644 --- a/apps/cdn/package.json +++ b/apps/cdn/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index c61d530..8831695 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -2,9 +2,9 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' import loadingScreenTipData from '../static/loading-screen-tip-data.json' -import { validateAndGetAccountId } from './jwt' import type { Context } from 'hono' import type { App, Env } from './context' diff --git a/apps/cdn/src/jwt.ts b/apps/cdn/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/cdn/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/clubs/package.json b/apps/clubs/package.json index 3f61247..522ffe9 100644 --- a/apps/clubs/package.json +++ b/apps/clubs/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index d49ce7c..049edf3 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -2,8 +2,7 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' - -import { validateAndGetAccountId } from './jwt' +import { validateAndGetAccountId } from '@repo/jwt' import type { Context } from 'hono' import type { App } from './context' diff --git a/apps/clubs/src/jwt.ts b/apps/clubs/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/clubs/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/econ/package.json b/apps/econ/package.json index 72c961a..e6f3526 100644 --- a/apps/econ/package.json +++ b/apps/econ/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 9ac75d4..c9a3c8f 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -2,13 +2,13 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' import defaultAvatarItems from '../static/default-avatar-items.json' import defaultAvatar from '../static/default-avatar.json' import myProgress from '../static/my-progress.json' import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' -import { validateAndGetAccountId } from './jwt' import type { Context } from 'hono' import type { Avatar } from './avatar-db' diff --git a/apps/econ/src/jwt.ts b/apps/econ/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/econ/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/match/package.json b/apps/match/package.json index 526e26c..5ec3236 100644 --- a/apps/match/package.json +++ b/apps/match/package.json @@ -17,6 +17,7 @@ "dependencies": { "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/match/src/jwt.ts b/apps/match/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/match/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index f125693..6104394 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -3,8 +3,8 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { RoomInstanceType } from '@repo/domain' import { withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' -import { validateAndGetAccountId } from './jwt' import { createRoomInstance, getJoinableInstance, diff --git a/apps/playersettings/package.json b/apps/playersettings/package.json index b7bd2af..66d6ed6 100644 --- a/apps/playersettings/package.json +++ b/apps/playersettings/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/playersettings/src/jwt.ts b/apps/playersettings/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/playersettings/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/playersettings/src/playersettings.app.ts b/apps/playersettings/src/playersettings.app.ts index ecfe034..8edd1c4 100644 --- a/apps/playersettings/src/playersettings.app.ts +++ b/apps/playersettings/src/playersettings.app.ts @@ -2,9 +2,9 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' import { DEFAULT_SETTINGS } from './default-settings' -import { validateAndGetAccountId } from './jwt' import type { Context } from 'hono' import type { App } from './context' diff --git a/apps/rooms/package.json b/apps/rooms/package.json index 9f0ce09..42289ee 100644 --- a/apps/rooms/package.json +++ b/apps/rooms/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/rooms/src/jwt.ts b/apps/rooms/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/rooms/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 121a15d..99d16cb 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -2,8 +2,8 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { logger, withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' -import { validateAndGetAccountId } from './jwt' import { cloneRoom, findSubRoom, diff --git a/apps/storage/package.json b/apps/storage/package.json index b2d01d9..7469db0 100644 --- a/apps/storage/package.json +++ b/apps/storage/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/storage/src/jwt.ts b/apps/storage/src/jwt.ts deleted file mode 100644 index 01ff98d..0000000 --- a/apps/storage/src/jwt.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Minimal HS256 JWT validation. - * - * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets - * Store binding (see context.ts) - the same key the `auth` worker signs with. - */ - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} - -/** - * Validate an HS256 token and return its `sub` (account id) claim, or `null` - * when the token is malformed, has a bad signature, or is expired. - */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } - try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) - } catch { - return null - } - - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return claims.sub ?? null -} diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index f37f70b..38eca2d 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -2,8 +2,7 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' - -import { validateAndGetAccountId } from './jwt' +import { validateAndGetAccountId } from '@repo/jwt' import type { Context } from 'hono' import type { App } from './context' diff --git a/packages/jwt/README.md b/packages/jwt/README.md new file mode 100644 index 0000000..5eae785 --- /dev/null +++ b/packages/jwt/README.md @@ -0,0 +1,9 @@ +# jwt + +Shared HS256 JWT helpers for the recflare workers. Single source of truth for +token validation and generation, so the same signing/verification code isn't +re-copied per worker. + +`auth` signs tokens (`generateToken`); every worker validates the incoming +Bearer token (`validateAndGetAccountId`). Both take the signing key from the +shared `JWT_SECRET` binding (see each worker's `context.ts`). diff --git a/packages/jwt/package.json b/packages/jwt/package.json new file mode 100644 index 0000000..c9195a6 --- /dev/null +++ b/packages/jwt/package.json @@ -0,0 +1,17 @@ +{ + "name": "@repo/jwt", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "main": "src/index.ts", + "scripts": { + "check:lint": "run-oxlint", + "check:types": "run-tsc" + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20260630.1", + "@repo/tools": "workspace:*", + "@repo/typescript-config": "workspace:*" + } +} diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts new file mode 100644 index 0000000..3a6c4d3 --- /dev/null +++ b/packages/jwt/src/index.ts @@ -0,0 +1 @@ +export { validateAndGetAccountId, generateToken, TOKEN_TTL_SECONDS } from './jwt' diff --git a/apps/auth/src/jwt.ts b/packages/jwt/src/jwt.ts similarity index 91% rename from apps/auth/src/jwt.ts rename to packages/jwt/src/jwt.ts index d445d1a..1f83ab6 100644 --- a/apps/auth/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -1,8 +1,9 @@ /** - * Minimal HS256 JWT generation. + * Minimal HS256 JWT generation and validation. * - * The signing key is supplied by the caller from the `JWT_SECRET` binding - * (a Cloudflare secret in deployed envs, `.dev.vars` locally) — see context.ts. + * The signing key is supplied by the caller from the shared `JWT_SECRET` binding + * (a Cloudflare secret in deployed envs, `.dev.vars` locally) — see each worker's + * context.ts. `auth` signs tokens; every worker validates them with the same key. */ /** Token lifetime in seconds (mirrored in the `expires_in` response field). */ @@ -81,7 +82,7 @@ const TOKEN_SCOPES = [ ] /** Roles granted — the client needs `gameClient` to operate. */ -const TOKEN_ROLES = ['gameClient', /* 'developer', 'moderator', 'junior'*/]; +const TOKEN_ROLES = ['gameClient' /* 'developer', 'moderator', 'junior'*/] export async function generateToken( accountId: string, diff --git a/packages/jwt/tsconfig.json b/packages/jwt/tsconfig.json new file mode 100644 index 0000000..51f5939 --- /dev/null +++ b/packages/jwt/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript-config/workers-lib.json", + // jwt has no vitest of its own, so pull in only the Workers ambient types + // (drop @cloudflare/vitest-pool-workers/types that workers-lib.json adds). + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8686c61..e2f1fb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -90,6 +93,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -124,6 +130,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -155,6 +164,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -217,6 +229,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -279,6 +294,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -347,6 +365,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -440,6 +461,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -471,6 +495,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -502,6 +529,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.12.27 version: 4.12.27 @@ -626,6 +656,18 @@ importers: specifier: 4.1.9 version: 4.1.9(@types/node@26.0.1)(vite@6.4.3(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.29.2)(tsx@4.22.4)(yaml@2.9.0)) + packages/jwt: + devDependencies: + '@cloudflare/workers-types': + specifier: 4.20260630.1 + version: 4.20260630.1 + '@repo/tools': + specifier: workspace:* + version: link:../tools + '@repo/typescript-config': + specifier: workspace:* + version: link:../typescript-config + packages/oxlint-config: dependencies: oxlint: