diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..dc31761 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: pilcrowOnPaper diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..aff1f24 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,33 @@ +name: "Publish" +on: + push: + branches: + - main + +env: + CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_PAGES_API_TOKEN}} + +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + steps: + - name: setup actions + uses: actions/checkout@v3 + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: 20.5.1 + registry-url: https://registry.npmjs.org + - name: install malta + working-directory: docs + run: | + curl -o malta.tgz -L https://github.com/pilcrowonpaper/malta/releases/latest/download/linux-amd64.tgz + tar -xvzf malta.tgz + - name: build + working-directory: docs + run: ./linux-amd64/malta build + - name: install wrangler + run: npm i -g wrangler + - name: deploy + run: wrangler pages deploy docs/dist --project-name lucia-next --branch main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fbf9f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist +pnpm-lock.yaml +node_modules +package-lock.json +.DS_Store \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d8ad2e7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.DS_Store +node_modules +/dist + +pnpm-lock.yaml +package-lock.json +yarn.lock + diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..36e09d0 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "useTabs": true, + "trailingComma": "none", + "printWidth": 120 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b335a8 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Lucia + +Private link (requires auth): https://lucia-next.pages.dev + +## Development + +Install [Malta](https://malta.pilcrowonpaper.com) and start the dev server. + +``` +malta dev +``` diff --git a/malta.config.json b/malta.config.json new file mode 100644 index 0000000..95b69a9 --- /dev/null +++ b/malta.config.json @@ -0,0 +1,42 @@ +{ + "name": "Lucia", + "description": "ok", + "domain": "https://lucia-auth.com", + "twitter": "@lucia_auth", + "asset_hashing": true, + "sidebar": [ + { + "title": "Sessions", + "pages": [ + ["Overview", "/sessions/overview"], + ["Database", "/sessions/database"], + ["Cookies", "/sessions/cookies"] + ] + }, + { + "title": "Tutorials", + "pages": [ + ["GitHub OAuth", "/tutorials/github-oauth"], + ["Google OAuth", "/tutorials/google-oauth"] + ] + }, + { + "title": "Example projects", + "pages": [ + ["GitHub OAuth", "/examples/github-oauth"], + ["Google OAuth", "/examples/google-oauth"], + ["Email and password with 2FA", "/examples/email-password-2fa"], + ["Email and password with 2FA and WebAuthn", "/examples/email-password-2fa-webauthn"] + ] + }, + { + "title": "Community", + "pages": [ + ["GitHub", "https://github.com/lucia-auth/lucia"], + ["Discord", "https://discord.com/invite/PwrK3kpVR3"], + ["Twitter", "https://x.com/lucia_auth"], + ["Donate", "https://github.com/sponsors/pilcrowOnPaper"] + ] + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..21c2ecd --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "lucia-next", + "scripts": { + "format": "prettier -w ." + }, + "repository": { + "type": "git", + "url": "https://github.com/lucia-auth/next" + }, + "author": "pilcrowOnPaper", + "license": "MIT", + "devDependencies": { + "prettier": "^3.0.3" + } +} diff --git a/pages/examples/email-password-2fa-webauthn.md b/pages/examples/email-password-2fa-webauthn.md new file mode 100644 index 0000000..fe057e8 --- /dev/null +++ b/pages/examples/email-password-2fa-webauthn.md @@ -0,0 +1,5 @@ +--- +title: "Email and password with 2FA and WebAuthn" +--- + +# Email and password with 2FA and WebAuthn diff --git a/pages/examples/email-password-2fa.md b/pages/examples/email-password-2fa.md new file mode 100644 index 0000000..54d1d73 --- /dev/null +++ b/pages/examples/email-password-2fa.md @@ -0,0 +1,5 @@ +--- +title: "Email and password with 2FA" +--- + +# Email and password with 2FA diff --git a/pages/examples/github-oauth.md b/pages/examples/github-oauth.md new file mode 100644 index 0000000..f4a979c --- /dev/null +++ b/pages/examples/github-oauth.md @@ -0,0 +1,14 @@ +--- +title: "GitHub OAuth" +--- + +# GitHub OAuth + +## Repositories + +- [Astro](https://github.com/lucia-auth/github-oauth-astro) +- [Next.js](https://github.com/lucia-auth/github-oauth-nextjs) +- [Nuxt](https://github.com/lucia-auth/github-oauth-nuxt) +- [SvelteKit](https://github.com/lucia-auth/github-oauth-sveltekit) + +## Important points diff --git a/pages/examples/google-oauth.md b/pages/examples/google-oauth.md new file mode 100644 index 0000000..32ead19 --- /dev/null +++ b/pages/examples/google-oauth.md @@ -0,0 +1,5 @@ +--- +title: "Google OAuth" +--- + +# Google OAuth diff --git a/pages/index.md b/pages/index.md new file mode 100644 index 0000000..c99927b --- /dev/null +++ b/pages/index.md @@ -0,0 +1,21 @@ +--- +title: "Lucia" +--- + +# Lucia + +Lucia is an open source resource on implementing authentication with JavaScript and TypeScript. + +The main section is on implementing sessions with your database, library, and framework of choice. Using the API you just defined, you can continue learning by going through the tutorials or by referencing the many fully-fledged examples. + +We also recommend checking out [the Copenhagen Book](https://thecopenhagenbook.com). This is a free, online resource covering the various auth concepts in web applications. + +## Why not a library? + +We've found it extremely hard to develop a library that: + +1. Supports the many database libraries, ORMs, frameworks, runtimes, and deployment options available in the ecosystem. +2. Provides enough flexibility for the majority of use cases. +3. Does not add significant complexity to projects. + +We came to the conclusion that at least for the core of auth - sessions - it's better to teach the code and concepts rather than to try cramming it into a library. The code is very straightforward and shouldn't take more than 10 minutes to write it once you understand it. As an added bonus, it's fully customizable. diff --git a/pages/sessions/cookies/astro.md b/pages/sessions/cookies/astro.md new file mode 100644 index 0000000..f733eb1 --- /dev/null +++ b/pages/sessions/cookies/astro.md @@ -0,0 +1,161 @@ +--- +title: "Session cookies in Astro" +--- + +# Session cookies in Astro + +_This page builds upon the API defined in the [Database](/sessions/database) page._ + +## CSRF protection + +CSRF protection is a must when using cookies. From Astro v5.0, basic CSRF protection using the `Origin` header is enabled by default. If you're using Astro v4, you must manually enable it by updating the config file. + +```ts +// astro.config.mjs +export default defineConfig({ + output: "server", + security: { + checkOrigin: false + } +}); +``` + +## Cookies + +Session cookies should have the following attributes: + +- `HttpOnly`: Cookies are only accessible server-side +- `SameSite=Lax`: Use `Strict` for critical websites +- `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) +- `Max-Age` or `Expires`: Must be defined to persist cookies +- `Path=/`: Cookies can be accessed from all routes + +```ts +import type { APIContext } from "astro"; + +// ... + +export async function createSession(userId: number): Promise { + // ... +} + +export async function validateSession(sessionId: string): Promise { + // ... +} + +export async function invalidateSession(sessionId: string): Promise { + // ... +} + +export function setSessionCookie(context: APIContext, session: Session): void { + context.cookies.set("session", session.id, { + httpOnly: true, + sameSite: "lax", + secure: import.meta.env.PROD, + expires: session.expiresAt, + path: "/" + }); +} + +export function deleteSessionCookie(context: APIContext): void { + context.cookies.set("session", "", { + httpOnly: true, + sameSite: "lax", + secure: import.meta.env.PROD, + maxAge: 0, + path: "/" + }); +} +``` + +## Session validation + +Sessions can be validated by getting the cookie and using the `validateSession()` function we created. If the session is invalid, delete the session cookie. Importantly, we recommend setting a new session cookie after validation to persist the cookie for an extended time. + +```ts +import { validateSession, deleteSessionCookie, setSessionCookie } from "$lib/server/auth"; + +import type { APIContext } from "astro"; + +export async function GET(context: APIContext): Promise { + const sessionId = context.cookies.get("session")?.value ?? null; + if (sessionId === null) { + return new Response(null, { + status: 401 + }); + } + + const { session, user } = await validateSession(sessionId); + if (session === null) { + deleteSessionCookie(context); + return new Response(null, { + status: 401 + }); + } + setSessionCookie(context, session); + + // ... +} +``` + +We recommend handling session validation in middleware and passing the current auth context to each route. + +```ts +// src/env.d.ts + +/// +declare namespace App { + // Note: 'import {} from ""' syntax does not work in .d.ts files. + interface Locals { + session: import("./lib/server/auth").Session | null; + user: import("./lib/server/auth").User | null; + } +} +``` + +```ts +// src/middleware.ts +import { validateSession, setSessionCookie, deleteSessionCookie } from "./lib/server/auth"; +import { defineMiddleware } from "astro:middleware"; + +export const onRequest = defineMiddleware(async (context, next) => { + const sessionId = context.cookies.get("session")?.value ?? null; + if (sessionId === null) { + context.locals.user = null; + context.locals.session = null; + return next(); + } + + const { session, user } = await validateSession(sessionId); + if (session !== null) { + setSessionCookie(context, session); + } else { + deleteSessionCookie(context); + } + + context.locals.session = session; + context.locals.user = user; + return next(); +}); +``` + +Both the current user and session will be available in Astro files and API endpoints. + +```ts +--- +if (Astro.locals.user === null) { + return Astro.redirect("/login") +} +--- +``` + +```ts +export function GET(context: APIContext): Promise { + if (context.locals.user === null) { + return new Response(null, { + status: 401 + }); + } + // ... +} +``` diff --git a/pages/sessions/cookies/index.md b/pages/sessions/cookies/index.md new file mode 100644 index 0000000..a26cb4b --- /dev/null +++ b/pages/sessions/cookies/index.md @@ -0,0 +1,133 @@ +--- +title: "Session cookies" +--- + +# Session cookies + +_This page builds upon the API defined in the [Database](/sessions/database) page._ + +Framework and library specific guides are also available: + +- [Astro](/sessions/cookies/astro) +- [Next.js](/sessions/cookies/nextjs) +- [Nuxt](/sessions/cookies/nuxt) +- [SvelteKit](/sessions/cookies/sveltekit) + +## CSRF protection + +CSRF protection is a must when using cookies. A very simple way to prevent CSRF attacks is to check the `Origin` header for non-GET requests. If you rely on this method, it is crucial that your application does not use GET requests for modifying resources. + +```ts +// `HTTPRequest` and `HTTPResponse` are generic interfaces. +// Adjust this code to fit your framework's API. + +function handleRequest(request: HTTPRequest, response: HTTPResponse): void { + if (request.method !== "GET") { + const origin = request.headers.get("Origin"); + // You can also compare it against the Host or X-Forwarded-Host header. + if (origin === null || origin !== "https://example.com") { + response.setStatusCode(403); + return; + } + } + + // ... +} +``` + +## Cookies + +If the frontend and backend are hosted on the same domain, session cookies should have the following attributes: + +- `HttpOnly`: Cookies are only accessible server-side +- `SameSite=Lax`: Use `Strict` for critical websites +- `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) +- `Max-Age` or `Expires`: Must be defined to persist cookies +- `Path=/`: Cookies can be accessed from all routes + +```ts +export async function createSession(userId: number): Promise { + // ... +} + +export async function validateSession(sessionId: string): Promise { + // ... +} + +export async function invalidateSession(sessionId: string): Promise { + // ... +} + +// `HTTPResponse` is a generic interface. +// Adjust this code to fit your framework's API. + +export function setSessionCookie(response: HTTPResponse, session: Session): void { + if (env === Env.PROD) { + // When deployed over HTTPS + response.headers.add( + "Set-Cookie", + `session=${session.id}; HttpOnly; SameSite=Lax; Expires=${session.expiresAt.toUTCString()}; Path=/; Secure;` + ); + } else { + // When deployed over HTTP (localhost) + response.headers.add( + "Set-Cookie", + `session=${session.id}; HttpOnly; SameSite=Lax; Expires=${session.expiresAt.toUTCString()}; Path=/` + ); + } +} + +export function deleteSessionCookie(response: HTTPResponse): void { + if (env === Env.PROD) { + // When deployed over HTTPS + response.headers.add("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure;"); + } else { + // When deployed over HTTP (localhost) + response.headers.add("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/"); + } +} + +interface Session { + id: string; + userId: number; + expiresAt: Date; +} +``` + +## Session validation + +Sessions can be validated by getting the cookie and using the `validateSession()` function we created. If the session is invalid, delete the session cookie. Importantly, we recommend setting a new session cookie after validation to persist the cookie for an extended time. + +```ts +// `HTTPRequest` and `HTTPResponse` are generic interfaces. +// Adjust this code to fit your framework's API. + +function handleRequest(request: HTTPRequest, response: HTTPResponse): void { + // csrf protection + if (request.method !== "GET") { + const origin = request.headers.get("Origin"); + // You can also compare it against the Host or X-Forwarded-Host header. + if (origin === null || origin !== "https://example.com") { + response.setStatusCode(403); + return; + } + } + + // session validation + const cookies = parseCookieHeader(request.headers.get("Cookie") ?? ""); + const sessionId = cookies.get("session"); + if (sessionId === null) { + response.setStatusCode(401); + return; + } + + const { session, user } = await validateSession(sessionId); + if (session === null) { + response.setStatusCode(401); + return; + } + setSessionCookie(response, session); + + // ... +} +``` diff --git a/pages/sessions/cookies/nextjs.md b/pages/sessions/cookies/nextjs.md new file mode 100644 index 0000000..b5aee82 --- /dev/null +++ b/pages/sessions/cookies/nextjs.md @@ -0,0 +1,203 @@ +--- +title: "Next.js" +--- + +# Session cookies in Next.js + +_This page builds upon the API defined in the [Database](/sessions/database) page._ + +## CSRF protection + +CSRF protection is a must when using cookies. While Next.js provides built-in CSRF protection for server actions, regular route handlers are not protected. As such, we recommend implementing CSRF protection globally via middleware as a precaution. + +```ts +// middleware.ts +import { NextResponse } from "next/server"; + +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest): Promise { + if (request.method === "GET") { + return NextResponse.next(); + } + const originHeader = request.headers.get("Origin"); + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = request.headers.get("Host"); + if (originHeader === null || hostHeader === null) { + return new NextResponse(null, { + status: 403 + }); + } + if (new URL(originHeader).host !== hostHeader) { + return new NextResponse(null, { + status: 403 + }); + } + return NextResponse.next(); +} +``` + +## Cookies + +Session cookies should have the following attributes: + +- `HttpOnly`: Cookies are only accessible server-side +- `SameSite=Lax`: Use `Strict` for critical websites +- `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) +- `Max-Age` or `Expires`: Must be defined to persist cookies +- `Path=/`: Cookies can be accessed from all routes + +```ts +import { cookies } from "next/headers"; + +// ... + +export async function createSession(userId: number): Promise { + // ... +} + +export async function validateSession(sessionId: string): Promise { + // ... +} + +export async function invalidateSession(sessionId: string): Promise { + // ... +} + +export function setSessionCookie(session: Session): void { + cookies().set("session", session.id, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + expires: session.expiresAt, + path: "/" + }); +} + +export function deleteSessionCookie(): void { + cookies().set("session", "", { + httpOnly: true, + sameSite: "lax", + secure: import.meta.env.PROD, + maxAge: 0, + path: "/" + }); +} +``` + +Since we can't extend set cookies in server components due to a limitation with Next.js, we recommend continuously extending the cookie expiration inside middleware. + +```ts +// middleware.ts +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest): Promise { + const sessionId = cookies().get("session")?.value ?? null; + if (sessionId !== null) { + // Not using `setSessionCookie()` to avoid accidentally importing Node-only modules. + cookies().set("session", sessionId, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 30, + path: "/" + }); + } + + // CSRF protection, etc + + return NextResponse.next(); +} +``` + +## Session validation + +Sessions can be validated by getting the cookie and using the `validateSession()` function we created. If the session is invalid, delete the session cookie. + +```ts +import { validateSession, deleteSessionCookie, setSessionCookie } from "$lib/server/auth"; + +import type { APIContext } from "astro"; + +export async function GET(context: APIContext): Promise { + const sessionId = cookies().get("session")?.value ?? null; + if (sessionId === null) { + return new Response(null, { + status: 401 + }); + } + + const { session, user } = await validateSession(sessionId); + if (session === null) { + deleteSessionCookie(context); + return new Response(null, { + status: 401 + }); + } + + // ... +} +``` + +We recommend creating a reusable function and wrapping the it with `cache()` so it can be called multiple times without incurring multiple database calls. + +```ts +import { cookies } from "next/headers"; +import { cache } from "react"; + +export async function createSession(userId: number): Promise { + // ... +} + +export async function validateSession(sessionId: string): Promise { + // ... +} + +export async function invalidateSession(sessionId: string): Promise { + // ... +} + +export function setSessionCookie(session: Session): void { + // ... +} + +export function deleteSessionCookie(): void { + // ... +} + +export const getCurrentSession = cache(async (): Promise => { + const sessionId = cookies().get("session")?.value ?? null; + if (sessionId === null) { + return { session: null, user: null }; + } + const result = await lucia.validateSession(sessionId); + return result; +}); +``` + +Use `getCurrentSession()` to get the current user in server components, server actions, and route handlers. Keep in mind that each server action function + +```ts +// app/api/page.tsx +import { redirect } from "next/navigation"; + +async function Page() { + const { user } = await getUser(); + if (user === null) { + redirect("/login"); + } + + async function action() { + "use server"; + const { user } = await getUser(); + if (user === null) { + redirect("/login"); + } + // ... + } + // ... +} +``` diff --git a/pages/sessions/cookies/nuxt.md b/pages/sessions/cookies/nuxt.md new file mode 100644 index 0000000..5ff65e1 --- /dev/null +++ b/pages/sessions/cookies/nuxt.md @@ -0,0 +1,7 @@ +--- +title: "Nuxt" +--- + +# Session cookies in Nuxt + +_This page builds upon the API defined in the [Database](/sessions/database) page._ diff --git a/pages/sessions/cookies/sveltekit.md b/pages/sessions/cookies/sveltekit.md new file mode 100644 index 0000000..11b1761 --- /dev/null +++ b/pages/sessions/cookies/sveltekit.md @@ -0,0 +1,160 @@ +--- +title: "Session cookies in SvelteKit" +--- + +# Session cookies in SvelteKit + +_This page builds upon the API defined in the [Database](/sessions/database) page._ + +## Cookies + +CSRF protection is a must when using cookies. SvelteKit has basic CSRF protection using the `Origin` header is enabled by default. + +Session cookies should have the following attributes: + +- `HttpOnly`: Cookies are only accessible server-side +- `SameSite=Lax`: Use `Strict` for critical websites +- `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) +- `Max-Age` or `Expires`: Must be defined to persist cookies +- `Path=/`: Cookies can be accessed from all routes + +SvelteKit automatically sets the `Secure` flag when deployed to production. + +```ts +import type { RequestEvent } from "@sveltejs/kit"; + +// ... + +export async function createSession(userId: number): Promise { + // ... +} + +export async function validateSession(sessionId: string): Promise { + // ... +} + +export async function invalidateSession(sessionId: string): Promise { + // ... +} + +export function setSessionCookie(event: RequestEvent, session: Session): void { + context.cookies.set("session", session.id, { + httpOnly: true, + sameSite: "lax", + expires: session.expiresAt, + path: "/" + }); +} + +export function deleteSessionCookie(event: RequestEvent): void { + context.cookies.set("session", "", { + httpOnly: true, + sameSite: "lax", + maxAge: 0, + path: "/" + }); +} +``` + +## Session validation + +Sessions can be validated by getting the cookie and using the `validateSession()` function we created. If the session is invalid, delete the session cookie. Importantly, we recommend setting a new session cookie after validation to persist the cookie for an extended time. + +```ts +// +page.server.ts +import { fail, redirect } from "@sveltejs/kit"; +import { validateSession, deleteSessionCookie, setSessionCookie } from "$lib/server/auth"; + +import type { Actions, PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + if (event.locals.user === null) { + redirect("/login"); + } + // ... +}; +``` + +We recommend handling session validation in the handle hook and passing the current auth context to each route. + +```ts +// src/app.d.ts + +declare global { + namespace App { + // Note: 'import {} from ""' syntax does not work in .d.ts files. + interface Locals { + session: import("./lib/server/auth").Session | null; + user: import("./lib/server/auth").User | null; + } + } +} + +export {}; +``` + +```ts +// src/hooks.server.ts +import { validateSession, setSessionCookie, deleteSessionCookie } from "./lib/server/auth"; + +import type { Handle } from "@sveltejs/kit"; + +export const handle: Handle = async ({ event, resolve }) => { + const sessionId = event.cookies.get("session") ?? null; + if (sessionId === null) { + event.locals.user = null; + event.locals.session = null; + return next(); + } + + const { session, user } = await validateSession(sessionId); + if (session !== null) { + setSessionCookie(event, session); + } else { + deleteSessionCookie(event); + } + + event.locals.session = session; + event.locals.user = user; + return next(); +}; +``` + +Both the current user and session will be available in loaders, actions, and endpoints. + +```ts +// +page.server.ts +import { fail, redirect } from "@sveltejs/kit"; + +import type { Actions, PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + if (event.locals.user === null) { + redirect("/login"); + } + // ... +}; + +export const actions: Actions = { + default: async (event) => { + if (event.locals.user === null) { + throw fail(401); + } + // ... + } +}; +``` + +```ts +// +server.ts +import { lucia } from "$lib/server/auth"; + +export function GET(event: RequestEvent): Promise { + if (event.locals.user === null) { + return new Response(null, { + status: 401 + }); + } + // ... +} +``` diff --git a/pages/sessions/database/drizzle-orm.md b/pages/sessions/database/drizzle-orm.md new file mode 100644 index 0000000..79a3bbe --- /dev/null +++ b/pages/sessions/database/drizzle-orm.md @@ -0,0 +1,256 @@ +--- +title: "Sessions with Drizzle ORM" +--- + +# Sessions with Drizzle ORM + +## Declare your schema + +Create a session model with a field for an ID, user ID, and expiration. + +### MySQL + +```ts +import mysql from "mysql2/promise"; +import { mysqlTable, int, varchar, datetime } from "drizzle-orm/mysql-core"; +import { drizzle } from "drizzle-orm/mysql2"; + +import type { InferSelectModel } from "drizzle-orm"; + +const connection = await mysql.createConnection(); +const db = drizzle(connection); + +export const userTable = mysqlTable("user", { + id: int("id").primaryKey().autoincrement() +}); + +export const sessionTable = mysqlTable("session", { + id: varchar("id", { + length: 255 + }).primaryKey(), + userId: int("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: datetime("expires_at").notNull() +}); + +export type User = InferSelectModel; +export type Session = InferSelectModel; +``` + +### PostgreSQL + +```ts +import pg from "pg"; +import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core"; +import { drizzle } from "drizzle-orm/node-postgres"; + +const pool = new pg.Pool(); +const db = drizzle(pool); + +const userTable = pgTable("user", { + id: serial("id").primaryKey() +}); + +const sessionTable = pgTable("session", { + id: text("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: timestamp("expires_at", { + withTimezone: true, + mode: "date" + }).notNull() +}); + +export type User = InferSelectModel; +export type Session = InferSelectModel; +``` + +### SQLite + +```ts +import sqlite from "better-sqlite3"; +import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core"; +import { drizzle } from "drizzle-orm/better-sqlite3"; + +const sqliteDB = sqlite(":memory:"); +const db = drizzle(sqliteDB); + +const userTable = sqliteTable("user", { + id: integer("id").primaryKey() +}); + +const sessionTable = sqliteTable("session", { + id: text("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: integer("expires_at", { + mode: "timestamp" + }).notNull() +}); + +export type User = InferSelectModel; +export type Session = InferSelectModel; +``` + +## Create your API + +Here's what our API will look like. What each method does should be pretty self explanatory. + +```ts +import type { User, Session } from "./db.js"; + +export async function createSession(userId: number): Promise { + // TODO +} + +export async function validateSession(sessionId: string): Promise { + // TODO +} + +export async function invalidateSession(sessionId: string): Promise { + // TODO +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; +``` + +The session ID should be a random string. We recommend generating at least 20 random bytes from a secure source (**DO NOT USE `Math.random()`**) and encoding it with base32. You can use any encoding schemes, but base32 is case insensitive unlike base64 and only uses alphanumeric letters while being more compact than hex encoding. We'll set the expiration to 30 days. + +The example uses the Web Crypto API for generating random bytes, which is available in most modern runtimes. If your runtime doesn't support it, similar runtime-specific alternatives are available. Do not use user-land RNGs. + +- [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. +- [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. +- [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. + +```ts +import { db, userTable, sessionTable } from "./db.js"; +import { eq } from "drizzle-orm"; + +// ... + +export async function createSession(userId: number): Session { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await db.insert(sessionTable).values(session); + return session; +} +``` + +Sessions are validated in 2 steps: + +1. Does the session exist in your database? +2. Is it still within expiration? + +We'll also extend the session expiration when it's close to expiration. This ensures active sessions are persisted, while inactive ones will eventually expire. We'll handle this by checking if there's less than 15 days (half of the 30 day expiration) before expiration. + +For convenience, we'll return both the session and user object tied to the session ID. + +```ts +import { db, userTable, sessionTable } from "./db.js"; +import { eq } from "drizzle-orm"; + +// ... + +export async function validateSession(sessionId: string): Promise { + const result = await db + .select({ user: userTable, session: sessionTable }) + .from(sessionTable) + .innerJoin(userTable, eq(sessionTable.userId, userTable.id)) + .where(eq(sessionTable.id, sessionId)); + if (result.length < 1) { + return { session: null, user: null }; + } + const { user, session } = result[0]; + if (Date.now() >= session.expiresAt.getTime()) { + await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); + return { session: null, user: null }; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await db + .update(sessionTable) + .set({ + expiresAt: session.expiresAt + }) + .where(eq(sessionTable.id, session.id)); + } + return { session, user }; +} +``` + +Finally, invalidate sessions by simply deleting it from the database. + +```ts +import { eq } from "drizzle-orm"; +import { db, userTable, sessionTable } from "./db.js"; + +// ... + +export async function invalidateSession(sessionId: string): void { + await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); +} +``` + +Here's the full code: + +```ts +import { db, userTable, sessionTable } from "./db.js"; +import { eq } from "drizzle-orm"; +import { encodeBase32 } from "@oslojs/encoding"; + +import type { User, Session } from "./db.js"; + +export async function createSession(userId: number): Session { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await db.insert(sessionTable).values(session); + return session; +} + +export async function validateSession(sessionId: string): Promise { + const result = await db + .select({ user: userTable, session: sessionTable }) + .from(sessionTable) + .innerJoin(userTable, eq(sessionTable.userId, userTable.id)) + .where(eq(sessionTable.id, sessionId)); + if (result.length < 1) { + return { session: null, user: null }; + } + const { user, session } = result[0]; + if (Date.now() >= session.expiresAt.getTime()) { + await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); + return { session: null, user: null }; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await db + .update(sessionTable) + .set({ + expiresAt: session.expiresAt + }) + .where(eq(sessionTable.id, session.id)); + } + return { session, user }; +} + +export async function invalidateSession(sessionId: string): void { + await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; +``` diff --git a/pages/sessions/database/index.md b/pages/sessions/database/index.md new file mode 100644 index 0000000..386bf58 --- /dev/null +++ b/pages/sessions/database/index.md @@ -0,0 +1,12 @@ +--- +title: "Session storage" +--- + +# Session storage + +- [Drizzle ORM](/sessions/database/drizzle-orm) +- [Redis](/sessions/database/redis) +- [Prisma](/sessions/database/prisma) +- [MySQL](/sessions/database/mysql) +- [PostgreSQL](/sessions/database/postgresql) +- [SQLite](/sessions/database/sqlite) diff --git a/pages/sessions/database/mysql.md b/pages/sessions/database/mysql.md new file mode 100644 index 0000000..9f9bbba --- /dev/null +++ b/pages/sessions/database/mysql.md @@ -0,0 +1,217 @@ +--- +title: "Sessions with MySQL" +--- + +# Sessions with MySQL + +## Declare your schema + +Create a session table with a field for a text ID, user ID, and expiration. + +``` +CREATE TABLE user ( + id INT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE user_session ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + user_id INT NOT NULL REFERENCES user(id), + expires_at DATETIME NOT NULL, +); +``` + +## Create your API + +Here's what our API will look like. What each method does should be pretty self explanatory. + +```ts +import { db } from "./db.js"; + +export async function createSession(userId: number): Promise { + // TODO +} + +export async function validateSession(sessionId: string): Promise { + // TODO +} + +export async function invalidateSession(sessionId: string): Promise { + // TODO +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} + +export interface User { + id: number; +} +``` + +The session ID should be a random string. We recommend generating at least 20 random bytes from a secure source (**DO NOT USE `Math.random()`**) and encoding it with base32. You can use any encoding schemes, but base32 is case insensitive unlike base64 and only uses alphanumeric letters while being more compact than hex encoding. We'll set the expiration to 30 days. + +The example uses the Web Crypto API for generating random bytes, which is available in most modern runtimes. If your runtime doesn't support it, similar runtime-specific alternatives are available. Do not use user-land RNGs. + +- [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. +- [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. +- [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. + +```ts +import { db } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +// ... + +export async function createSession(userId: number): Promise { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await db.execute( + "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", + session.id, + session.userId, + session.expiresAt + ); + return session; +} +``` + +Sessions are validated in 2 steps: + +1. Does the session exist in your database? +2. Is it still within expiration? + +We'll also extend the session expiration when it's close to expiration. This ensures active sessions are persisted, while inactive ones will eventually expire. We'll handle this by checking if there's less than 15 days (half of the 30 day expiration) before expiration. + +For convenience, we'll return both the session and user object tied to the session ID. + +```ts +import { db } from "./db.js"; + +// ... + +export async function validateSession(sessionId: string): Promise { + const row = await db.queryOne( + "SELECT user_session.id, user_session.user_id, user_session.expires_at, user.id FROM user_session INNER JOIN user ON user.id = user_session.user_id WHERE id = ?", + sessionId + ); + if (row === null) { + return { session: null, user: null }; + } + const session: Session = { + id: row[0], + userId: row[1], + expiresAt: new Date(row[2] * 1000) + }; + const user: User = { + id: row[3] + }; + if (Date.now() >= session.expiresAt.getTime()) { + await db.execute("DELETE FROM user_session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await db.execute( + "UPDATE user_session SET expires_at = ? WHERE id = ?", + Math.floor(session.expiresAt / 1000), + session.id + ); + } + return { session, user }; +} +``` + +Finally, invalidate sessions by simply deleting it from the database. + +```ts +import { db } from "./db.js"; + +// ... + +export async function invalidateSession(sessionId: string): Promise { + await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); +} +``` + +Here's the full code: + +```ts +import { db } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +export async function createSession(userId: number): Promise { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await db.execute( + "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", + session.id, + session.userId, + session.expiresAt + ); + return session; +} + +export async function validateSession(sessionId: string): Promise { + const row = await db.queryOne( + "SELECT user_session.id, user_session.user_id, user_session.expires_at, user.id FROM user_session INNER JOIN user ON user.id = user_session.user_id WHERE id = ?", + sessionId + ); + if (row === null) { + return { session: null, user: null }; + } + const session: Session = { + id: row[0], + userId: row[1], + expiresAt: new Date(row[2] * 1000) + }; + const user: User = { + id: row[3] + }; + if (Date.now() >= session.expiresAt.getTime()) { + await db.execute("DELETE FROM user_session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await db.execute( + "UPDATE user_session SET expires_at = ? WHERE id = ?", + Math.floor(session.expiresAt / 1000), + session.id + ); + } + return { session, user }; +} + +export async function invalidateSession(sessionId: string): Promise { + await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} + +export interface User { + id: number; +} +``` diff --git a/pages/sessions/database/postgresql.md b/pages/sessions/database/postgresql.md new file mode 100644 index 0000000..5a27f36 --- /dev/null +++ b/pages/sessions/database/postgresql.md @@ -0,0 +1,217 @@ +--- +title: "Sessions with PostgreSQL" +--- + +# Sessions with PostgreSQL + +## Declare your schema + +Create a session table with a field for a text ID, user ID, and expiration. + +``` +CREATE TABLE app_user ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE +); + +CREATE TABLE user_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES app_user(id), + expires_at TIMESTAMPTZ NOT NULL, +); +``` + +## Create your API + +Here's what our API will look like. What each method does should be pretty self explanatory. + +```ts +import { db } from "./db.js"; + +export async function createSession(userId: number): Promise { + // TODO +} + +export async function validateSession(sessionId: string): Promise { + // TODO +} + +export async function invalidateSession(sessionId: string): Promise { + // TODO +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} + +export interface User { + id: number; +} +``` + +The session ID should be a random string. We recommend generating at least 20 random bytes from a secure source (**DO NOT USE `Math.random()`**) and encoding it with base32. You can use any encoding schemes, but base32 is case insensitive unlike base64 and only uses alphanumeric letters while being more compact than hex encoding. We'll set the expiration to 30 days. + +The example uses the Web Crypto API for generating random bytes, which is available in most modern runtimes. If your runtime doesn't support it, similar runtime-specific alternatives are available. Do not use user-land RNGs. + +- [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. +- [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. +- [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. + +```ts +import { db } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +// ... + +export async function createSession(userId: number): Promise { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await db.execute( + "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", + session.id, + session.userId, + session.expiresAt + ); + return session; +} +``` + +Sessions are validated in 2 steps: + +1. Does the session exist in your database? +2. Is it still within expiration? + +We'll also extend the session expiration when it's close to expiration. This ensures active sessions are persisted, while inactive ones will eventually expire. We'll handle this by checking if there's less than 15 days (half of the 30 day expiration) before expiration. + +For convenience, we'll return both the session and user object tied to the session ID. + +```ts +import { db } from "./db.js"; + +// ... + +export async function validateSession(sessionId: string): Promise { + const row = await db.queryOne( + "SELECT user_session.id, user_session.user_id, user_session.expires_at, app_user.id FROM user_session INNER JOIN user ON app_user.id = user_session.user_id WHERE id = ?", + sessionId + ); + if (row === null) { + return { session: null, user: null }; + } + const session: Session = { + id: row[0], + userId: row[1], + expiresAt: new Date(row[2] * 1000) + }; + const user: User = { + id: row[3] + }; + if (Date.now() >= session.expiresAt.getTime()) { + await db.execute("DELETE FROM user_session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await db.execute( + "UPDATE user_session SET expires_at = ? WHERE id = ?", + Math.floor(session.expiresAt / 1000), + session.id + ); + } + return { session, user }; +} +``` + +Finally, invalidate sessions by simply deleting it from the database. + +```ts +import { db } from "./db.js"; + +// ... + +export async function invalidateSession(sessionId: string): Promise { + await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); +} +``` + +Here's the full code: + +```ts +import { db } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +export async function createSession(userId: number): Promise { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await db.execute( + "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", + session.id, + session.userId, + session.expiresAt + ); + return session; +} + +export async function validateSession(sessionId: string): Promise { + const row = await db.queryOne( + "SELECT user_session.id, user_session.user_id, user_session.expires_at, app_user.id FROM user_session INNER JOIN user ON app_user.id = user_session.user_id WHERE id = ?", + sessionId + ); + if (row === null) { + return { session: null, user: null }; + } + const session: Session = { + id: row[0], + userId: row[1], + expiresAt: new Date(row[2] * 1000) + }; + const user: User = { + id: row[3] + }; + if (Date.now() >= session.expiresAt.getTime()) { + await db.execute("DELETE FROM user_session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await db.execute( + "UPDATE user_session SET expires_at = ? WHERE id = ?", + Math.floor(session.expiresAt / 1000), + session.id + ); + } + return { session, user }; +} + +export async function invalidateSession(sessionId: string): Promise { + await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} + +export interface User { + id: number; +} +``` diff --git a/pages/sessions/database/prisma.md b/pages/sessions/database/prisma.md new file mode 100644 index 0000000..5af4781 --- /dev/null +++ b/pages/sessions/database/prisma.md @@ -0,0 +1,195 @@ +--- +title: "Sessions with Prisma" +--- + +# Sessions with Prisma + +## Declare your schema + +Create a session model with a field for a text ID, user ID, and expiration. + +``` +model User { + id Int @id @default(autoincrement()) + sessions Session[] +} + +model Session { + id String @id + userId Int + expiresAt DateTime + + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +## Create your API + +Here's what our API will look like. What each method does should be pretty self explanatory. + +```ts +import type { User, Session } from "@prisma/client"; + +export async function createSession(userId: number): Promise { + // TODO +} + +export async function validateSession(sessionId: string): Promise { + // TODO +} + +export async function invalidateSession(sessionId: string): Promise { + // TODO +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; +``` + +The session ID should be a random string. We recommend generating at least 20 random bytes from a secure source (**DO NOT USE `Math.random()`**) and encoding it with base32. You can use any encoding schemes, but base32 is case insensitive unlike base64 and only uses alphanumeric letters while being more compact than hex encoding. We'll set the expiration to 30 days. + +The example uses the Web Crypto API for generating random bytes, which is available in most modern runtimes. If your runtime doesn't support it, similar runtime-specific alternatives are available. Do not use user-land RNGs. + +- [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. +- [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. +- [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. + +```ts +import { prisma } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +// ... + +export async function createSession(userId: number): Session { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await prisma.session.create({ + data: session + }); + return session; +} +``` + +Sessions are validated in 2 steps: + +1. Does the session exist in your database? +2. Is it still within expiration? + +We'll also extend the session expiration when it's close to expiration. This ensures active sessions are persisted, while inactive ones will eventually expire. We'll handle this by checking if there's less than 15 days (half of the 30 day expiration) before expiration. + +For convenience, we'll return both the session and user object tied to the session ID. + +```ts +import { prisma } from "./db.js"; + +// ... + +export async function validateSession(sessionId: string): Promise { + const result = await prisma.session.findUnique({ + where: { + id: sessionId + }, + include: { + user: true + } + }); + if (result === null) { + return { session: null, user: null }; + } + const { user, ...session } = result; + if (Date.now() >= session.expiresAt.getTime()) { + await prisma.session.delete(sessionId); + return { session: null, user: null }; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await prisma.session.update({ + where: { + id: session.id + }, + data: { + expiresAt: session.expiresAt + } + }); + } + return { session, user }; +} +``` + +Finally, invalidate sessions by simply deleting it from the database. + +```ts +import { prisma } from "./db.js"; + +// ... + +export async function invalidateSession(sessionId: string): void { + await db.session.delete(sessionId); +} +``` + +Here's the full code: + +```ts +import { db } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +import type { User, Session } from "@prisma/client"; + +export async function createSession(userId: number): Session { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await prisma.session.create({ + data: session + }); + return session; +} + +export async function validateSession(sessionId: string): Promise { + const result = await prisma.session.findUnique({ + where: { + id: sessionId + }, + include: { + user: true + } + }); + if (result === null) { + return { session: null, user: null }; + } + const { user, ...session } = result; + if (Date.now() >= session.expiresAt.getTime()) { + await prisma.session.delete(sessionId); + return { session: null, user: null }; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await prisma.session.update({ + where: { + id: session.id + }, + data: { + expiresAt: session.expiresAt + } + }); + } + return { session, user }; +} + +export async function invalidateSession(sessionId: string): void { + await db.session.delete(sessionId); +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; +``` diff --git a/pages/sessions/database/redis.md b/pages/sessions/database/redis.md new file mode 100644 index 0000000..7e3d8f9 --- /dev/null +++ b/pages/sessions/database/redis.md @@ -0,0 +1,180 @@ +--- +title: "Sessions with Redis" +--- + +# Sessions with Redis + +## Create your API + +Here's what our API will look like. What each method does should be pretty self explanatory. + +```ts +import { redis } from "./redis.js"; + +export async function createSession(userId: number): Promise { + // TODO +} + +export async function validateSession(sessionId: string): Promise { + // TODO +} + +export async function invalidateSession(sessionId: string): Promise { + // TODO +} + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} +``` + +The session ID should be a random string. We recommend generating at least 20 random bytes from a secure source (**DO NOT USE `Math.random()`**) and encoding it with base32. You can use any encoding schemes, but base32 is case insensitive unlike base64 and only uses alphanumeric letters while being more compact than hex encoding. We'll set the expiration to 30 days. + +The example uses the Web Crypto API for generating random bytes, which is available in most modern runtimes. If your runtime doesn't support it, similar runtime-specific alternatives are available. Do not use user-land RNGs. + +- [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. +- [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. +- [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. + +```ts +import { redis } from "./redis.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +// ... + +export async function createSession(userId: number): Promise { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await redis.set( + `session:${session.id}`, + JSON.stringify({ + id: session.id, + user_id: session.userId, + expires_at: Math.floor(session.expiresAt / 1000) + }), + { + EXAT: Math.floor(session.expiresAt / 1000) + } + ); + return session; +} +``` + +Sessions are validated in 2 steps: + +1. Does the session exist in your database? +2. Is it still within expiration? + +We'll also extend the session expiration when it's close to expiration. This ensures active sessions are persisted, while inactive ones will eventually expire. We'll handle this by checking if there's less than 15 days (half of the 30 day expiration) before expiration. + +For convenience, we'll return both the session and user object tied to the session ID. + +```ts +import { redis } from "./redis.js"; + +// ... + +export async function validateSession(sessionId: string): Promise { + const item = await redis.get(`session:${sessionId}`); + if (item === null) { + return { session: null, user: null }; + } + const result = JSON.parse(item); + const session: Session = { + id: result.id, + userId: result.user_id, + expiresAt: new Date(result.expires_at * 1000) + }; + if (Date.now() >= session.expiresAt.getTime()) { + db.execute("DELETE FROM session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + db.execute("UPDATE session SET expires_at = ? WHERE id = ?", Math.floor(session.expiresAt / 1000), session.id); + } + return session; +} +``` + +Finally, invalidate sessions by simply deleting it from the database. + +```ts +import { redis } from "./redis.js"; + +// ... + +export async function invalidateSession(sessionId: string): Promise { + await redis.delete(sessionId); +} +``` + +Here's the full code: + +```ts +import { redis } from "./redis.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +export async function createSession(userId: number): Promise { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + await redis.set( + `session:${session.id}`, + JSON.stringify({ + id: session.id, + user_id: session.userId, + expires_at: Math.floor(session.expiresAt / 1000) + }), + { + EXAT: Math.floor(session.expiresAt / 1000) + } + ); + return session; +} + +export async function validateSession(sessionId: string): Promise { + const item = await redis.get(`session:${sessionId}`); + if (item === null) { + return { session: null, user: null }; + } + const result = JSON.parse(item); + const session: Session = { + id: result.id, + userId: result.user_id, + expiresAt: new Date(result.expires_at * 1000) + }; + if (Date.now() >= session.expiresAt.getTime()) { + db.execute("DELETE FROM session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + db.execute("UPDATE session SET expires_at = ? WHERE id = ?", Math.floor(session.expiresAt / 1000), session.id); + } + return session; +} + +export async function invalidateSession(sessionId: string): Promise { + await redis.delete(sessionId); +} + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} +``` diff --git a/pages/sessions/database/sqlite.md b/pages/sessions/database/sqlite.md new file mode 100644 index 0000000..6c95471 --- /dev/null +++ b/pages/sessions/database/sqlite.md @@ -0,0 +1,210 @@ +--- +title: "Sessions with SQLite" +--- + +# Sessions with SQLite + +## Declare your schema + +Create a session table with a field for a text ID, user ID, and expiration. We'll store the expiration date as a UNIX timestamp (seconds) but how you store these attributes is up to you. + +``` +CREATE TABLE user ( + id INTEGER NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE +); + + +CREATE TABLE session ( + id TEXT NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user(id), + expires_at INTEGER NOT NULL +); +``` + +## Create your API + +Here's what our API will look like. What each method does should be pretty self explanatory. + +```ts +import { db } from "./db.js"; + +export function createSession(userId: number): Session { + // TODO +} + +export function validateSession(sessionId: string): SessionValidationResult { + // TODO +} + +export function invalidateSession(sessionId: string): void { + // TODO +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} + +export interface User { + id: number; +} +``` + +The session ID should be a random string. We recommend generating at least 20 random bytes from a secure source (**DO NOT USE `Math.random()`**) and encoding it with base32. You can use any encoding schemes, but base32 is case insensitive unlike base64 and only uses alphanumeric letters while being more compact than hex encoding. We'll set the expiration to 30 days. + +The example uses the Web Crypto API for generating random bytes, which is available in most modern runtimes. If your runtime doesn't support it, similar runtime-specific alternatives are available. Do not use user-land RNGs. + +- [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. +- [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. +- [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. + +```ts +import { db } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +// ... + +export function createSession(userId: number): Session { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + db.execute( + "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)", + session.id, + session.userId, + Math.floor(session.expiresAt / 1000) + ); + return session; +} +``` + +Sessions are validated in 2 steps: + +1. Does the session exist in your database? +2. Is it still within expiration? + +We'll also extend the session expiration when it's close to expiration. This ensures active sessions are persisted, while inactive ones will eventually expire. We'll handle this by checking if there's less than 15 days (half of the 30 day expiration) before expiration. + +For convenience, we'll return both the session and user object tied to the session ID. + +```ts +import { db } from "./db.js"; + +// ... + +export function validateSession(sessionId: string): SessionValidationResult { + const row = db.queryOne( + "SELECT session.id, session.user_id, session.expires_at, user.id FROM session INNER JOIN user ON user.id = session.user_id WHERE id = ?", + sessionId + ); + if (row === null) { + return { session: null, user: null }; + } + const session: Session = { + id: row[0], + userId: row[1], + expiresAt: new Date(row[2] * 1000) + }; + const user: User = { + id: row[3] + }; + if (Date.now() >= session.expiresAt.getTime()) { + db.execute("DELETE FROM session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + db.execute("UPDATE session SET expires_at = ? WHERE id = ?", Math.floor(session.expiresAt / 1000), session.id); + } + return { session, user }; +} +``` + +Finally, invalidate sessions by simply deleting it from the database. + +```ts +import { db } from "./db.js"; + +// ... + +export function invalidateSession(sessionId: string): void { + db.execute("DELETE FROM session WHERE id = ?", sessionId); +} +``` + +Here's the full code: + +```ts +import { db } from "./db.js"; +import { encodeBase32 } from "@oslojs/encoding"; + +export function createSession(userId: number): Session { + const sessionIdBytes = new Uint8Array(20); + crypto.getRandomValues(sessionIdBytes); + const sessionId = encodeBase32(sessionIdBytes).toLowerCase(); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) + }; + db.execute( + "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)", + session.id, + session.userId, + Math.floor(session.expiresAt / 1000) + ); + return session; +} + +export function validateSession(sessionId: string): SessionValidationResult { + const row = db.queryOne( + "SELECT session.id, session.user_id, session.expires_at, user.id FROM session INNER JOIN user ON user.id = session.user_id WHERE id = ?", + sessionId + ); + if (row === null) { + return { session: null, user: null }; + } + const session: Session = { + id: row[0], + userId: row[1], + expiresAt: new Date(row[2] * 1000) + }; + const user: User = { + id: row[3] + }; + if (Date.now() >= session.expiresAt.getTime()) { + db.execute("DELETE FROM session WHERE id = ?", session.id); + return null; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + db.execute("UPDATE session SET expires_at = ? WHERE id = ?", Math.floor(session.expiresAt / 1000), session.id); + } + return { session, user }; +} + +export function invalidateSession(sessionId: string): void { + db.execute("DELETE FROM session WHERE id = ?", sessionId); +} + +export type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; + +export interface Session { + id: string; + userId: number; + expiresAt: Date; +} + +export interface User { + id: number; +} +``` diff --git a/pages/sessions/overview.md b/pages/sessions/overview.md new file mode 100644 index 0000000..d4db8ce --- /dev/null +++ b/pages/sessions/overview.md @@ -0,0 +1,16 @@ +--- +title: "Sessions" +--- + +# Sessions + +Sessions are a way to persist state in the server. It is especially useful for managing the authentication state, such as the client's identity. We can assign each session with a unique ID and store it on the server to use it as a token. The client can then associate subsequent requests with a session, and by extension the user, by sending the its ID. + +Session IDs can either be stored using cookies or local storage in browsers. We recommend using cookies since it provides some protection against XSS and the easiest to deal with overall. + +This guide has 2 sections on sessions: + +- Database: Define your session API using your database driver/ORM of choice. +- Cookies: Define your session cookie using your JavaScript framework of choice. + +To learn how to implement auth using the API you created, see the tutorials section. If you want to learn from real-life projects, see the examples section. diff --git a/pages/tutorials/github-oauth/index.md b/pages/tutorials/github-oauth/index.md new file mode 100644 index 0000000..d353194 --- /dev/null +++ b/pages/tutorials/github-oauth/index.md @@ -0,0 +1,10 @@ +--- +title: "Tutorial: GitHub OAuth" +--- + +# Tutorial: GitHub OAuth + +- [Astro](/tutorials/github-oauth/astro) +- [Next.js](/tutorials/github-oauth/nextjs) +- [SvelteKit](/tutorials/github-oauth/sveltekit) +- [Nuxt](/tutorials/github-oauth/nuxt) diff --git a/pages/tutorials/google-oauth/index.md b/pages/tutorials/google-oauth/index.md new file mode 100644 index 0000000..3394c2c --- /dev/null +++ b/pages/tutorials/google-oauth/index.md @@ -0,0 +1,10 @@ +--- +title: "Tutorial: Google OAuth" +--- + +# Tutorial: Google OAuth + +- [Astro](/tutorials/google-oauth/astro) +- [Next.js](/tutorials/google-oauth/nextjs) +- [SvelteKit](/tutorials/google-oauth/sveltekit) +- [Nuxt](/tutorials/google-oauth/nuxt)