mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
move JWT validation to package
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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`).
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { validateAndGetAccountId, generateToken, TOKEN_TTL_SECONDS } from './jwt'
|
||||
@@ -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,
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
Generated
+42
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user