File
Blob: src/worker/lib/auth.ts
| 1 | import { SignJWT, jwtVerify } from "jose"; |
| 2 | import { ulid } from "ulid"; |
| 3 | import type { Context } from "hono"; |
| 4 | import { deleteCookie, setCookie } from "hono/cookie"; |
| 5 | import type { CookieOptions } from "hono/utils/cookie"; |
| 6 | |
| 7 | import { SESSION_HINT_COOKIE } from "@/shared/auth"; |
| 8 | import { users } from "@/worker/db/d1/schema"; |
| 9 | import { JWT_ALGORITHM, REFRESH_COOKIE_MAX_AGE } from "@/worker/lib/constants"; |
| 10 | |
| 11 | export const REFRESH_COOKIE = "bland_refresh"; |
| 12 | |
| 13 | const BASE_COOKIE_OPTIONS = { |
| 14 | path: "/", |
| 15 | sameSite: "Strict", |
| 16 | secure: true, |
| 17 | } satisfies CookieOptions; |
| 18 | const REFRESH_COOKIE_OPTIONS = { |
| 19 | ...BASE_COOKIE_OPTIONS, |
| 20 | httpOnly: true, |
| 21 | maxAge: REFRESH_COOKIE_MAX_AGE, |
| 22 | } satisfies CookieOptions; |
| 23 | const SESSION_HINT_COOKIE_OPTIONS = { |
| 24 | ...BASE_COOKIE_OPTIONS, |
| 25 | maxAge: REFRESH_COOKIE_MAX_AGE, |
| 26 | } satisfies CookieOptions; |
| 27 | |
| 28 | export function getJwtSecret(env: Env): Uint8Array { |
| 29 | return new TextEncoder().encode(env.JWT_SECRET); |
| 30 | } |
| 31 | |
| 32 | export async function verifyAccessToken(token: string, env: Env): Promise<{ sub: string; jti: string }> { |
| 33 | const { payload } = await jwtVerify(token, getJwtSecret(env), { |
| 34 | algorithms: [JWT_ALGORITHM], |
| 35 | }); |
| 36 | |
| 37 | if (!payload.sub || !payload.jti) { |
| 38 | throw new Error("missing_claims"); |
| 39 | } |
| 40 | |
| 41 | if (payload.type === "refresh") { |
| 42 | throw new Error("refresh_token_misuse"); |
| 43 | } |
| 44 | |
| 45 | return { sub: payload.sub, jti: payload.jti }; |
| 46 | } |
| 47 | |
| 48 | export function generateSecureToken(): string { |
| 49 | const bytes = crypto.getRandomValues(new Uint8Array(32)); |
| 50 | let binary = ""; |
| 51 | for (let i = 0; i < bytes.length; i++) { |
| 52 | binary += String.fromCharCode(bytes[i]); |
| 53 | } |
| 54 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 55 | } |
| 56 | |
| 57 | export async function createAccessToken(userId: string, env: Env): Promise<string> { |
| 58 | return new SignJWT({ sub: userId, jti: ulid() }) |
| 59 | .setProtectedHeader({ alg: JWT_ALGORITHM }) |
| 60 | .setIssuedAt() |
| 61 | .setExpirationTime("15m") |
| 62 | .sign(getJwtSecret(env)); |
| 63 | } |
| 64 | |
| 65 | export async function createRefreshToken(userId: string, env: Env): Promise<string> { |
| 66 | return new SignJWT({ sub: userId, jti: ulid(), type: "refresh" }) |
| 67 | .setProtectedHeader({ alg: JWT_ALGORITHM }) |
| 68 | .setIssuedAt() |
| 69 | .setExpirationTime("7d") |
| 70 | .sign(getJwtSecret(env)); |
| 71 | } |
| 72 | |
| 73 | export function setRefreshCookie(c: Context, token: string): void { |
| 74 | setCookie(c, REFRESH_COOKIE, token, REFRESH_COOKIE_OPTIONS); |
| 75 | setCookie(c, SESSION_HINT_COOKIE, "1", SESSION_HINT_COOKIE_OPTIONS); |
| 76 | } |
| 77 | |
| 78 | export function clearRefreshCookie(c: Context): void { |
| 79 | deleteCookie(c, REFRESH_COOKIE, { ...BASE_COOKIE_OPTIONS, httpOnly: true }); |
| 80 | deleteCookie(c, SESSION_HINT_COOKIE, BASE_COOKIE_OPTIONS); |
| 81 | } |
| 82 | |
| 83 | export function toUserResponse(user: typeof users.$inferSelect) { |
| 84 | return { |
| 85 | id: user.id, |
| 86 | email: user.email, |
| 87 | name: user.name, |
| 88 | avatar_url: user.avatar_url, |
| 89 | created_at: user.created_at, |
| 90 | }; |
| 91 | } |