├── .github ├── FUNDING.yml └── workflows │ └── publish.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE-0BSD ├── LICENSE-MIT ├── README.md ├── malta.config.json ├── package.json └── pages ├── examples ├── email-password-2fa-webauthn.md ├── email-password-2fa.md ├── github-oauth.md └── google-oauth.md ├── index.md ├── rate-limit ├── throttling.md └── token-bucket.md ├── sessions ├── basic-api │ ├── drizzle-orm.md │ ├── index.md │ ├── mysql.md │ ├── postgresql.md │ ├── prisma.md │ ├── redis.md │ └── sqlite.md ├── cookies │ ├── astro.md │ ├── index.md │ ├── nextjs.md │ └── sveltekit.md ├── migrate-lucia-v3.md └── overview.md └── tutorials ├── github-oauth ├── astro.md ├── index.md ├── nextjs.md └── sveltekit.md └── google-oauth ├── astro.md ├── index.md ├── nextjs.md └── sveltekit.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pilcrowOnPaper 2 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | env: 8 | CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_PAGES_API_TOKEN}} 9 | 10 | jobs: 11 | publish: 12 | name: Publish 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: setup actions 16 | uses: actions/checkout@v3 17 | - name: setup node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 20.5.1 21 | registry-url: https://registry.npmjs.org 22 | - name: install malta 23 | run: | 24 | curl -o malta.tgz -L https://github.com/pilcrowonpaper/malta/releases/latest/download/linux-amd64.tgz 25 | tar -xvzf malta.tgz 26 | - name: build 27 | run: ./linux-amd64/malta build 28 | - name: install wrangler 29 | run: npm i -g wrangler 30 | - name: deploy 31 | run: wrangler pages deploy dist --project-name lucia --branch main 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | node_modules 4 | package-lock.json 5 | .DS_Store -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | pnpm-lock.yaml 6 | package-lock.json 7 | yarn.lock 8 | 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "trailingComma": "none", 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE-0BSD: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 pilcrowOnPaper and contributors 2 | 3 | Permission to use, copy, modify, and/or distribute this software for 4 | any purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 7 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 8 | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 9 | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 10 | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 11 | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 12 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 pilcrowOnPaper and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lucia 2 | 3 | **Link: [lucia-auth.com](https://lucia-auth.com)** 4 | 5 | > [!IMPORTANT] 6 | > Lucia v3 will be deprecated by March 2025. Lucia is now a learning resource on implementing auth from scratch. See the [announcement](https://github.com/lucia-auth/lucia/discussions/1714) for details and migration path. The source code for v3 is available in the `v3` branch. 7 | 8 | Lucia is an open source project to provide resources on implementing authentication with JavaScript and TypeScript. 9 | 10 | The main section is on implementing sessions with your database, library, and framework of choice. Using the API you just created, you can continue learning by going through the tutorials or by referencing one of the fully-fledged examples. 11 | 12 | If you have any questions on auth, feel free to ask them in our [Discord server](https://discord.com/invite/PwrK3kpVR3) or on [GitHub Discussions](https://github.com/lucia-auth/lucia/discussions)! 13 | 14 | ## Why not a library? 15 | 16 | We've found it extremely hard to develop a library that: 17 | 18 | 1. Supports the many database libraries, ORMs, frameworks, runtimes, and deployment options available in the ecosystem. 19 | 2. Provides enough flexibility for the majority of use cases. 20 | 3. Does not add significant complexity to projects. 21 | 22 | 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. 23 | 24 | ## Related projects 25 | 26 | - [The Copenhagen Book](https://thecopenhagenbook.com): A free online resource covering the various auth concepts in web applications. 27 | - [Oslo](https://oslojs.dev): Simple, runtime agnostic, and fully-typed packages with minimal dependency for auth and cryptography. 28 | - [Arctic](https://arcticjs.dev): OAuth 2.0 client library with support for 50+ providers. 29 | 30 | ## Disclaimer 31 | 32 | All example code in the site is licensed under the [Zero-Clause BSD license](https://github.com/lucia-auth/lucia/blob/main/LICENSE-0BSD). You're free to use, copy, modify, and distribute it without any attribution. The license is approved by the [Open Source Initiative (OSI)](https://opensource.org/license/0bsd) and [Google](https://opensource.google/documentation/reference/patching#forbidden). 33 | 34 | Everything else this repository is licensed under the [MIT license](https://github.com/lucia-auth/lucia/blob/main/LICENSE-MIT). 35 | 36 | _Copyright © 2024 pilcrow and contributors_ 37 | -------------------------------------------------------------------------------- /malta.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lucia", 3 | "description": "An open source resource on implementing authentication with JavaScript", 4 | "domain": "https://lucia-auth.com", 5 | "twitter": "@lucia_auth", 6 | "asset_hashing": true, 7 | "sidebar": [ 8 | { 9 | "title": "Sessions", 10 | "pages": [ 11 | ["Overview", "/sessions/overview"], 12 | ["Basic API", "/sessions/basic-api"], 13 | ["Cookies", "/sessions/cookies"], 14 | ["Migrate from Lucia v3", "/sessions/migrate-lucia-v3"] 15 | ] 16 | }, 17 | { 18 | "title": "Tutorials", 19 | "pages": [ 20 | ["GitHub OAuth", "/tutorials/github-oauth"], 21 | ["Google OAuth", "/tutorials/google-oauth"] 22 | ] 23 | }, 24 | { 25 | "title": "Example projects", 26 | "pages": [ 27 | ["GitHub OAuth", "/examples/github-oauth"], 28 | ["Google OAuth", "/examples/google-oauth"], 29 | ["Email and password with 2FA", "/examples/email-password-2fa"], 30 | ["Email and password with 2FA and WebAuthn", "/examples/email-password-2fa-webauthn"] 31 | ] 32 | }, 33 | { 34 | "title": "Rate limiting", 35 | "pages": [ 36 | ["Token bucket", "/rate-limit/token-bucket"], 37 | ["Throttling", "/rate-limit/throttling"] 38 | ] 39 | }, 40 | { 41 | "title": "Community", 42 | "pages": [ 43 | ["GitHub", "https://github.com/lucia-auth/lucia"], 44 | ["Discord", "https://discord.com/invite/PwrK3kpVR3"], 45 | ["Twitter", "https://x.com/lucia_auth"], 46 | ["Donate", "https://github.com/sponsors/pilcrowOnPaper"] 47 | ] 48 | }, 49 | { 50 | "title": "Related projects", 51 | "pages": [ 52 | ["The Copenhagen Book", "https://thecopenhagenbook.com"], 53 | ["Oslo", "https://oslojs.dev"], 54 | ["Arctic", "https://arcticjs.dev"] 55 | ] 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lucia", 3 | "scripts": { 4 | "format": "prettier -w ." 5 | }, 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/lucia-auth/lucia" 9 | }, 10 | "author": "pilcrowOnPaper", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "prettier": "^3.0.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pages/examples/email-password-2fa-webauthn.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Email and password with 2FA and WebAuthn" 3 | --- 4 | 5 | # Email and password with 2FA and WebAuthn 6 | 7 | Example project with: 8 | 9 | - Email and password authentication 10 | - Password checks with HaveIBeenPwned 11 | - Sign in with passkeys 12 | - Email verification 13 | - 2FA with TOTP 14 | - 2FA recovery codes 15 | - 2FA with passkeys and security keys 16 | - Password reset with 2FA 17 | - Login throttling and rate limiting 18 | 19 | ## GitHub repositories 20 | 21 | - [Astro](https://github.com/lucia-auth/example-astro-email-password-webauthn) 22 | - [Next.js](https://github.com/lucia-auth/example-nextjs-email-password-webauthn) 23 | - [SvelteKit](https://github.com/lucia-auth/example-sveltekit-email-password-webauthn) 24 | -------------------------------------------------------------------------------- /pages/examples/email-password-2fa.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Email and password with 2FA" 3 | --- 4 | 5 | # Email and password with 2FA 6 | 7 | Example project with: 8 | 9 | - Email and password authentication 10 | - Password check with HaveIBeenPwned 11 | - Email verification 12 | - 2FA with TOTP 13 | - 2FA recovery codes 14 | - Password reset 15 | - Login throttling and rate limiting 16 | 17 | ## GitHub repositories 18 | 19 | - [Astro](https://github.com/lucia-auth/example-astro-email-password-2fa) 20 | - [Next.js](https://github.com/lucia-auth/example-nextjs-email-password-2fa) 21 | - [SvelteKit](https://github.com/lucia-auth/example-sveltekit-email-password-2fa) 22 | -------------------------------------------------------------------------------- /pages/examples/github-oauth.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GitHub OAuth" 3 | --- 4 | 5 | # GitHub OAuth 6 | 7 | Basic example project with GitHub OAuth and rate limiting. 8 | 9 | ## GitHub repositories 10 | 11 | - [Astro](https://github.com/lucia-auth/example-astro-github-oauth) 12 | - [Next.js](https://github.com/lucia-auth/example-nextjs-github-oauth) 13 | - [SvelteKit](https://github.com/lucia-auth/example-sveltekit-github-oauth) 14 | -------------------------------------------------------------------------------- /pages/examples/google-oauth.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Google OAuth" 3 | --- 4 | 5 | # Google OAuth 6 | 7 | Basic example project with Google OAuth and rate limiting. 8 | 9 | ## GitHub repositories 10 | 11 | - [Astro](https://github.com/lucia-auth/example-astro-google-oauth) 12 | - [Next.js](https://github.com/lucia-auth/example-nextjs-google-oauth) 13 | - [SvelteKit](https://github.com/lucia-auth/example-sveltekit-google-oauth) 14 | -------------------------------------------------------------------------------- /pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Lucia" 3 | --- 4 | 5 | # Lucia 6 | 7 | _Lucia is now a learning resource on implementing auth from scratch. See the [announcement](https://github.com/lucia-auth/lucia/discussions/1714) for details and migration path._ 8 | 9 | Lucia is an open source project to provide resources on implementing authentication with JavaScript and TypeScript. 10 | 11 | The main section is on implementing sessions with your database, library, and framework of choice. Using the API you just created, you can continue learning by going through the tutorials or by referencing one of the fully-fledged examples. 12 | 13 | If you have any questions on auth, feel free to ask them in our [Discord server](https://discord.com/invite/PwrK3kpVR3) or on [GitHub Discussions](https://github.com/lucia-auth/lucia/discussions)! 14 | 15 | ## Why not a library? 16 | 17 | We've found it extremely hard to develop a library that: 18 | 19 | 1. Supports the many database libraries, ORMs, frameworks, runtimes, and deployment options available in the ecosystem. 20 | 2. Provides enough flexibility for the majority of use cases. 21 | 3. Does not add significant complexity to projects. 22 | 23 | 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. 24 | 25 | ## Related projects 26 | 27 | - [The Copenhagen Book](https://thecopenhagenbook.com): A free online resource covering the various auth concepts in web applications. 28 | - [Oslo](https://oslojs.dev): Simple, runtime agnostic, and fully-typed packages with minimal dependency for auth and cryptography. 29 | - [Arctic](https://arcticjs.dev): OAuth 2.0 client library with support for 50+ providers. 30 | 31 | ## Disclaimer 32 | 33 | All example code in this site is licensed under the [Zero-Clause BSD license](https://github.com/lucia-auth/lucia/blob/main/LICENSE-0BSD). You're free to use, copy, modify, and distribute it without any attribution. The license is approved by the [Open Source Initiative (OSI)](https://opensource.org/license/0bsd) and [Google](https://opensource.google/documentation/reference/patching#forbidden). 34 | 35 | Everything else is licensed under the [MIT license](https://github.com/lucia-auth/lucia/blob/main/LICENSE-MIT). 36 | 37 | _Copyright © 2024 pilcrow and contributors_ 38 | -------------------------------------------------------------------------------- /pages/rate-limit/throttling.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Throttling" 3 | --- 4 | 5 | # Throttling 6 | 7 | After each failed attempt, the user has to wait longer before their next attempt. 8 | 9 | ## Memory storage 10 | 11 | `timeoutSeconds` holds the number of seconds to lock out the user for. 12 | 13 | ```ts 14 | export class Throttler<_Key> { 15 | public timeoutSeconds: number[]; 16 | 17 | private storage = new Map<_Key, ThrottlingCounter>(); 18 | 19 | constructor(timeoutSeconds: number[]) { 20 | this.timeoutSeconds = timeoutSeconds; 21 | } 22 | 23 | public consume(key: _Key): boolean { 24 | let counter = this.storage.get(key) ?? null; 25 | const now = Date.now(); 26 | if (counter === null) { 27 | counter = { 28 | index: 0, 29 | updatedAt: now 30 | }; 31 | this.storage.set(key, counter); 32 | return true; 33 | } 34 | const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.index] * 1000; 35 | if (!allowed) { 36 | return false; 37 | } 38 | counter.updatedAt = now; 39 | counter.index = Math.min(counter.index + 1, this.timeoutSeconds.length - 1); 40 | this.storage.set(key, counter); 41 | return true; 42 | } 43 | 44 | public reset(key: _Key): void { 45 | this.storage.delete(key); 46 | } 47 | } 48 | 49 | interface ThrottlingCounter { 50 | index: number; 51 | updatedAt: number; 52 | } 53 | ``` 54 | 55 | Here, on each failed sign in attempt, the lockout time gets extended with a max of 5 minutes. 56 | 57 | ```ts 58 | const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 180, 300]); 59 | 60 | if (!throttler.consume(userId)) { 61 | throw new Error("Too many requests"); 62 | } 63 | const validPassword = verifyPassword(password); 64 | if (!validPassword) { 65 | throw new Error("Invalid password"); 66 | } 67 | throttler.reset(user.id); 68 | ``` 69 | 70 | ## Redis 71 | 72 | We'll use Lua scripts to ensure queries are atomic. `timeoutSeconds` holds the number of seconds to lock out the user for. 73 | 74 | ```lua 75 | -- Returns 1 if allowed, 0 if not 76 | local key = KEYS[1] 77 | local now = tonumber(ARGV[1]) 78 | 79 | local timeoutSeconds = {1, 2, 4, 8, 16, 30, 60, 180, 300} 80 | 81 | local fields = redis.call("HGETALL", key) 82 | if #fields == 0 then 83 | redis.call("HSET", key, "index", 1, "updated_at", now) 84 | return {1} 85 | end 86 | local index = 0 87 | local updatedAt = 0 88 | for i = 1, #fields, 2 do 89 | if fields[i] == "index" then 90 | index = tonumber(fields[i+1]) 91 | elseif fields[i] == "updated_at" then 92 | updatedAt = tonumber(fields[i+1]) 93 | end 94 | end 95 | local allowed = now - updatedAt >= timeoutSeconds[index] 96 | if not allowed then 97 | return {0} 98 | end 99 | index = math.min(index + 1, #timeoutSeconds) 100 | redis.call("HSET", key, "index", index, "updated_at", now) 101 | return {1} 102 | ``` 103 | 104 | Load the script and retrieve the script hash. 105 | 106 | ```ts 107 | const SCRIPT_SHA = await client.scriptLoad(script); 108 | ``` 109 | 110 | Reference the script with the hash. 111 | 112 | ```ts 113 | export class Throttler { 114 | private storageKey: string; 115 | 116 | constructor(storageKey: string) { 117 | this.storageKey = storageKey; 118 | } 119 | 120 | public async consume(key: string): Promise { 121 | const result = await client.EVALSHA(SCRIPT_SHA, { 122 | keys: [`${this.storageKey}:${key}`], 123 | arguments: [Math.floor(Date.now() / 1000).toString()] 124 | }); 125 | return Boolean(result[0]); 126 | } 127 | 128 | public async reset(key: string): Promise { 129 | await client.DEL(key); 130 | } 131 | } 132 | ``` 133 | 134 | Here, on each failed sign in attempt, the lockout time gets extended. 135 | 136 | ```ts 137 | const throttler = new Throttler("login_throttler"); 138 | 139 | if (!throttler.consume(userId)) { 140 | throw new Error("Too many requests"); 141 | } 142 | const validPassword = verifyPassword(password); 143 | if (!validPassword) { 144 | throw new Error("Invalid password"); 145 | } 146 | throttler.reset(user.id); 147 | ``` 148 | -------------------------------------------------------------------------------- /pages/rate-limit/token-bucket.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Token bucket" 3 | --- 4 | 5 | # Token bucket 6 | 7 | Each user has their own bucket of tokens that gets refilled at a set interval. A token is removed on every request until none is left and the request is rejected. While a bit more complex than the fixed-window algorithm, it allows you to handle initial bursts and process requests more smoothly overall. 8 | 9 | ## Memory storage 10 | 11 | This requires the server to persist its memory across requests and will not work in serverless environments. 12 | 13 | ```ts 14 | export class TokenBucketRateLimit<_Key> { 15 | public max: number; 16 | public refillIntervalSeconds: number; 17 | 18 | constructor(max: number, refillIntervalSeconds: number) { 19 | this.max = max; 20 | this.refillIntervalSeconds = refillIntervalSeconds; 21 | } 22 | 23 | private storage = new Map<_Key, Bucket>(); 24 | 25 | public consume(key: _Key, cost: number): boolean { 26 | let bucket = this.storage.get(key) ?? null; 27 | const now = Date.now(); 28 | if (bucket === null) { 29 | bucket = { 30 | count: this.max - cost, 31 | refilledAt: now 32 | }; 33 | this.storage.set(key, bucket); 34 | return true; 35 | } 36 | const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); 37 | bucket.count = Math.min(bucket.count + refill, this.max); 38 | bucket.refilledAt = bucket.refilledAt + refill * this.refillIntervalSeconds * 1000; 39 | if (bucket.count < cost) { 40 | return false; 41 | } 42 | bucket.count -= cost; 43 | this.storage.set(key, bucket); 44 | return true; 45 | } 46 | } 47 | 48 | interface Bucket { 49 | count: number; 50 | refilledAt: number; 51 | } 52 | ``` 53 | 54 | ```ts 55 | // Bucket that has 10 tokens max and refills at a rate of 2 tokens/sec 56 | const ratelimit = new TokenBucketRateLimit(10, 2); 57 | 58 | if (!ratelimit.consume(ip, 1)) { 59 | throw new Error("Too many requests"); 60 | } 61 | ``` 62 | 63 | ## Redis 64 | 65 | We'll use Lua scripts to ensure queries are atomic. 66 | 67 | ```lua 68 | -- Returns 1 if allowed, 0 if not 69 | local key = KEYS[1] 70 | local max = tonumber(ARGV[1]) 71 | local refillIntervalSeconds = tonumber(ARGV[2]) 72 | local cost = tonumber(ARGV[3]) 73 | local now = tonumber(ARGV[4]) -- Current unix time in seconds 74 | 75 | local fields = redis.call("HGETALL", key) 76 | 77 | if #fields == 0 then 78 | local expiresInSeconds = cost * refillIntervalSeconds 79 | redis.call("HSET", key, "count", max - cost, "refilled_at", now) 80 | redis.call("EXPIRE", key, expiresInSeconds) 81 | return {1} 82 | end 83 | 84 | local count = 0 85 | local refilledAt = 0 86 | for i = 1, #fields, 2 do 87 | if fields[i] == "count" then 88 | count = tonumber(fields[i+1]) 89 | elseif fields[i] == "refilled_at" then 90 | refilledAt = tonumber(fields[i+1]) 91 | end 92 | end 93 | 94 | local refill = math.floor((now - refilledAt) / refillIntervalSeconds) 95 | count = math.min(count + refill, max) 96 | refilledAt = refilledAt + refill * refillIntervalSeconds 97 | 98 | if count < cost then 99 | return {0} 100 | end 101 | 102 | count = count - cost 103 | local expiresInSeconds = (max - count) * refillIntervalSeconds 104 | redis.call("HSET", key, "count", count, "refilled_at", now) 105 | redis.call("EXPIRE", key, expiresInSeconds) 106 | return {1} 107 | ``` 108 | 109 | Load the script and retrieve the script hash. 110 | 111 | ```ts 112 | const SCRIPT_SHA = await client.scriptLoad(script); 113 | ``` 114 | 115 | Reference the script with the hash. 116 | 117 | ```ts 118 | export class TokenBucketRateLimit { 119 | private storageKey: string; 120 | 121 | public max: number; 122 | public refillIntervalSeconds: number; 123 | 124 | constructor(storageKey: string, max: number, refillIntervalSeconds: number) { 125 | this.storageKey = storageKey; 126 | this.max = max; 127 | this.refillIntervalSeconds = refillIntervalSeconds; 128 | } 129 | 130 | public async consume(key: string, cost: number): Promise { 131 | const result = await client.EVALSHA(SCRIPT_SHA, { 132 | keys: [`${this.storageKey}:${key}`], 133 | arguments: [ 134 | this.max.toString(), 135 | this.refillIntervalSeconds.toString(), 136 | cost.toString(), 137 | Math.floor(Date.now() / 1000).toString() 138 | ] 139 | }); 140 | return Boolean(result[0]); 141 | } 142 | } 143 | ``` 144 | 145 | ```ts 146 | // Bucket that has 10 tokens max and refills at a rate of 2 tokens/sec. 147 | // Ensure that the storage key is unique. 148 | const ratelimit = new TokenBucketRateLimit("global_ip", 10, 2); 149 | 150 | const valid = await ratelimit.consume(ip, 1); 151 | if (!valid) { 152 | throw new Error("Too many requests"); 153 | } 154 | ``` 155 | -------------------------------------------------------------------------------- /pages/sessions/basic-api/drizzle-orm.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sessions with Drizzle ORM" 3 | --- 4 | 5 | # Sessions with Drizzle ORM 6 | 7 | Users will use a session token linked to a session instead of the ID directly. The session ID will be the SHA-256 hash of the token. SHA-256 is a one-way hash function. This ensures that even if the database contents were leaked, the attacker won't be able to retrieve valid tokens. 8 | 9 | ## Declare your schema 10 | 11 | Create a session table with a field for an ID, user ID, and expiration. 12 | 13 | ### MySQL 14 | 15 | ```ts 16 | import mysql from "mysql2/promise"; 17 | import { mysqlTable, int, varchar, datetime } from "drizzle-orm/mysql-core"; 18 | import { drizzle } from "drizzle-orm/mysql2"; 19 | 20 | import type { InferSelectModel } from "drizzle-orm"; 21 | 22 | const connection = await mysql.createConnection(); 23 | const db = drizzle(connection); 24 | 25 | export const userTable = mysqlTable("user", { 26 | id: int("id").primaryKey().autoincrement() 27 | }); 28 | 29 | export const sessionTable = mysqlTable("session", { 30 | id: varchar("id", { 31 | length: 255 32 | }).primaryKey(), 33 | userId: int("user_id") 34 | .notNull() 35 | .references(() => userTable.id), 36 | expiresAt: datetime("expires_at").notNull() 37 | }); 38 | 39 | export type User = InferSelectModel; 40 | export type Session = InferSelectModel; 41 | ``` 42 | 43 | ### PostgreSQL 44 | 45 | ```ts 46 | import pg from "pg"; 47 | import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core"; 48 | import { drizzle } from "drizzle-orm/node-postgres"; 49 | 50 | import type { InferSelectModel } from "drizzle-orm"; 51 | 52 | const pool = new pg.Pool(); 53 | const db = drizzle(pool); 54 | 55 | export const userTable = pgTable("user", { 56 | id: serial("id").primaryKey() 57 | }); 58 | 59 | export const sessionTable = pgTable("session", { 60 | id: text("id").primaryKey(), 61 | userId: integer("user_id") 62 | .notNull() 63 | .references(() => userTable.id), 64 | expiresAt: timestamp("expires_at", { 65 | withTimezone: true, 66 | mode: "date" 67 | }).notNull() 68 | }); 69 | 70 | export type User = InferSelectModel; 71 | export type Session = InferSelectModel; 72 | ``` 73 | 74 | ### SQLite 75 | 76 | ```ts 77 | import sqlite from "better-sqlite3"; 78 | import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core"; 79 | import { drizzle } from "drizzle-orm/better-sqlite3"; 80 | 81 | import type { InferSelectModel } from "drizzle-orm"; 82 | 83 | const sqliteDB = sqlite(":memory:"); 84 | const db = drizzle(sqliteDB); 85 | 86 | export const userTable = sqliteTable("user", { 87 | id: integer("id").primaryKey() 88 | }); 89 | 90 | export const sessionTable = sqliteTable("session", { 91 | id: text("id").primaryKey(), 92 | userId: integer("user_id") 93 | .notNull() 94 | .references(() => userTable.id), 95 | expiresAt: integer("expires_at", { 96 | mode: "timestamp" 97 | }).notNull() 98 | }); 99 | 100 | export type User = InferSelectModel; 101 | export type Session = InferSelectModel; 102 | ``` 103 | 104 | ## Install dependencies 105 | 106 | This page uses [Oslo](https://oslojs.dev) for various operations to support a wide range of runtimes. Oslo packages are fully-typed, lightweight, and has minimal dependencies. These packages are optional and can be replaced by runtime built-ins. 107 | 108 | ``` 109 | npm i @oslojs/encoding @oslojs/crypto 110 | ``` 111 | 112 | ## Create your API 113 | 114 | Here's what our API will look like. What each method does should be pretty self explanatory. 115 | 116 | ```ts 117 | import type { User, Session } from "./db.js"; 118 | 119 | export function generateSessionToken(): string { 120 | // TODO 121 | } 122 | 123 | export async function createSession(token: string, userId: number): Promise { 124 | // TODO 125 | } 126 | 127 | export async function validateSessionToken(token: string): Promise { 128 | // TODO 129 | } 130 | 131 | export async function invalidateSession(sessionId: string): Promise { 132 | // TODO 133 | } 134 | 135 | export async function invalidateAllSessions(userId: number): Promise { 136 | // TODO 137 | } 138 | 139 | export type SessionValidationResult = 140 | | { session: Session; user: User } 141 | | { session: null; user: null }; 142 | ``` 143 | 144 | The session token 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. 145 | 146 | 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. 147 | 148 | - [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. 149 | - [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. 150 | - [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. 151 | 152 | ```ts 153 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 154 | 155 | // ... 156 | 157 | export function generateSessionToken(): string { 158 | const bytes = new Uint8Array(20); 159 | crypto.getRandomValues(bytes); 160 | const token = encodeBase32LowerCaseNoPadding(bytes); 161 | return token; 162 | } 163 | ``` 164 | 165 | > You can use UUID v4 here but the RFC does not mandate that IDs are generated using a secure random source. Do not use libraries that are not clear on the source they use. Do not use other UUID versions as they do not offer the same entropy size as v4. Consider using [`Crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID). 166 | 167 | The session ID will be SHA-256 hash of the token. We'll set the expiration to 30 days. 168 | 169 | ```ts 170 | import { db, userTable, sessionTable } from "./db.js"; 171 | import { eq } from "drizzle-orm"; 172 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 173 | import { sha256 } from "@oslojs/crypto/sha2"; 174 | 175 | // ... 176 | 177 | export async function createSession(token: string, userId: number): Promise { 178 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 179 | const session: Session = { 180 | id: sessionId, 181 | userId, 182 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 183 | }; 184 | await db.insert(sessionTable).values(session); 185 | return session; 186 | } 187 | ``` 188 | 189 | Sessions are validated in 2 steps: 190 | 191 | 1. Does the session exist in your database? 192 | 2. Is it still within expiration? 193 | 194 | 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. 195 | 196 | For convenience, we'll return both the session and user object tied to the session ID. 197 | 198 | ```ts 199 | import { db, userTable, sessionTable } from "./db.js"; 200 | import { eq } from "drizzle-orm"; 201 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 202 | import { sha256 } from "@oslojs/crypto/sha2"; 203 | 204 | // ... 205 | 206 | export async function validateSessionToken(token: string): Promise { 207 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 208 | const result = await db 209 | .select({ user: userTable, session: sessionTable }) 210 | .from(sessionTable) 211 | .innerJoin(userTable, eq(sessionTable.userId, userTable.id)) 212 | .where(eq(sessionTable.id, sessionId)); 213 | if (result.length < 1) { 214 | return { session: null, user: null }; 215 | } 216 | const { user, session } = result[0]; 217 | if (Date.now() >= session.expiresAt.getTime()) { 218 | await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); 219 | return { session: null, user: null }; 220 | } 221 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 222 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 223 | await db 224 | .update(sessionTable) 225 | .set({ 226 | expiresAt: session.expiresAt 227 | }) 228 | .where(eq(sessionTable.id, session.id)); 229 | } 230 | return { session, user }; 231 | } 232 | ``` 233 | 234 | Finally, invalidate sessions by simply deleting it from the database. 235 | 236 | ```ts 237 | import { db, userTable, sessionTable } from "./db.js"; 238 | import { eq } from "drizzle-orm"; 239 | 240 | // ... 241 | 242 | export async function invalidateSession(sessionId: string): Promise { 243 | await db.delete(sessionTable).where(eq(sessionTable.id, sessionId)); 244 | } 245 | 246 | export async function invalidateAllSessions(userId: number): Promise { 247 | await db.delete(sessionTable).where(eq(sessionTable.userId, userId)); 248 | } 249 | ``` 250 | 251 | Here's the full code: 252 | 253 | ```ts 254 | import { db, userTable, sessionTable } from "./db.js"; 255 | import { eq } from "drizzle-orm"; 256 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 257 | import { sha256 } from "@oslojs/crypto/sha2"; 258 | 259 | import type { User, Session } from "./db.js"; 260 | 261 | export function generateSessionToken(): string { 262 | const bytes = new Uint8Array(20); 263 | crypto.getRandomValues(bytes); 264 | const token = encodeBase32LowerCaseNoPadding(bytes); 265 | return token; 266 | } 267 | 268 | export async function createSession(token: string, userId: number): Promise { 269 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 270 | const session: Session = { 271 | id: sessionId, 272 | userId, 273 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 274 | }; 275 | await db.insert(sessionTable).values(session); 276 | return session; 277 | } 278 | 279 | export async function validateSessionToken(token: string): Promise { 280 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 281 | const result = await db 282 | .select({ user: userTable, session: sessionTable }) 283 | .from(sessionTable) 284 | .innerJoin(userTable, eq(sessionTable.userId, userTable.id)) 285 | .where(eq(sessionTable.id, sessionId)); 286 | if (result.length < 1) { 287 | return { session: null, user: null }; 288 | } 289 | const { user, session } = result[0]; 290 | if (Date.now() >= session.expiresAt.getTime()) { 291 | await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); 292 | return { session: null, user: null }; 293 | } 294 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 295 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 296 | await db 297 | .update(sessionTable) 298 | .set({ 299 | expiresAt: session.expiresAt 300 | }) 301 | .where(eq(sessionTable.id, session.id)); 302 | } 303 | return { session, user }; 304 | } 305 | 306 | export async function invalidateSession(sessionId: string): Promise { 307 | await db.delete(sessionTable).where(eq(sessionTable.id, sessionId)); 308 | } 309 | 310 | export async function invalidateAllSessions(userId: number): Promise { 311 | await db.delete(sessionTable).where(eq(sessionTable.userId, userId)); 312 | } 313 | 314 | export type SessionValidationResult = 315 | | { session: Session; user: User } 316 | | { session: null; user: null }; 317 | ``` 318 | 319 | ## Using your API 320 | 321 | When a user signs in, generate a session token with `generateSessionToken()` and create a session linked to it with `createSession()`. The token is provided to the user client. 322 | 323 | ```ts 324 | import { generateSessionToken, createSession } from "./session.js"; 325 | 326 | const token = generateSessionToken(); 327 | const session = createSession(token, userId); 328 | setSessionTokenCookie(token); 329 | ``` 330 | 331 | Validate a user-provided token with `validateSessionToken()`. 332 | 333 | ```ts 334 | import { validateSessionToken } from "./session.js"; 335 | 336 | const token = cookies.get("session"); 337 | if (token !== null) { 338 | const { session, user } = validateSessionToken(token); 339 | } 340 | ``` 341 | 342 | To learn how to store the token on the client, see the [Session cookies](/sessions/cookies) page. 343 | -------------------------------------------------------------------------------- /pages/sessions/basic-api/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Basic API" 3 | --- 4 | 5 | # Basic session API 6 | 7 | This page covers how to implement sessions with your database of choice. 8 | 9 | - [Drizzle ORM](/sessions/basic-api/drizzle-orm) 10 | - [Redis](/sessions/basic-api/redis) 11 | - [Prisma](/sessions/basic-api/prisma) 12 | - [MySQL](/sessions/basic-api/mysql) 13 | - [PostgreSQL](/sessions/basic-api/postgresql) 14 | - [SQLite](/sessions/basic-api/sqlite) 15 | -------------------------------------------------------------------------------- /pages/sessions/basic-api/mysql.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sessions with MySQL" 3 | --- 4 | 5 | # Sessions with MySQL 6 | 7 | Users will use a session token linked to a session instead of the ID directly. The session ID will be the SHA-256 hash of the token. SHA-256 is a one-way hash function. This ensures that even if the database contents were leaked, the attacker won't be able retrieve valid tokens. 8 | 9 | ## Declare your schema 10 | 11 | Create a session table with a field for a text ID, user ID, and expiration. 12 | 13 | ``` 14 | CREATE TABLE user ( 15 | id INT PRIMARY KEY AUTO_INCREMENT, 16 | username VARCHAR(255) NOT NULL UNIQUE 17 | ); 18 | 19 | CREATE TABLE user_session ( 20 | id VARCHAR(255) NOT NULL PRIMARY KEY, 21 | user_id INT NOT NULL REFERENCES user(id), 22 | expires_at DATETIME NOT NULL 23 | ); 24 | ``` 25 | 26 | ## Install dependencies 27 | 28 | This page uses [Oslo](https://oslojs.dev) for various operations to support a wide range of runtimes. Oslo packages are fully-typed, lightweight, and have minimal dependencies. These packages are optional and can be replaced by runtime built-ins. 29 | 30 | ``` 31 | npm i @oslojs/encoding @oslojs/crypto 32 | ``` 33 | 34 | ## Create your API 35 | 36 | Here's what our API will look like. What each method does should be pretty self explanatory. 37 | 38 | If you just need the code full code without the explanation, skip to the end of this section. 39 | 40 | ```ts 41 | import { db } from "./db.js"; 42 | 43 | export function generateSessionToken(): string { 44 | // TODO 45 | } 46 | 47 | export async function createSession(token: string, userId: number): Promise { 48 | // TODO 49 | } 50 | 51 | export async function validateSessionToken(token: string): Promise { 52 | // TODO 53 | } 54 | 55 | export async function invalidateSession(sessionId: string): Promise { 56 | // TODO 57 | } 58 | 59 | export async function invalidateAllSessions(userId: number): Promise { 60 | // TODO 61 | } 62 | 63 | export type SessionValidationResult = 64 | | { session: Session; user: User } 65 | | { session: null; user: null }; 66 | 67 | export interface Session { 68 | id: string; 69 | userId: number; 70 | expiresAt: Date; 71 | } 72 | 73 | export interface User { 74 | id: number; 75 | } 76 | ``` 77 | 78 | The session token 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. 79 | 80 | 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. 81 | 82 | - [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. 83 | - [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. 84 | - [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. 85 | 86 | ```ts 87 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 88 | 89 | // ... 90 | 91 | export function generateSessionToken(): string { 92 | const bytes = new Uint8Array(20); 93 | crypto.getRandomValues(bytes); 94 | const token = encodeBase32LowerCaseNoPadding(bytes); 95 | return token; 96 | } 97 | ``` 98 | 99 | > You can use UUID v4 here but the RFC does not mandate that IDs are generated using a secure random source. Do not use libraries that are not clear on the source they use. Do not use other UUID versions as they do not offer the same entropy size as v4. Consider using [`Crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID). 100 | 101 | The session ID will be SHA-256 hash of the token. We'll set the expiration to 30 days. 102 | 103 | ```ts 104 | import { db } from "./db.js"; 105 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 106 | import { sha256 } from "@oslojs/crypto/sha2"; 107 | 108 | // ... 109 | 110 | export async function createSession(token: string, userId: number): Promise { 111 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 112 | const session: Session = { 113 | id: sessionId, 114 | userId, 115 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 116 | }; 117 | await db.execute( 118 | "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", 119 | session.id, 120 | session.userId, 121 | session.expiresAt 122 | ); 123 | return session; 124 | } 125 | ``` 126 | 127 | Sessions are validated in 2 steps: 128 | 129 | 1. Does the session exist in your database? 130 | 2. Is it still within expiration? 131 | 132 | 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. 133 | 134 | For convenience, we'll return both the session and user object tied to the session ID. 135 | 136 | ```ts 137 | import { db } from "./db.js"; 138 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 139 | import { sha256 } from "@oslojs/crypto/sha2"; 140 | 141 | // ... 142 | 143 | export async function validateSessionToken(token: string): Promise { 144 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 145 | const row = await db.queryOne( 146 | "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 = ?", 147 | sessionId 148 | ); 149 | if (row === null) { 150 | return { session: null, user: null }; 151 | } 152 | const session: Session = { 153 | id: row[0], 154 | userId: row[1], 155 | expiresAt: row[2] 156 | }; 157 | const user: User = { 158 | id: row[3] 159 | }; 160 | if (Date.now() >= session.expiresAt.getTime()) { 161 | await db.execute("DELETE FROM user_session WHERE id = ?", session.id); 162 | return { session: null, user: null }; 163 | } 164 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 165 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 166 | await db.execute( 167 | "UPDATE user_session SET expires_at = ? WHERE id = ?", 168 | session.expiresAt, 169 | session.id 170 | ); 171 | } 172 | return { session, user }; 173 | } 174 | ``` 175 | 176 | Finally, invalidate sessions by simply deleting it from the database. 177 | 178 | ```ts 179 | import { db } from "./db.js"; 180 | 181 | // ... 182 | 183 | export async function invalidateSession(sessionId: string): Promise { 184 | await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); 185 | } 186 | 187 | export async function invalidateAllSessions(userId: number): Promise { 188 | await db.execute("DELETE FROM user_session WHERE user_id = ?", userId); 189 | } 190 | ``` 191 | 192 | Here's the full code: 193 | 194 | ```ts 195 | import { db } from "./db.js"; 196 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 197 | import { sha256 } from "@oslojs/crypto/sha2"; 198 | 199 | export function generateSessionToken(): string { 200 | const bytes = new Uint8Array(20); 201 | crypto.getRandomValues(bytes); 202 | const token = encodeBase32LowerCaseNoPadding(bytes); 203 | return token; 204 | } 205 | 206 | export async function createSession(token: string, userId: number): Promise { 207 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 208 | const session: Session = { 209 | id: sessionId, 210 | userId, 211 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 212 | }; 213 | await db.execute( 214 | "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", 215 | session.id, 216 | session.userId, 217 | session.expiresAt 218 | ); 219 | return session; 220 | } 221 | 222 | export async function validateSessionToken(token: string): Promise { 223 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 224 | const row = await db.queryOne( 225 | "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 = ?", 226 | sessionId 227 | ); 228 | if (row === null) { 229 | return { session: null, user: null }; 230 | } 231 | const session: Session = { 232 | id: row[0], 233 | userId: row[1], 234 | expiresAt: row[2] 235 | }; 236 | const user: User = { 237 | id: row[3] 238 | }; 239 | if (Date.now() >= session.expiresAt.getTime()) { 240 | await db.execute("DELETE FROM user_session WHERE id = ?", session.id); 241 | return { session: null, user: null }; 242 | } 243 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 244 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 245 | await db.execute( 246 | "UPDATE user_session SET expires_at = ? WHERE id = ?", 247 | session.expiresAt, 248 | session.id 249 | ); 250 | } 251 | return { session, user }; 252 | } 253 | 254 | export async function invalidateSession(sessionId: string): Promise { 255 | await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); 256 | } 257 | 258 | export async function invalidateAllSessions(userId: number): Promise { 259 | await db.execute("DELETE FROM user_session WHERE user_id = ?", userId); 260 | } 261 | 262 | export type SessionValidationResult = 263 | | { session: Session; user: User } 264 | | { session: null; user: null }; 265 | 266 | export interface Session { 267 | id: string; 268 | userId: number; 269 | expiresAt: Date; 270 | } 271 | 272 | export interface User { 273 | id: number; 274 | } 275 | ``` 276 | 277 | ## Using your API 278 | 279 | When a user signs in, generate a session token with `generateSessionToken()` and create a session linked to it with `createSession()`. The token is provided to the user client. 280 | 281 | ```ts 282 | import { generateSessionToken, createSession } from "./session.js"; 283 | 284 | const token = generateSessionToken(); 285 | const session = createSession(token, userId); 286 | setSessionTokenCookie(token); 287 | ``` 288 | 289 | Validate a user-provided token with `validateSessionToken()`. 290 | 291 | ```ts 292 | import { validateSessionToken } from "./session.js"; 293 | 294 | const token = cookies.get("session"); 295 | if (token !== null) { 296 | const { session, user } = validateSessionToken(token); 297 | } 298 | ``` 299 | 300 | To learn how to store the token on the client, see the [Session cookies](/sessions/cookies) page. 301 | -------------------------------------------------------------------------------- /pages/sessions/basic-api/postgresql.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sessions with PostgreSQL" 3 | --- 4 | 5 | # Sessions with PostgreSQL 6 | 7 | Users will use a session token linked to a session instead of the ID directly. The session ID will be the SHA-256 hash of the token. SHA-256 is a one-way hash function. This ensures that even if the database contents were leaked, the attacker won't be able retrieve valid tokens. 8 | 9 | ## Declare your schema 10 | 11 | Create a session table with a field for a text ID, user ID, and expiration. 12 | 13 | ``` 14 | CREATE TABLE app_user ( 15 | id SERIAL PRIMARY KEY, 16 | username TEXT NOT NULL UNIQUE 17 | ); 18 | 19 | CREATE TABLE user_session ( 20 | id TEXT NOT NULL PRIMARY KEY, 21 | user_id INTEGER NOT NULL REFERENCES app_user(id), 22 | expires_at TIMESTAMPTZ NOT NULL, 23 | ); 24 | ``` 25 | 26 | ## Install dependencies 27 | 28 | This page uses [Oslo](https://oslojs.dev) for various operations to support a wide range of runtimes. Oslo packages are fully-typed, lightweight, and has minimal dependencies. These packages are optional and can be replaced by runtime built-ins. 29 | 30 | ``` 31 | npm i @oslojs/encoding @oslojs/crypto 32 | ``` 33 | 34 | ## Create your API 35 | 36 | Here's what our API will look like. What each method does should be pretty self explanatory. 37 | 38 | If you just need the code full code without the explanation, skip to the end of this section. 39 | 40 | ```ts 41 | import { db } from "./db.js"; 42 | 43 | export function generateSessionToken(): string { 44 | // TODO 45 | } 46 | 47 | export async function createSession(token: string, userId: number): Promise { 48 | // TODO 49 | } 50 | 51 | export async function validateSessionToken(token: string): Promise { 52 | // TODO 53 | } 54 | 55 | export async function invalidateSession(sessionId: string): Promise { 56 | // TODO 57 | } 58 | 59 | export async function invalidateAllSessions(userId: number): Promise { 60 | // TODO 61 | } 62 | 63 | export type SessionValidationResult = 64 | | { session: Session; user: User } 65 | | { session: null; user: null }; 66 | 67 | export interface Session { 68 | id: string; 69 | userId: number; 70 | expiresAt: Date; 71 | } 72 | 73 | export interface User { 74 | id: number; 75 | } 76 | ``` 77 | 78 | The session token 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. 79 | 80 | 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. 81 | 82 | - [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. 83 | - [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. 84 | - [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. 85 | 86 | ```ts 87 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 88 | 89 | // ... 90 | 91 | export function generateSessionToken(): string { 92 | const bytes = new Uint8Array(20); 93 | crypto.getRandomValues(bytes); 94 | const token = encodeBase32LowerCaseNoPadding(bytes); 95 | return token; 96 | } 97 | ``` 98 | 99 | > You can use UUID v4 here but the RFC does not mandate that IDs are generated using a secure random source. Do not use libraries that are not clear on the source they use. Do not use other UUID versions as they do not offer the same entropy size as v4. Consider using [`Crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID). 100 | 101 | The session ID will be SHA-256 hash of the token. We'll set the expiration to 30 days. 102 | 103 | ```ts 104 | import { db } from "./db.js"; 105 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 106 | import { sha256 } from "@oslojs/crypto/sha2"; 107 | 108 | // ... 109 | 110 | export async function createSession(token: string, userId: number): Promise { 111 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 112 | const session: Session = { 113 | id: sessionId, 114 | userId, 115 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 116 | }; 117 | await db.execute( 118 | "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", 119 | session.id, 120 | session.userId, 121 | session.expiresAt 122 | ); 123 | return session; 124 | } 125 | ``` 126 | 127 | Sessions are validated in 2 steps: 128 | 129 | 1. Does the session exist in your database? 130 | 2. Is it still within expiration? 131 | 132 | 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. 133 | 134 | For convenience, we'll return both the session and user object tied to the session ID. 135 | 136 | ```ts 137 | import { db } from "./db.js"; 138 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 139 | import { sha256 } from "@oslojs/crypto/sha2"; 140 | 141 | // ... 142 | 143 | export async function validateSessionToken(token: string): Promise { 144 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 145 | const row = await db.queryOne( 146 | "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 = ?", 147 | sessionId 148 | ); 149 | if (row === null) { 150 | return { session: null, user: null }; 151 | } 152 | const session: Session = { 153 | id: row[0], 154 | userId: row[1], 155 | expiresAt: row[2] 156 | }; 157 | const user: User = { 158 | id: row[3] 159 | }; 160 | if (Date.now() >= session.expiresAt.getTime()) { 161 | await db.execute("DELETE FROM user_session WHERE id = ?", session.id); 162 | return { session: null, user: null }; 163 | } 164 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 165 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 166 | await db.execute( 167 | "UPDATE user_session SET expires_at = ? WHERE id = ?", 168 | session.expiresAt, 169 | session.id 170 | ); 171 | } 172 | return { session, user }; 173 | } 174 | ``` 175 | 176 | Finally, invalidate sessions by simply deleting it from the database. 177 | 178 | ```ts 179 | import { db } from "./db.js"; 180 | 181 | // ... 182 | 183 | export async function invalidateSession(sessionId: string): Promise { 184 | await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); 185 | } 186 | 187 | export async function invalidateAllSessions(userId: number): Promise { 188 | await db.execute("DELETE FROM user_session WHERE user_id = ?", userId); 189 | } 190 | ``` 191 | 192 | Here's the full code: 193 | 194 | ```ts 195 | import { db } from "./db.js"; 196 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 197 | import { sha256 } from "@oslojs/crypto/sha2"; 198 | 199 | export function generateSessionToken(): string { 200 | const bytes = new Uint8Array(20); 201 | crypto.getRandomValues(bytes); 202 | const token = encodeBase32LowerCaseNoPadding(bytes); 203 | return token; 204 | } 205 | 206 | export async function createSession(token: string, userId: number): Promise { 207 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 208 | const session: Session = { 209 | id: sessionId, 210 | userId, 211 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 212 | }; 213 | await db.execute( 214 | "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)", 215 | session.id, 216 | session.userId, 217 | session.expiresAt 218 | ); 219 | return session; 220 | } 221 | 222 | export async function validateSessionToken(token: string): Promise { 223 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 224 | const row = await db.queryOne( 225 | "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 = ?", 226 | sessionId 227 | ); 228 | if (row === null) { 229 | return { session: null, user: null }; 230 | } 231 | const session: Session = { 232 | id: row[0], 233 | userId: row[1], 234 | expiresAt: row[2] 235 | }; 236 | const user: User = { 237 | id: row[3] 238 | }; 239 | if (Date.now() >= session.expiresAt.getTime()) { 240 | await db.execute("DELETE FROM user_session WHERE id = ?", session.id); 241 | return { session: null, user: null }; 242 | } 243 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 244 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 245 | await db.execute( 246 | "UPDATE user_session SET expires_at = ? WHERE id = ?", 247 | session.expiresAt, 248 | session.id 249 | ); 250 | } 251 | return { session, user }; 252 | } 253 | 254 | export async function invalidateSession(sessionId: string): Promise { 255 | await db.execute("DELETE FROM user_session WHERE id = ?", sessionId); 256 | } 257 | 258 | export async function invalidateAllSessions(userId: number): Promise { 259 | await db.execute("DELETE FROM user_session WHERE user_id = ?", userId); 260 | } 261 | 262 | export type SessionValidationResult = 263 | | { session: Session; user: User } 264 | | { session: null; user: null }; 265 | 266 | export interface Session { 267 | id: string; 268 | userId: number; 269 | expiresAt: Date; 270 | } 271 | 272 | export interface User { 273 | id: number; 274 | } 275 | ``` 276 | 277 | ## Using your API 278 | 279 | When a user signs in, generate a session token with `generateSessionToken()` and create a session linked to it with `createSession()`. The token is provided to the user client. 280 | 281 | ```ts 282 | import { generateSessionToken, createSession } from "./session.js"; 283 | 284 | const token = generateSessionToken(); 285 | const session = createSession(token, userId); 286 | setSessionTokenCookie(token); 287 | ``` 288 | 289 | Validate a user-provided token with `validateSessionToken()`. 290 | 291 | ```ts 292 | import { validateSessionToken } from "./session.js"; 293 | 294 | const token = cookies.get("session"); 295 | if (token !== null) { 296 | const { session, user } = validateSessionToken(token); 297 | } 298 | ``` 299 | 300 | To learn how to store the token on the client, see the [Session cookies](/sessions/cookies) page. 301 | -------------------------------------------------------------------------------- /pages/sessions/basic-api/prisma.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sessions with Prisma" 3 | --- 4 | 5 | # Sessions with Prisma 6 | 7 | Users will use a session token linked to a session instead of the ID directly. The session ID will be the SHA-256 hash of the token. SHA-256 is a one-way hash function. This ensures that even if the database contents were leaked, the attacker won't be able retrieve valid tokens. 8 | 9 | ## Declare your schema 10 | 11 | Create a session model with a field for a text ID, user ID, and expiration. 12 | 13 | ``` 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | sessions Session[] 17 | } 18 | 19 | model Session { 20 | id String @id 21 | userId Int 22 | expiresAt DateTime 23 | 24 | user User @relation(references: [id], fields: [userId], onDelete: Cascade) 25 | } 26 | ``` 27 | 28 | ## Install dependencies 29 | 30 | This page uses [Oslo](https://oslojs.dev) for various operations to support a wide range of runtimes. Oslo packages are fully-typed, lightweight, and has minimal dependencies. These packages are optional and can be replaced by runtime built-ins. 31 | 32 | ``` 33 | npm i @oslojs/encoding @oslojs/crypto 34 | ``` 35 | 36 | ## Create your API 37 | 38 | Here's what our API will look like. What each method does should be pretty self explanatory. 39 | 40 | If you just need the full code without the explanation, skip to the end of this section. 41 | 42 | ```ts 43 | import type { User, Session } from "@prisma/client"; 44 | 45 | export function generateSessionToken(): string { 46 | // TODO 47 | } 48 | 49 | export async function createSession(token: string, userId: number): Promise { 50 | // TODO 51 | } 52 | 53 | export async function validateSessionToken(token: string): Promise { 54 | // TODO 55 | } 56 | 57 | export async function invalidateSession(sessionId: string): Promise { 58 | // TODO 59 | } 60 | 61 | export async function invalidateAllSessions(userId: number): Promise { 62 | // TODO 63 | } 64 | 65 | export type SessionValidationResult = 66 | | { session: Session; user: User } 67 | | { session: null; user: null }; 68 | ``` 69 | 70 | The session token 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. 71 | 72 | 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. 73 | 74 | - [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. 75 | - [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. 76 | - [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. 77 | 78 | ```ts 79 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 80 | 81 | // ... 82 | 83 | export function generateSessionToken(): string { 84 | const bytes = new Uint8Array(20); 85 | crypto.getRandomValues(bytes); 86 | const token = encodeBase32LowerCaseNoPadding(bytes); 87 | return token; 88 | } 89 | ``` 90 | 91 | > You can use UUID v4 here but the RFC does not mandate that IDs are generated using a secure random source. Do not use libraries that are not clear on the source they use. Do not use other UUID versions as they do not offer the same entropy size as v4. Consider using [`Crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID). 92 | 93 | The session ID will be SHA-256 hash of the token. We'll set the expiration to 30 days. 94 | 95 | ```ts 96 | import { prisma } from "./db.js"; 97 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 98 | import { sha256 } from "@oslojs/crypto/sha2"; 99 | 100 | // ... 101 | 102 | export async function createSession(token: string, userId: number): Promise { 103 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 104 | const session: Session = { 105 | id: sessionId, 106 | userId, 107 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 108 | }; 109 | await prisma.session.create({ 110 | data: session 111 | }); 112 | return session; 113 | } 114 | ``` 115 | 116 | Sessions are validated in 2 steps: 117 | 118 | 1. Does the session exist in your database? 119 | 2. Is it still within expiration? 120 | 121 | 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. 122 | 123 | For convenience, we'll return both the session and user object tied to the session ID. 124 | 125 | ```ts 126 | import { prisma } from "./db.js"; 127 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 128 | import { sha256 } from "@oslojs/crypto/sha2"; 129 | 130 | // ... 131 | 132 | export async function validateSessionToken(token: string): Promise { 133 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 134 | const result = await prisma.session.findUnique({ 135 | where: { 136 | id: sessionId 137 | }, 138 | include: { 139 | user: true 140 | } 141 | }); 142 | if (result === null) { 143 | return { session: null, user: null }; 144 | } 145 | const { user, ...session } = result; 146 | if (Date.now() >= session.expiresAt.getTime()) { 147 | await prisma.session.delete({ where: { id: sessionId } }); 148 | return { session: null, user: null }; 149 | } 150 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 151 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 152 | await prisma.session.update({ 153 | where: { 154 | id: session.id 155 | }, 156 | data: { 157 | expiresAt: session.expiresAt 158 | } 159 | }); 160 | } 161 | return { session, user }; 162 | } 163 | ``` 164 | 165 | Finally, invalidate sessions by simply deleting it from the database. 166 | 167 | ```ts 168 | import { prisma } from "./db.js"; 169 | 170 | // ... 171 | 172 | export async function invalidateSession(sessionId: string): Promise { 173 | await prisma.session.delete({ where: { id: sessionId } }); 174 | } 175 | 176 | export async function invalidateAllSessions(userId: number): Promise { 177 | await prisma.session.deleteMany({ 178 | where: { 179 | userId: userId 180 | } 181 | }); 182 | } 183 | ``` 184 | 185 | Here's the full code: 186 | 187 | ```ts 188 | import { prisma } from "./db.js"; 189 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 190 | import { sha256 } from "@oslojs/crypto/sha2"; 191 | 192 | import type { User, Session } from "@prisma/client"; 193 | 194 | export function generateSessionToken(): string { 195 | const bytes = new Uint8Array(20); 196 | crypto.getRandomValues(bytes); 197 | const token = encodeBase32LowerCaseNoPadding(bytes); 198 | return token; 199 | } 200 | 201 | export async function createSession(token: string, userId: number): Promise { 202 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 203 | const session: Session = { 204 | id: sessionId, 205 | userId, 206 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 207 | }; 208 | await prisma.session.create({ 209 | data: session 210 | }); 211 | return session; 212 | } 213 | 214 | export async function validateSessionToken(token: string): Promise { 215 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 216 | const result = await prisma.session.findUnique({ 217 | where: { 218 | id: sessionId 219 | }, 220 | include: { 221 | user: true 222 | } 223 | }); 224 | if (result === null) { 225 | return { session: null, user: null }; 226 | } 227 | const { user, ...session } = result; 228 | if (Date.now() >= session.expiresAt.getTime()) { 229 | await prisma.session.delete({ where: { id: sessionId } }); 230 | return { session: null, user: null }; 231 | } 232 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 233 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 234 | await prisma.session.update({ 235 | where: { 236 | id: session.id 237 | }, 238 | data: { 239 | expiresAt: session.expiresAt 240 | } 241 | }); 242 | } 243 | return { session, user }; 244 | } 245 | 246 | export async function invalidateSession(sessionId: string): Promise { 247 | await prisma.session.delete({ where: { id: sessionId } }); 248 | } 249 | 250 | export async function invalidateAllSessions(userId: number): Promise { 251 | await prisma.session.deleteMany({ 252 | where: { 253 | userId: userId 254 | } 255 | }); 256 | } 257 | 258 | export type SessionValidationResult = 259 | | { session: Session; user: User } 260 | | { session: null; user: null }; 261 | ``` 262 | 263 | ## Using your API 264 | 265 | When a user signs in, generate a session token with `generateSessionToken()` and create a session linked to it with `createSession()`. The token is provided to the user client. 266 | 267 | ```ts 268 | import { generateSessionToken, createSession } from "./session.js"; 269 | 270 | const token = generateSessionToken(); 271 | const session = createSession(token, userId); 272 | setSessionTokenCookie(token); 273 | ``` 274 | 275 | Validate a user-provided token with `validateSessionToken()`. 276 | 277 | ```ts 278 | import { validateSessionToken } from "./session.js"; 279 | 280 | const token = cookies.get("session"); 281 | if (token !== null) { 282 | const { session, user } = validateSessionToken(token); 283 | } 284 | ``` 285 | 286 | To learn how to store the token on the client, see the [Session cookies](/sessions/cookies) page. 287 | -------------------------------------------------------------------------------- /pages/sessions/basic-api/redis.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sessions with Redis" 3 | --- 4 | 5 | # Sessions with Redis 6 | 7 | Users will use a session token linked to a session instead of the ID directly. The session ID will be the SHA-256 hash of the token. SHA-256 is a one-way hash function. This ensures that even if the database contents were leaked, the attacker won't be able retrieve valid tokens. 8 | 9 | This page uses [Oslo](https://oslojs.dev) for various operations to support a wide range of runtimes. Oslo packages are fully-typed, lightweight, and has minimal dependencies. These packages are optional and can be replaced by runtime built-ins. 10 | 11 | ``` 12 | npm i @oslojs/encoding @oslojs/crypto 13 | ``` 14 | 15 | Here's what our API will look like. What each method does should be pretty self explanatory. 16 | 17 | ```ts 18 | import { redis } from "./redis.js"; 19 | 20 | export function generateSessionToken(): string { 21 | // TODO 22 | } 23 | 24 | export async function createSession(token: string, userId: number): Promise { 25 | // TODO 26 | } 27 | 28 | export async function validateSessionToken(token: string): Promise { 29 | // TODO 30 | } 31 | 32 | export async function invalidateSession(sessionId: string): Promise { 33 | // TODO 34 | } 35 | 36 | export async function invalidateAllSessions(userId: number): Promise { 37 | // TODO 38 | } 39 | 40 | export interface Session { 41 | id: string; 42 | userId: number; 43 | expiresAt: Date; 44 | } 45 | ``` 46 | 47 | The session token 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. 48 | 49 | 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. 50 | 51 | - [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. 52 | - [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. 53 | - [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. 54 | 55 | ```ts 56 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 57 | 58 | // ... 59 | 60 | export function generateSessionToken(): string { 61 | const bytes = new Uint8Array(20); 62 | crypto.getRandomValues(bytes); 63 | const token = encodeBase32LowerCaseNoPadding(bytes); 64 | return token; 65 | } 66 | ``` 67 | 68 | > You can use UUID v4 here but the RFC does not mandate that IDs are generated using a secure random source. Do not use libraries that are not clear on the source they use. Do not use other UUID versions as they do not offer the same entropy size as v4. Consider using [`Crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID). 69 | 70 | The session ID will be SHA-256 hash of the token. We'll set the expiration to 30 days. We'll also keep a list of sessions linked to each user. 71 | 72 | ```ts 73 | import { redis } from "./redis.js"; 74 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 75 | import { sha256 } from "@oslojs/crypto/sha2"; 76 | 77 | // ... 78 | 79 | export async function createSession(token: string, userId: number): Promise { 80 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 81 | const session: Session = { 82 | id: sessionId, 83 | userId, 84 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 85 | }; 86 | await redis.set( 87 | `session:${session.id}`, 88 | JSON.stringify({ 89 | id: session.id, 90 | user_id: session.userId, 91 | expires_at: Math.floor(session.expiresAt / 1000) 92 | }), 93 | { 94 | EXAT: Math.floor(session.expiresAt / 1000) 95 | } 96 | ); 97 | await redis.sadd(`user_sessions:${userId}`, sessionId); 98 | 99 | return session; 100 | } 101 | ``` 102 | 103 | Sessions are validated in 2 steps: 104 | 105 | 1. Does the session exist in your database? 106 | 2. Is it still within expiration? 107 | 108 | 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. 109 | 110 | ```ts 111 | import { redis } from "./redis.js"; 112 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 113 | import { sha256 } from "@oslojs/crypto/sha2"; 114 | 115 | // ... 116 | 117 | export async function validateSessionToken(token: string): Promise { 118 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 119 | const item = await redis.get(`session:${sessionId}`); 120 | if (item === null) { 121 | return null; 122 | } 123 | 124 | const result = JSON.parse(item); 125 | const session: Session = { 126 | id: result.id, 127 | userId: result.user_id, 128 | expiresAt: new Date(result.expires_at * 1000) 129 | }; 130 | if (Date.now() >= session.expiresAt.getTime()) { 131 | await redis.delete(`session:${sessionId}`); 132 | await redis.srem(`user_sessions:${userId}`, sessionId); 133 | return null; 134 | } 135 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 136 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 137 | await redis.set( 138 | `session:${session.id}`, 139 | JSON.stringify({ 140 | id: session.id, 141 | user_id: session.userId, 142 | expires_at: Math.floor(session.expiresAt / 1000) 143 | }), 144 | { 145 | EXAT: Math.floor(session.expiresAt / 1000) 146 | } 147 | ); 148 | } 149 | return session; 150 | } 151 | ``` 152 | 153 | Finally, invalidate sessions by simply deleting it from the database. 154 | 155 | ```ts 156 | import { redis } from "./redis.js"; 157 | 158 | // ... 159 | 160 | export async function invalidateSession(sessionId: string, userId: number): Promise { 161 | await redis.delete(`session:${sessionId}`); 162 | await redis.srem(`user_sessions:${userId}`, sessionId); 163 | } 164 | 165 | export async function invalidateAllSessions(userId: number): Promise { 166 | const sessionIds = await redis.smembers(`user_sessions:${userId}`); 167 | if (sessionIds.length < 1) { 168 | return; 169 | } 170 | 171 | const pipeline = redis.pipeline(); 172 | 173 | for (const sessionId of sessionIds) { 174 | pipeline.unlink(`session:${sessionId}`); 175 | } 176 | pipeline.unlink(`user_sessions:${userId}`); 177 | 178 | await pipeline.exec(); 179 | } 180 | ``` 181 | 182 | Here's the full code: 183 | 184 | ```ts 185 | import { redis } from "./redis.js"; 186 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 187 | import { sha256 } from "@oslojs/crypto/sha2"; 188 | 189 | export function generateSessionToken(): string { 190 | const bytes = new Uint8Array(20); 191 | crypto.getRandomValues(bytes); 192 | const token = encodeBase32LowerCaseNoPadding(bytes); 193 | return token; 194 | } 195 | 196 | export async function createSession(token: string, userId: number): Promise { 197 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 198 | const session: Session = { 199 | id: sessionId, 200 | userId, 201 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 202 | }; 203 | await redis.set( 204 | `session:${session.id}`, 205 | JSON.stringify({ 206 | id: session.id, 207 | user_id: session.userId, 208 | expires_at: Math.floor(session.expiresAt / 1000) 209 | }), 210 | { 211 | EXAT: Math.floor(session.expiresAt / 1000) 212 | } 213 | ); 214 | await redis.sadd(`user_sessions:${userId}`, sessionId); 215 | 216 | return session; 217 | } 218 | 219 | export async function validateSessionToken(token: string): Promise { 220 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 221 | const item = await redis.get(`session:${sessionId}`); 222 | if (item === null) { 223 | return null; 224 | } 225 | 226 | const result = JSON.parse(item); 227 | const session: Session = { 228 | id: result.id, 229 | userId: result.user_id, 230 | expiresAt: new Date(result.expires_at * 1000) 231 | }; 232 | if (Date.now() >= session.expiresAt.getTime()) { 233 | await redis.delete(`session:${sessionId}`); 234 | await redis.srem(`user_sessions:${userId}`, sessionId); 235 | return null; 236 | } 237 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 238 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 239 | await redis.set( 240 | `session:${session.id}`, 241 | JSON.stringify({ 242 | id: session.id, 243 | user_id: session.userId, 244 | expires_at: Math.floor(session.expiresAt / 1000) 245 | }), 246 | { 247 | EXAT: Math.floor(session.expiresAt / 1000) 248 | } 249 | ); 250 | } 251 | return session; 252 | } 253 | 254 | export async function invalidateSession(sessionId: string, userId: number): Promise { 255 | await redis.delete(`session:${sessionId}`); 256 | await redis.srem(`user_sessions:${userId}`, sessionId); 257 | } 258 | 259 | export async function invalidateAllSessions(userId: number): Promise { 260 | const sessionIds = await redis.smembers(`user_sessions:${userId}`); 261 | if (sessionIds.length < 1) { 262 | return; 263 | } 264 | 265 | const pipeline = redis.pipeline(); 266 | 267 | for (const sessionId of sessionIds) { 268 | pipeline.unlink(`session:${sessionId}`); 269 | } 270 | pipeline.unlink(`user_sessions:${userId}`); 271 | 272 | await pipeline.exec(); 273 | } 274 | 275 | export interface Session { 276 | id: string; 277 | userId: number; 278 | expiresAt: Date; 279 | } 280 | ``` 281 | 282 | ## Using your API 283 | 284 | When a user signs in, generate a session token with `generateSessionToken()` and create a session linked to it with `createSession()`. The token is provided to the user client. 285 | 286 | ```ts 287 | import { generateSessionToken, createSession } from "./session.js"; 288 | 289 | const token = generateSessionToken(); 290 | const session = createSession(token, userId); 291 | setSessionTokenCookie(token); 292 | ``` 293 | 294 | Validate a user-provided token with `validateSessionToken()`. 295 | 296 | ```ts 297 | import { validateSessionToken } from "./session.js"; 298 | 299 | const token = cookies.get("session"); 300 | if (token !== null) { 301 | const session = validateSessionToken(token); 302 | } 303 | ``` 304 | 305 | To learn how to store the token on the client, see the [Session cookies](/sessions/cookies) page. 306 | -------------------------------------------------------------------------------- /pages/sessions/basic-api/sqlite.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sessions with SQLite" 3 | --- 4 | 5 | # Sessions with SQLite 6 | 7 | Users will use a session token linked to a session instead of the ID directly. The session ID will be the SHA-256 hash of the token. SHA-256 is a one-way hash function. This ensures that even if the database contents were leaked, the attacker won't be able retrieve valid tokens. 8 | 9 | ## Declare your schema 10 | 11 | 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) here. 12 | 13 | ``` 14 | CREATE TABLE user ( 15 | id INTEGER NOT NULL PRIMARY KEY 16 | ); 17 | 18 | 19 | CREATE TABLE session ( 20 | id TEXT NOT NULL PRIMARY KEY, 21 | user_id INTEGER NOT NULL REFERENCES user(id), 22 | expires_at INTEGER NOT NULL 23 | ); 24 | ``` 25 | 26 | ## Install dependencies 27 | 28 | This page uses [Oslo](https://oslojs.dev) for various operations to support a wide range of runtimes. Oslo packages are fully-typed, lightweight, and has minimal dependencies. These packages are optional and can be replaced by runtime built-ins. 29 | 30 | ``` 31 | npm i @oslojs/encoding @oslojs/crypto 32 | ``` 33 | 34 | ## Create your API 35 | 36 | Here's what our API will look like. What each method does should be pretty self explanatory. 37 | 38 | If you just need the code full code without the explanation, skip to the end of this section. 39 | 40 | ```ts 41 | import { db } from "./db.js"; 42 | 43 | export function generateSessionToken(): string { 44 | // TODO 45 | } 46 | 47 | export function createSession(token: string, userId: number): Session { 48 | // TODO 49 | } 50 | 51 | export function validateSessionToken(token: string): SessionValidationResult { 52 | // TODO 53 | } 54 | 55 | export function invalidateSession(sessionId: string): void { 56 | // TODO 57 | } 58 | 59 | export async function invalidateAllSessions(userId: number): Promise { 60 | // TODO 61 | } 62 | 63 | export type SessionValidationResult = 64 | | { session: Session; user: User } 65 | | { session: null; user: null }; 66 | 67 | export interface Session { 68 | id: string; 69 | userId: number; 70 | expiresAt: Date; 71 | } 72 | 73 | export interface User { 74 | id: number; 75 | } 76 | ``` 77 | 78 | The session token 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. 79 | 80 | 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. 81 | 82 | - [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback) for older versions of Node.js. 83 | - [`expo-random`](https://docs.expo.dev/versions/v49.0.0/sdk/random/) for Expo. 84 | - [`react-native-get-random-bytes`](https://github.com/LinusU/react-native-get-random-values) for React Native. 85 | 86 | ```ts 87 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 88 | 89 | // ... 90 | 91 | export function generateSessionToken(): string { 92 | const bytes = new Uint8Array(20); 93 | crypto.getRandomValues(bytes); 94 | const token = encodeBase32LowerCaseNoPadding(bytes); 95 | return token; 96 | } 97 | ``` 98 | 99 | > You can use UUID v4 here but the RFC does not mandate that IDs are generated using a secure random source. Do not use libraries that are not clear on the source they use. Do not use other UUID versions as they do not offer the same entropy size as v4. Consider using [`Crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID). 100 | 101 | The session ID will be SHA-256 hash of the token. We'll set the expiration to 30 days. 102 | 103 | ```ts 104 | import { db } from "./db.js"; 105 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 106 | import { sha256 } from "@oslojs/crypto/sha2"; 107 | 108 | // ... 109 | 110 | export function createSession(token: string, userId: number): Session { 111 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 112 | const session: Session = { 113 | id: sessionId, 114 | userId, 115 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 116 | }; 117 | db.execute( 118 | "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)", 119 | session.id, 120 | session.userId, 121 | Math.floor(session.expiresAt.getTime() / 1000) 122 | ); 123 | return session; 124 | } 125 | ``` 126 | 127 | Sessions are validated in 2 steps: 128 | 129 | 1. Does the session exist in your database? 130 | 2. Is it still within expiration? 131 | 132 | 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. 133 | 134 | For convenience, we'll return both the session and user object tied to the session ID. 135 | 136 | ```ts 137 | import { db } from "./db.js"; 138 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 139 | import { sha256 } from "@oslojs/crypto/sha2"; 140 | 141 | // ... 142 | 143 | export function validateSessionToken(token: string): SessionValidationResult { 144 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 145 | const row = db.queryOne( 146 | "SELECT session.id, session.user_id, session.expires_at, user.id FROM session INNER JOIN user ON user.id = session.user_id WHERE id = ?", 147 | sessionId 148 | ); 149 | if (row === null) { 150 | return { session: null, user: null }; 151 | } 152 | const session: Session = { 153 | id: row[0], 154 | userId: row[1], 155 | expiresAt: new Date(row[2] * 1000) 156 | }; 157 | const user: User = { 158 | id: row[3] 159 | }; 160 | if (Date.now() >= session.expiresAt.getTime()) { 161 | db.execute("DELETE FROM session WHERE id = ?", session.id); 162 | return { session: null, user: null }; 163 | } 164 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 165 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 166 | db.execute( 167 | "UPDATE session SET expires_at = ? WHERE id = ?", 168 | Math.floor(session.expiresAt.getTime() / 1000), 169 | session.id 170 | ); 171 | } 172 | return { session, user }; 173 | } 174 | ``` 175 | 176 | Finally, invalidate sessions by simply deleting it from the database. 177 | 178 | ```ts 179 | import { db } from "./db.js"; 180 | 181 | // ... 182 | 183 | export function invalidateSession(sessionId: string): void { 184 | db.execute("DELETE FROM session WHERE id = ?", sessionId); 185 | } 186 | 187 | export async function invalidateAllSessions(userId: number): Promise { 188 | await db.execute("DELETE FROM user_session WHERE user_id = ?", userId); 189 | } 190 | ``` 191 | 192 | Here's the full code: 193 | 194 | ```ts 195 | import { db } from "./db.js"; 196 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 197 | import { sha256 } from "@oslojs/crypto/sha2"; 198 | 199 | export function generateSessionToken(): string { 200 | const bytes = new Uint8Array(20); 201 | crypto.getRandomValues(bytes); 202 | const token = encodeBase32LowerCaseNoPadding(bytes); 203 | return token; 204 | } 205 | 206 | export function createSession(token: string, userId: number): Session { 207 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 208 | const session: Session = { 209 | id: sessionId, 210 | userId, 211 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) 212 | }; 213 | db.execute( 214 | "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)", 215 | session.id, 216 | session.userId, 217 | Math.floor(session.expiresAt.getTime() / 1000) 218 | ); 219 | return session; 220 | } 221 | 222 | export function validateSessionToken(token: string): SessionValidationResult { 223 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 224 | const row = db.queryOne( 225 | "SELECT session.id, session.user_id, session.expires_at, user.id FROM session INNER JOIN user ON user.id = session.user_id WHERE id = ?", 226 | sessionId 227 | ); 228 | if (row === null) { 229 | return { session: null, user: null }; 230 | } 231 | const session: Session = { 232 | id: row[0], 233 | userId: row[1], 234 | expiresAt: new Date(row[2] * 1000) 235 | }; 236 | const user: User = { 237 | id: row[3] 238 | }; 239 | if (Date.now() >= session.expiresAt.getTime()) { 240 | db.execute("DELETE FROM session WHERE id = ?", session.id); 241 | return { session: null, user: null }; 242 | } 243 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 244 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 245 | db.execute( 246 | "UPDATE session SET expires_at = ? WHERE id = ?", 247 | Math.floor(session.expiresAt.getTime() / 1000), 248 | session.id 249 | ); 250 | } 251 | return { session, user }; 252 | } 253 | 254 | export function invalidateSession(sessionId: string): void { 255 | db.execute("DELETE FROM session WHERE id = ?", sessionId); 256 | } 257 | 258 | export async function invalidateAllSessions(userId: number): Promise { 259 | await db.execute("DELETE FROM user_session WHERE user_id = ?", userId); 260 | } 261 | 262 | export type SessionValidationResult = 263 | | { session: Session; user: User } 264 | | { session: null; user: null }; 265 | 266 | export interface Session { 267 | id: string; 268 | userId: number; 269 | expiresAt: Date; 270 | } 271 | 272 | export interface User { 273 | id: number; 274 | } 275 | ``` 276 | 277 | ## Using your API 278 | 279 | When a user signs in, generate a session token with `generateSessionToken()` and create a session linked to it with `createSession()`. The token is provided to the user client. 280 | 281 | ```ts 282 | import { generateSessionToken, createSession } from "./session.js"; 283 | 284 | const token = generateSessionToken(); 285 | const session = createSession(token, userId); 286 | setSessionTokenCookie(token); 287 | ``` 288 | 289 | Validate a user-provided token with `validateSessionToken()`. 290 | 291 | ```ts 292 | import { validateSessionToken } from "./session.js"; 293 | 294 | const token = cookies.get("session"); 295 | if (token !== null) { 296 | const { session, user } = validateSessionToken(token); 297 | } 298 | ``` 299 | 300 | To learn how to store the token on the client, see the [Session cookies](/sessions/cookies) page. 301 | -------------------------------------------------------------------------------- /pages/sessions/cookies/astro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Session cookies in Astro" 3 | --- 4 | 5 | # Session cookies in Astro 6 | 7 | _This page builds upon the API defined in the [Basic session API](/sessions/basic-api) page._ 8 | 9 | ## CSRF protection 10 | 11 | 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. 12 | 13 | ```ts 14 | // astro.config.mjs 15 | export default defineConfig({ 16 | output: "server", 17 | security: { 18 | checkOrigin: true 19 | } 20 | }); 21 | ``` 22 | 23 | ## Cookies 24 | 25 | Session cookies should have the following attributes: 26 | 27 | - `HttpOnly`: Cookies are only accessible server-side 28 | - `SameSite=Lax`: Use `Strict` for critical websites 29 | - `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) 30 | - `Max-Age` or `Expires`: Must be defined to persist cookies 31 | - `Path=/`: Cookies can be accessed from all routes 32 | 33 | > Lucia v3 used `auth_session` as the session cookie name. 34 | 35 | ```ts 36 | import type { APIContext } from "astro"; 37 | 38 | // ... 39 | 40 | export function setSessionTokenCookie(context: APIContext, token: string, expiresAt: Date): void { 41 | context.cookies.set("session", token, { 42 | httpOnly: true, 43 | sameSite: "lax", 44 | secure: import.meta.env.PROD, 45 | expires: expiresAt, 46 | path: "/" 47 | }); 48 | } 49 | 50 | export function deleteSessionTokenCookie(context: APIContext): void { 51 | context.cookies.set("session", "", { 52 | httpOnly: true, 53 | sameSite: "lax", 54 | secure: import.meta.env.PROD, 55 | maxAge: 0, 56 | path: "/" 57 | }); 58 | } 59 | ``` 60 | 61 | ## Session validation 62 | 63 | Session tokens can be validated using the `validateSessionToken()` function from the [Basic session API](/sessions/basic-api/) page. 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. 64 | 65 | ```ts 66 | import { 67 | validateSessionToken, 68 | setSessionTokenCookie, 69 | deleteSessionTokenCookie 70 | } from "$lib/server/session"; 71 | 72 | import type { APIContext } from "astro"; 73 | 74 | export async function GET(context: APIContext): Promise { 75 | const token = context.cookies.get("session")?.value ?? null; 76 | if (token === null) { 77 | return new Response(null, { 78 | status: 401 79 | }); 80 | } 81 | 82 | const { session, user } = await validateSessionToken(token); 83 | if (session === null) { 84 | deleteSessionTokenCookie(context); 85 | return new Response(null, { 86 | status: 401 87 | }); 88 | } 89 | setSessionTokenCookie(context, token, session.expiresAt); 90 | 91 | // ... 92 | } 93 | ``` 94 | 95 | We recommend handling session validation in middleware and passing the current auth context to each route. 96 | 97 | ```ts 98 | // src/env.d.ts 99 | 100 | /// 101 | declare namespace App { 102 | // Note: 'import {} from ""' syntax does not work in .d.ts files. 103 | interface Locals { 104 | session: import("./lib/server/session").Session | null; 105 | user: import("./lib/server/session").User | null; 106 | } 107 | } 108 | ``` 109 | 110 | ```ts 111 | // src/middleware.ts 112 | import { 113 | validateSession, 114 | setSessionTokenCookie, 115 | deleteSessionTokenCookie 116 | } from "./lib/server/session"; 117 | import { defineMiddleware } from "astro:middleware"; 118 | 119 | export const onRequest = defineMiddleware(async (context, next) => { 120 | const token = context.cookies.get("session")?.value ?? null; 121 | if (token === null) { 122 | context.locals.user = null; 123 | context.locals.session = null; 124 | return next(); 125 | } 126 | 127 | const { session, user } = await validateSessionToken(token); 128 | if (session !== null) { 129 | setSessionTokenCookie(context, token, session.expiresAt); 130 | } else { 131 | deleteSessionTokenCookie(context); 132 | } 133 | 134 | context.locals.session = session; 135 | context.locals.user = user; 136 | return next(); 137 | }); 138 | ``` 139 | 140 | Both the current user and session will be available in Astro files and API endpoints. 141 | 142 | ```ts 143 | --- 144 | if (Astro.locals.user === null) { 145 | return Astro.redirect("/login") 146 | } 147 | --- 148 | ``` 149 | 150 | ```ts 151 | export function GET(context: APIContext): Promise { 152 | if (context.locals.user === null) { 153 | return new Response(null, { 154 | status: 401 155 | }); 156 | } 157 | // ... 158 | } 159 | ``` 160 | -------------------------------------------------------------------------------- /pages/sessions/cookies/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Session cookies" 3 | --- 4 | 5 | # Session cookies 6 | 7 | _This page builds upon the API defined in the [Basic session API](/sessions/basic-api) page._ 8 | 9 | Framework and library specific guides are also available: 10 | 11 | - [Astro](/sessions/cookies/astro) 12 | - [Next.js](/sessions/cookies/nextjs) 13 | - [SvelteKit](/sessions/cookies/sveltekit) 14 | 15 | ## CSRF protection 16 | 17 | 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. 18 | 19 | ```ts 20 | // `HTTPRequest` and `HTTPResponse` are generic interfaces. 21 | // Adjust this code to fit your framework's API. 22 | 23 | function handleRequest(request: HTTPRequest, response: HTTPResponse): void { 24 | if (request.method !== "GET") { 25 | const origin = request.headers.get("Origin"); 26 | // You can also compare it against the Host or X-Forwarded-Host header. 27 | if (origin === null || origin !== "https://example.com") { 28 | response.setStatusCode(403); 29 | return; 30 | } 31 | } 32 | 33 | // ... 34 | } 35 | ``` 36 | 37 | ## Cookies 38 | 39 | If the frontend and backend are hosted on the same domain, session cookies should have the following attributes: 40 | 41 | - `HttpOnly`: Cookies are only accessible server-side 42 | - `SameSite=Lax`: Use `Strict` for critical websites 43 | - `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) 44 | - `Max-Age` or `Expires`: Must be defined to persist cookies 45 | - `Path=/`: Cookies can be accessed from all routes 46 | 47 | > Lucia v3 used `auth_session` as the session cookie name. 48 | 49 | ```ts 50 | // `HTTPResponse` is a generic interface. 51 | // Adjust this code to fit your framework's API. 52 | 53 | export function setSessionTokenCookie(response: HTTPResponse, token: string, expiresAt: Date): void { 54 | if (env === Env.PROD) { 55 | // When deployed over HTTPS 56 | response.headers.add( 57 | "Set-Cookie", 58 | `session=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure;` 59 | ); 60 | } else { 61 | // When deployed over HTTP (localhost) 62 | response.headers.add( 63 | "Set-Cookie", 64 | `session=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/` 65 | ); 66 | } 67 | } 68 | 69 | export function deleteSessionTokenCookie(response: HTTPResponse): void { 70 | if (env === Env.PROD) { 71 | // When deployed over HTTPS 72 | response.headers.add( 73 | "Set-Cookie", 74 | "session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure;" 75 | ); 76 | } else { 77 | // When deployed over HTTP (localhost) 78 | response.headers.add("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/"); 79 | } 80 | } 81 | ``` 82 | 83 | ## Session validation 84 | 85 | Session tokens can be validated using the `validateSessionToken()` function from the [Basic session API](/sessions/basic-api/) page. 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. 86 | 87 | ```ts 88 | import { 89 | validateSessionToken, 90 | deleteSessionTokenCookie, 91 | setSessionTokenCookie 92 | } from "./session.js"; 93 | 94 | // `HTTPRequest` and `HTTPResponse` are generic interfaces. 95 | // Adjust this code to fit your framework's API. 96 | 97 | function handleRequest(request: HTTPRequest, response: HTTPResponse): void { 98 | // csrf protection 99 | if (request.method !== "GET") { 100 | const origin = request.headers.get("Origin"); 101 | // You can also compare it against the Host or X-Forwarded-Host header. 102 | if (origin === null || origin !== "https://example.com") { 103 | response.setStatusCode(403); 104 | return; 105 | } 106 | } 107 | 108 | // session validation 109 | const cookies = parseCookieHeader(request.headers.get("Cookie") ?? ""); 110 | const token = cookies.get("session"); 111 | if (token === null) { 112 | response.setStatusCode(401); 113 | return; 114 | } 115 | 116 | const { session, user } = await validateSessionToken(token); 117 | if (session === null) { 118 | deleteSessionTokenCookie(response); 119 | response.setStatusCode(401); 120 | return; 121 | } 122 | setSessionTokenCookie(response, token, session, expiresAt); 123 | 124 | // ... 125 | } 126 | ``` 127 | -------------------------------------------------------------------------------- /pages/sessions/cookies/nextjs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Next.js" 3 | --- 4 | 5 | # Session cookies in Next.js 6 | 7 | _This page builds upon the API defined in the [Basic session API](/sessions/basic-api) page._ 8 | 9 | ## CSRF protection 10 | 11 | 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. 12 | 13 | ```ts 14 | // middleware.ts 15 | import { NextResponse } from "next/server"; 16 | 17 | import type { NextRequest } from "next/server"; 18 | 19 | export async function middleware(request: NextRequest): Promise { 20 | if (request.method === "GET") { 21 | return NextResponse.next(); 22 | } 23 | const originHeader = request.headers.get("Origin"); 24 | // NOTE: You may need to use `X-Forwarded-Host` instead 25 | const hostHeader = request.headers.get("Host"); 26 | if (originHeader === null || hostHeader === null) { 27 | return new NextResponse(null, { 28 | status: 403 29 | }); 30 | } 31 | let origin: URL; 32 | try { 33 | origin = new URL(originHeader); 34 | } catch { 35 | return new NextResponse(null, { 36 | status: 403 37 | }); 38 | } 39 | if (origin.host !== hostHeader) { 40 | return new NextResponse(null, { 41 | status: 403 42 | }); 43 | } 44 | return NextResponse.next(); 45 | } 46 | ``` 47 | 48 | ## Cookies 49 | 50 | Session cookies should have the following attributes: 51 | 52 | - `HttpOnly`: Cookies are only accessible server-side 53 | - `SameSite=Lax`: Use `Strict` for critical websites 54 | - `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) 55 | - `Max-Age` or `Expires`: Must be defined to persist cookies 56 | - `Path=/`: Cookies can be accessed from all routes 57 | 58 | > Lucia v3 used `auth_session` as the session cookie name. 59 | 60 | ```ts 61 | import { cookies } from "next/headers"; 62 | 63 | // ... 64 | 65 | export async function setSessionTokenCookie(token: string, expiresAt: Date): Promise { 66 | const cookieStore = await cookies(); 67 | cookieStore.set("session", token, { 68 | httpOnly: true, 69 | sameSite: "lax", 70 | secure: process.env.NODE_ENV === "production", 71 | expires: expiresAt, 72 | path: "/" 73 | }); 74 | } 75 | 76 | export async function deleteSessionTokenCookie(): Promise { 77 | const cookieStore = await cookies(); 78 | cookieStore.set("session", "", { 79 | httpOnly: true, 80 | sameSite: "lax", 81 | secure: process.env.NODE_ENV === "production", 82 | maxAge: 0, 83 | path: "/" 84 | }); 85 | } 86 | ``` 87 | 88 | > Before Next.js 15, `cookies()` was synchronous. If you are using an older version, you should replace `await cookies()` with `cookies()`. You should also switch the function return type to `void`, and remove the `async` keyword. 89 | 90 | Since we can't extend set cookies insides server components due to a limitation with React, we recommend continuously extending the cookie expiration inside middleware. However, this comes with its own issue. We can't detect if a new cookie was set inside server actions or route handlers from middleware. This becomes an issue if we need to assign a new session inside server actions (e.g. after updating the password) as the middleware cookie will override it. As such, we'll only extend the cookie expiration on GET requests. 91 | 92 | > While Lucia v3 recommended setup extended session cookie lifetime, it did not avoid the revalidation issue. 93 | 94 | ```ts 95 | // middleware.ts 96 | import { NextResponse } from "next/server"; 97 | 98 | import type { NextRequest } from "next/server"; 99 | 100 | export async function middleware(request: NextRequest): Promise { 101 | if (request.method === "GET") { 102 | const response = NextResponse.next(); 103 | const token = request.cookies.get("session")?.value ?? null; 104 | if (token !== null) { 105 | // Only extend cookie expiration on GET requests since we can be sure 106 | // a new session wasn't set when handling the request. 107 | response.cookies.set("session", token, { 108 | path: "/", 109 | maxAge: 60 * 60 * 24 * 30, 110 | sameSite: "lax", 111 | httpOnly: true, 112 | secure: process.env.NODE_ENV === "production" 113 | }); 114 | } 115 | return response; 116 | } 117 | 118 | // CSRF protection 119 | 120 | return NextResponse.next(); 121 | } 122 | ``` 123 | 124 | ## Session validation 125 | 126 | Session tokens can be validated using the `validateSessionToken()` function from the [Basic session API](/sessions/basic-api/) page. 127 | 128 | ```ts 129 | import { validateSessionToken } from "$lib/server/session"; 130 | 131 | import type { NextRequest } from "next/server"; 132 | 133 | export async function GET(request: NextRequest): Promise { 134 | const token = request.cookies.get("session")?.value ?? null; 135 | if (token === null) { 136 | return new Response(null, { 137 | status: 401 138 | }); 139 | } 140 | 141 | const { session, user } = await validateSessionToken(token); 142 | if (session === null) { 143 | return new Response(null, { 144 | status: 401 145 | }); 146 | } 147 | 148 | // ... 149 | } 150 | ``` 151 | 152 | We recommend creating a reusable `getCurrentSession()` function that wraps the validation logic with `cache()` so it can be called multiple times without incurring multiple database calls. 153 | 154 | ```ts 155 | import { cookies } from "next/headers"; 156 | import { cache } from "react"; 157 | 158 | // ... 159 | 160 | export const getCurrentSession = cache(async (): Promise => { 161 | const cookieStore = await cookies(); 162 | const token = cookieStore.get("session")?.value ?? null; 163 | if (token === null) { 164 | return { session: null, user: null }; 165 | } 166 | const result = await validateSessionToken(token); 167 | return result; 168 | }); 169 | ``` 170 | 171 | > On versions of Next.js below 15, replace `await cookies()` with `cookies()`. 172 | 173 | This function can be used in server components, server actions, and route handlers (but importantly not middleware). 174 | 175 | ```ts 176 | // app/api/page.tsx 177 | import { redirect } from "next/navigation"; 178 | 179 | async function Page() { 180 | const { user } = await getCurrentSession(); 181 | if (user === null) { 182 | return redirect("/login"); 183 | } 184 | 185 | async function action() { 186 | "use server"; 187 | const { user } = await getCurrentSession(); 188 | if (user === null) { 189 | return redirect("/login"); 190 | } 191 | // ... 192 | } 193 | // ... 194 | } 195 | ``` 196 | -------------------------------------------------------------------------------- /pages/sessions/cookies/sveltekit.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Session cookies in SvelteKit" 3 | --- 4 | 5 | # Session cookies in SvelteKit 6 | 7 | _This page builds upon the API defined in the [Basic session API](/sessions/basic-api) page._ 8 | 9 | ## Cookies 10 | 11 | CSRF protection is a must when using cookies. SvelteKit has basic CSRF protection using the `Origin` header is enabled by default. 12 | 13 | Session cookies should have the following attributes: 14 | 15 | - `HttpOnly`: Cookies are only accessible server-side 16 | - `SameSite=Lax`: Use `Strict` for critical websites 17 | - `Secure`: Cookies can only be sent over HTTPS (Should be omitted when testing on localhost) 18 | - `Max-Age` or `Expires`: Must be defined to persist cookies 19 | - `Path=/`: Cookies can be accessed from all routes 20 | 21 | SvelteKit automatically sets the `Secure` flag when deployed to production. 22 | 23 | > Lucia v3 used `auth_session` as the session cookie name. 24 | 25 | ```ts 26 | import type { RequestEvent } from "@sveltejs/kit"; 27 | 28 | // ... 29 | 30 | export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void { 31 | event.cookies.set("session", token, { 32 | httpOnly: true, 33 | sameSite: "lax", 34 | expires: expiresAt, 35 | path: "/" 36 | }); 37 | } 38 | 39 | export function deleteSessionTokenCookie(event: RequestEvent): void { 40 | event.cookies.set("session", "", { 41 | httpOnly: true, 42 | sameSite: "lax", 43 | maxAge: 0, 44 | path: "/" 45 | }); 46 | } 47 | ``` 48 | 49 | ## Session validation 50 | 51 | Session tokens can be validated using the `validateSessionToken()` function from the [Basic session API](/sessions/basic-api/) page. 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. 52 | 53 | ```ts 54 | // +page.server.ts 55 | import { fail, redirect } from "@sveltejs/kit"; 56 | import { 57 | validateSessionToken, 58 | setSessionTokenCookie, 59 | deleteSessionTokenCookie 60 | } from "$lib/server/session"; 61 | 62 | import type { Actions, PageServerLoad } from "./$types"; 63 | 64 | export const load: PageServerLoad = async (event) => { 65 | const token = event.cookies.get("session") ?? null; 66 | if (token === null) { 67 | return new Response(null, { 68 | status: 401 69 | }); 70 | } 71 | 72 | const { session, user } = await validateSessionToken(token); 73 | if (session === null) { 74 | deleteSessionTokenCookie(event); 75 | return new Response(null, { 76 | status: 401 77 | }); 78 | } 79 | setSessionTokenCookie(event, token, session.expiresAt); 80 | 81 | // ... 82 | }; 83 | ``` 84 | 85 | We recommend handling session validation in the handle hook and passing the current auth context to each route. 86 | 87 | ```ts 88 | // src/app.d.ts 89 | 90 | import type { User } from "$lib/server/user"; 91 | import type { Session } from "$lib/server/session"; 92 | 93 | declare global { 94 | namespace App { 95 | interface Locals { 96 | user: User | null; 97 | session: Session | null; 98 | } 99 | } 100 | } 101 | 102 | export {}; 103 | ``` 104 | 105 | ```ts 106 | // src/hooks.server.ts 107 | import { 108 | validateSessionToken, 109 | setSessionTokenCookie, 110 | deleteSessionTokenCookie 111 | } from "./lib/server/session"; 112 | 113 | import type { Handle } from "@sveltejs/kit"; 114 | 115 | export const handle: Handle = async ({ event, resolve }) => { 116 | const token = event.cookies.get("session") ?? null; 117 | if (token === null) { 118 | event.locals.user = null; 119 | event.locals.session = null; 120 | return resolve(event); 121 | } 122 | 123 | const { session, user } = await validateSessionToken(token); 124 | if (session !== null) { 125 | setSessionTokenCookie(event, token, session.expiresAt); 126 | } else { 127 | deleteSessionTokenCookie(event); 128 | } 129 | 130 | event.locals.session = session; 131 | event.locals.user = user; 132 | return resolve(event); 133 | }; 134 | ``` 135 | 136 | Both the current user and session will be available in loaders, actions, and endpoints. 137 | 138 | ```ts 139 | // +page.server.ts 140 | import { fail, redirect } from "@sveltejs/kit"; 141 | 142 | import type { Actions, PageServerLoad } from "./$types"; 143 | 144 | export const load: PageServerLoad = async (event) => { 145 | if (event.locals.user === null) { 146 | return redirect("/login"); 147 | } 148 | // ... 149 | }; 150 | 151 | export const actions: Actions = { 152 | default: async (event) => { 153 | if (event.locals.user === null) { 154 | throw fail(401); 155 | } 156 | // ... 157 | } 158 | }; 159 | ``` 160 | 161 | ```ts 162 | // +server.ts 163 | import { lucia } from "$lib/server/session"; 164 | 165 | export function GET(event: RequestEvent): Promise { 166 | if (event.locals.user === null) { 167 | return new Response(null, { 168 | status: 401 169 | }); 170 | } 171 | // ... 172 | } 173 | ``` 174 | -------------------------------------------------------------------------------- /pages/sessions/migrate-lucia-v3.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Migrate from Lucia v3" 3 | --- 4 | 5 | # Migrate from Lucia v3 6 | 7 | Because Lucia v3 is lightweight and relatively low-level, migrating your project shouldn't take long. Moreover, most of your knowledge will still be very useful. No database migrations are necessary. 8 | 9 | APIs on sessions are covered in the [Basic session API](/sessions/basic-api) page. 10 | 11 | - `Lucia.createSession()` => `generateSessionToken()` and `createSession()` 12 | - `Lucia.validateSession()` => `validateSessionToken()` 13 | - `Lucia.invalidateSession()` => `invalidateSession()` 14 | 15 | APIs on cookies are covered in the [Session cookies](/sessions/cookies) page. 16 | 17 | - `Lucia.createSessionCookie()` => `setSessionTokenCookie()` 18 | - `Lucia.createBlankSessionCookie()` => `deleteSessionTokenCookie()` 19 | 20 | The one change to how sessions work is that session tokens are now hashed before storage. The pre-hash token is the client-assigned session ID and the hash is the internal session ID. The easiest option would be to purge all existing sessions, but if you want keep existing sessions, SHA-256 and hex-encode the session IDs stored in the database. Or, you can skip the hashing altogether. Hashing is a good measure against database leaks, but not absolutely necessary. 21 | 22 | ```ts 23 | export function createSession(userId: number): Session { 24 | const bytes = new Uint8Array(20); 25 | crypto.getRandomValues(bytes); 26 | const sessionId = encodeBase32LowerCaseNoPadding(bytes); 27 | // Insert session into database. 28 | return session; 29 | } 30 | 31 | export function validateSessionToken(sessionId: string): SessionValidationResult { 32 | // Get and validate session 33 | return { session, user }; 34 | } 35 | ``` 36 | 37 | If you need help or have any questions, please ask them on [Discord](https://discord.com/invite/PwrK3kpVR3) or on [GitHub discussions](https://github.com/lucia-auth/lucia/discussions). 38 | -------------------------------------------------------------------------------- /pages/sessions/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sessions" 3 | --- 4 | 5 | # Sessions 6 | 7 | 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, with the user, by sending its ID. 8 | 9 | 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. 10 | 11 | This guide has 2 sections on sessions: 12 | 13 | - Basic session API: Create a basic session API using your database driver/ORM of choice. 14 | - Cookies: Define your session cookie using your JavaScript framework of choice. 15 | 16 | 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. 17 | -------------------------------------------------------------------------------- /pages/tutorials/github-oauth/astro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: GitHub OAuth in Astro" 3 | --- 4 | 5 | # Tutorial: GitHub OAuth in Astro 6 | 7 | _Before starting, make sure you've created the session and cookie API outlined in the [Sessions](/sessions/overview) page._ 8 | 9 | An [example project](https://github.com/lucia-auth/example-astro-github-oauth) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/example-astro-github-oauth). 10 | 11 | ``` 12 | git clone git@github.com:lucia-auth/example-astro-github-oauth.git 13 | ``` 14 | 15 | ## Create an OAuth App 16 | 17 | [Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). Set the redirect URI to `http://localhost:4321/login/github/callback`. Copy and paste the client ID and secret to your `.env` file. 18 | 19 | ```bash 20 | # .env 21 | GITHUB_CLIENT_ID="" 22 | GITHUB_CLIENT_SECRET="" 23 | ``` 24 | 25 | ## Update database 26 | 27 | Update your user model to include the user's GitHub ID and username. 28 | 29 | ```ts 30 | interface User { 31 | id: number; 32 | githubId: number; 33 | username: string; 34 | } 35 | ``` 36 | 37 | ## Setup Arctic 38 | 39 | We recommend using [Arctic](https://arcticjs.dev) for implementing OAuth. Arctic is a lightweight OAuth client library that supports 50+ providers out of the box. 40 | 41 | ``` 42 | npm install arctic 43 | ``` 44 | 45 | Initialize the GitHub provider with the client ID and secret. 46 | 47 | ```ts 48 | import { GitHub } from "arctic"; 49 | 50 | export const github = new GitHub( 51 | import.meta.env.GITHUB_CLIENT_ID, 52 | import.meta.env.GITHUB_CLIENT_SECRET, 53 | null 54 | ); 55 | ``` 56 | 57 | ## Sign in page 58 | 59 | Create `pages/login/index.astro` and add a basic sign in button, which should be a link to `/login/github`. 60 | 61 | ```html 62 | 63 | 64 | 65 |

Sign in

66 | Sign in with GitHub 67 | 68 | 69 | ``` 70 | 71 | ## Create authorization URL 72 | 73 | Create an API route in `pages/login/github/index.ts`. Generate a new state and create a new authorization URL. Store the state and redirect the user to the authorization URL. The user will be redirected to GitHub's sign in page. 74 | 75 | ```ts 76 | // pages/login/github/index.ts 77 | import { generateState } from "arctic"; 78 | import { github } from "@lib/oauth"; 79 | 80 | import type { APIContext } from "astro"; 81 | 82 | export async function GET(context: APIContext): Promise { 83 | const state = generateState(); 84 | const url = github.createAuthorizationURL(state, []); 85 | 86 | context.cookies.set("github_oauth_state", state, { 87 | path: "/", 88 | secure: import.meta.env.PROD, 89 | httpOnly: true, 90 | maxAge: 60 * 10, // 10 minutes 91 | sameSite: "lax" 92 | }); 93 | 94 | return context.redirect(url.toString()); 95 | } 96 | ``` 97 | 98 | ## Validate callback 99 | 100 | Create an API route in `pages/login/github/callback.ts` to handle the callback. Check that the state in the URL matches the one that's stored. Then, validate the authorization code and stored code verifier. Use the access token to get the user's profile with the GitHub API. Check if the user is already registered; if not, create a new user. Finally, create a new session and set the session cookie to complete the authentication process. 101 | 102 | ```ts 103 | // pages/login/github/callback.ts 104 | import { generateSessionToken, createSession, setSessionTokenCookie } from "@lib/session"; 105 | import { github } from "@lib/oauth"; 106 | 107 | import type { APIContext } from "astro"; 108 | import type { OAuth2Tokens } from "arctic"; 109 | 110 | export async function GET(context: APIContext): Promise { 111 | const code = context.url.searchParams.get("code"); 112 | const state = context.url.searchParams.get("state"); 113 | const storedState = context.cookies.get("github_oauth_state")?.value ?? null; 114 | if (code === null || state === null || storedState === null) { 115 | return new Response(null, { 116 | status: 400 117 | }); 118 | } 119 | if (state !== storedState) { 120 | return new Response(null, { 121 | status: 400 122 | }); 123 | } 124 | 125 | let tokens: OAuth2Tokens; 126 | try { 127 | tokens = await github.validateAuthorizationCode(code); 128 | } catch (e) { 129 | // Invalid code or client credentials 130 | return new Response(null, { 131 | status: 400 132 | }); 133 | } 134 | const githubUserResponse = await fetch("https://api.github.com/user", { 135 | headers: { 136 | Authorization: `Bearer ${tokens.accessToken()}` 137 | } 138 | }); 139 | const githubUser = await githubUserResponse.json(); 140 | const githubUserId = githubUser.id; 141 | const githubUsername = githubUser.login; 142 | 143 | // TODO: Replace this with your own DB query. 144 | const existingUser = await getUserFromGitHubId(githubUserId); 145 | 146 | if (existingUser !== null) { 147 | const sessionToken = generateSessionToken(); 148 | const session = await createSession(sessionToken, existingUser.id); 149 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 150 | return context.redirect("/"); 151 | } 152 | 153 | // TODO: Replace this with your own DB query. 154 | const user = await createUser(githubUserId, githubUsername); 155 | 156 | const sessionToken = generateSessionToken(); 157 | const session = await createSession(sessionToken, user.id); 158 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 159 | return context.redirect("/"); 160 | } 161 | ``` 162 | 163 | ## Get the current user 164 | 165 | If you implemented the middleware outlined in the [Session cookies in Astro](/sessions/cookies/astro) page, you can get the current session and user from `Locals`. 166 | 167 | ```ts 168 | if (Astro.locals.user === null) { 169 | return Astro.redirect("/login"); 170 | } 171 | 172 | const user = Astro.locals.user; 173 | ``` 174 | 175 | ## Sign out 176 | 177 | Sign out users by invalidating their session. Make sure to remove the session cookie as well. 178 | 179 | ```ts 180 | import { invalidateSession, deleteSessionTokenCookie } from "@lib/session"; 181 | 182 | import type { APIContext } from "astro"; 183 | 184 | export async function POST(context: APIContext): Promise { 185 | if (context.locals.session === null) { 186 | return new Response(null, { 187 | status: 401 188 | }); 189 | } 190 | await invalidateSession(context.locals.session.id); 191 | deleteSessionTokenCookie(context); 192 | return context.redirect("/login"); 193 | } 194 | ``` 195 | -------------------------------------------------------------------------------- /pages/tutorials/github-oauth/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: GitHub OAuth" 3 | --- 4 | 5 | # Tutorial: GitHub OAuth 6 | 7 | In this tutorial, you'll learn how to authenticate users with GitHub and persist sessions with the API you created. 8 | 9 | - [Astro](/tutorials/github-oauth/astro) 10 | - [Next.js](/tutorials/github-oauth/nextjs) 11 | - [SvelteKit](/tutorials/github-oauth/sveltekit) 12 | -------------------------------------------------------------------------------- /pages/tutorials/github-oauth/nextjs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: GitHub OAuth in Next.js" 3 | --- 4 | 5 | # Tutorial: GitHub OAuth in Next.js 6 | 7 | _Before starting, make sure you've created the session and cookie API outlined in the [Sessions](/sessions/overview) page._ 8 | 9 | An [example project](https://github.com/lucia-auth/example-nextjs-github-oauth) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/example-nextjs-github-oauth). 10 | 11 | ``` 12 | git clone git@github.com:lucia-auth/example-nextjs-github-oauth.git 13 | ``` 14 | 15 | ## Create an OAuth App 16 | 17 | [Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). Set the redirect URI to `http://localhost:3000/login/github/callback`. Copy and paste the client ID and secret to your `.env` file. 18 | 19 | ```bash 20 | # .env 21 | GITHUB_CLIENT_ID="" 22 | GITHUB_CLIENT_SECRET="" 23 | ``` 24 | 25 | ## Update database 26 | 27 | Update your user model to include the user's GitHub ID and username. 28 | 29 | ```ts 30 | interface User { 31 | id: number; 32 | githubId: number; 33 | username: string; 34 | } 35 | ``` 36 | 37 | ## Setup Arctic 38 | 39 | We recommend using [Arctic](https://arcticjs.dev) for implementing OAuth. Arctic is a lightweight OAuth client library that supports 50+ providers out of the box. 40 | 41 | ``` 42 | npm install arctic 43 | ``` 44 | 45 | Initialize the GitHub provider with the client ID and secret. 46 | 47 | ```ts 48 | import { GitHub } from "arctic"; 49 | 50 | export const github = new GitHub( 51 | process.env.GITHUB_CLIENT_ID, 52 | process.env.GITHUB_CLIENT_SECRET, 53 | null 54 | ); 55 | ``` 56 | 57 | ## Sign in page 58 | 59 | Create `app/login/page.tsx` and add a basic sign in button, which should be a link to `/login/github`. 60 | 61 | ```tsx 62 | // app/login/page.tsx 63 | export default async function Page() { 64 | return ( 65 | <> 66 |

Sign in

67 | Sign in with GitHub 68 | 69 | ); 70 | } 71 | ``` 72 | 73 | ## Create authorization URL 74 | 75 | Create an Route Handlers in `app/login/github/route.ts`. Generate a new state and create a new authorization URL. Store the state and redirect the user to the authorization URL. The user will be redirected to GitHub's sign in page. 76 | 77 | ```ts 78 | // app/login/github/route.ts 79 | import { generateState } from "arctic"; 80 | import { github } from "@/lib/oauth"; 81 | import { cookies } from "next/headers"; 82 | 83 | export async function GET(): Promise { 84 | const state = generateState(); 85 | const url = github.createAuthorizationURL(state, []); 86 | 87 | const cookieStore = await cookies(); 88 | cookieStore.set("github_oauth_state", state, { 89 | path: "/", 90 | secure: process.env.NODE_ENV === "production", 91 | httpOnly: true, 92 | maxAge: 60 * 10, 93 | sameSite: "lax" 94 | }); 95 | 96 | return new Response(null, { 97 | status: 302, 98 | headers: { 99 | Location: url.toString() 100 | } 101 | }); 102 | } 103 | ``` 104 | 105 | ## Validate callback 106 | 107 | Create an Route Handlers in `app/login/github/callback/route.ts` to handle the callback. Check that the state in the URL matches the one that's stored. Then, validate the authorization code and stored code verifier. Use the access token to get the user's profile with the GitHub API. Check if the user is already registered; if not, create a new user. Finally, create a new session and set the session cookie to complete the authentication process. 108 | 109 | ```ts 110 | // app/login/github/callback/route.ts 111 | import { generateSessionToken, createSession, setSessionTokenCookie } from "@/lib/session"; 112 | import { github } from "@/lib/oauth"; 113 | import { cookies } from "next/headers"; 114 | 115 | import type { OAuth2Tokens } from "arctic"; 116 | 117 | export async function GET(request: Request): Promise { 118 | const url = new URL(request.url); 119 | const code = url.searchParams.get("code"); 120 | const state = url.searchParams.get("state"); 121 | const cookieStore = await cookies(); 122 | const storedState = cookieStore.get("github_oauth_state")?.value ?? null; 123 | if (code === null || state === null || storedState === null) { 124 | return new Response(null, { 125 | status: 400 126 | }); 127 | } 128 | if (state !== storedState) { 129 | return new Response(null, { 130 | status: 400 131 | }); 132 | } 133 | 134 | let tokens: OAuth2Tokens; 135 | try { 136 | tokens = await github.validateAuthorizationCode(code); 137 | } catch (e) { 138 | // Invalid code or client credentials 139 | return new Response(null, { 140 | status: 400 141 | }); 142 | } 143 | const githubUserResponse = await fetch("https://api.github.com/user", { 144 | headers: { 145 | Authorization: `Bearer ${tokens.accessToken()}` 146 | } 147 | }); 148 | const githubUser = await githubUserResponse.json(); 149 | const githubUserId = githubUser.id; 150 | const githubUsername = githubUser.login; 151 | 152 | // TODO: Replace this with your own DB query. 153 | const existingUser = await getUserFromGitHubId(githubUserId); 154 | 155 | if (existingUser !== null) { 156 | const sessionToken = generateSessionToken(); 157 | const session = await createSession(sessionToken, existingUser.id); 158 | await setSessionTokenCookie(sessionToken, session.expiresAt); 159 | return new Response(null, { 160 | status: 302, 161 | headers: { 162 | Location: "/" 163 | } 164 | }); 165 | } 166 | 167 | // TODO: Replace this with your own DB query. 168 | const user = await createUser(githubUserId, githubUsername); 169 | 170 | const sessionToken = generateSessionToken(); 171 | const session = await createSession(sessionToken, user.id); 172 | await setSessionTokenCookie(sessionToken, session.expiresAt); 173 | return new Response(null, { 174 | status: 302, 175 | headers: { 176 | Location: "/" 177 | } 178 | }); 179 | } 180 | ``` 181 | 182 | ## Validate requests 183 | 184 | Use the `getCurrentSession()` function from the [Session cookies in Next.js](/sessions/cookies/nextjs) page to get the current user and session. 185 | 186 | ```tsx 187 | import { redirect } from "next/navigation"; 188 | import { getCurrentSession } from "@/lib/session"; 189 | 190 | export default async function Page() { 191 | const { user } = await getCurrentSession(); 192 | if (user === null) { 193 | return redirect("/login"); 194 | } 195 | return

Hi, {user.username}!

; 196 | } 197 | ``` 198 | 199 | ## Sign out 200 | 201 | Sign out users by invalidating their session. Make sure to remove the session cookie as well. 202 | 203 | ```tsx 204 | import { getCurrentSession, invalidateSession, deleteSessionTokenCookie } from "@/lib/session"; 205 | import { redirect } from "next/navigation"; 206 | import { cookies } from "next/headers"; 207 | 208 | export default async function Page() { 209 | return ( 210 |
211 | 212 |
213 | ); 214 | } 215 | 216 | async function logout(): Promise { 217 | "use server"; 218 | const { session } = await getCurrentSession(); 219 | if (!session) { 220 | return { 221 | error: "Unauthorized" 222 | }; 223 | } 224 | 225 | await invalidateSession(session.id); 226 | await deleteSessionTokenCookie(); 227 | return redirect("/login"); 228 | } 229 | 230 | interface ActionResult { 231 | error: string | null; 232 | } 233 | ``` 234 | -------------------------------------------------------------------------------- /pages/tutorials/github-oauth/sveltekit.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: GitHub OAuth in SvelteKit" 3 | --- 4 | 5 | # Tutorial: GitHub OAuth in SvelteKit 6 | 7 | _Before starting, make sure you've created the session and cookie API outlined in the [Sessions](/sessions/overview) page._ 8 | 9 | An [example project](https://github.com/lucia-auth/example-sveltekit-github-oauth) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/example-sveltekit-github-oauth). 10 | 11 | ``` 12 | git clone git@github.com:lucia-auth/example-sveltekit-github-oauth.git 13 | ``` 14 | 15 | ## Create an OAuth App 16 | 17 | [Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). Set the redirect URI to `http://localhost:5173/login/github/callback`. Copy and paste the client ID and secret to your `.env` file. 18 | 19 | ```bash 20 | # .env 21 | GITHUB_CLIENT_ID="" 22 | GITHUB_CLIENT_SECRET="" 23 | ``` 24 | 25 | ## Update database 26 | 27 | Update your user model to include the user's GitHub ID and username. 28 | 29 | ```ts 30 | interface User { 31 | id: number; 32 | githubId: number; 33 | username: string; 34 | } 35 | ``` 36 | 37 | ## Setup Arctic 38 | 39 | We recommend using [Arctic](https://arcticjs.dev) for implementing OAuth. Arctic is a lightweight OAuth client library that supports 50+ providers out of the box. 40 | 41 | ``` 42 | npm install arctic 43 | ``` 44 | 45 | Initialize the GitHub provider with the client ID and secret. 46 | 47 | ```ts 48 | import { GitHub } from "arctic"; 49 | import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from "$env/static/private"; 50 | 51 | export const github = new GitHub(GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, null); 52 | ``` 53 | 54 | ## Sign in page 55 | 56 | Create `routes/login/+page.svelte` and add a basic sign in button, which should be a link to `/login/github`. 57 | 58 | ```svelte 59 | 60 |

Sign in

61 | Sign in with GitHub 62 | ``` 63 | 64 | ## Create authorization URL 65 | 66 | Create an API route in `routes/login/github/+server.ts`. Generate a new state and create a new authorization URL. Store the state and redirect the user to the authorization URL. The user will be redirected to GitHub's sign in page. 67 | 68 | ```ts 69 | // routes/login/github/+server.ts 70 | import { generateState } from "arctic"; 71 | import { github } from "$lib/server/oauth"; 72 | 73 | import type { RequestEvent } from "@sveltejs/kit"; 74 | 75 | export async function GET(event: RequestEvent): Promise { 76 | const state = generateState(); 77 | const url = github.createAuthorizationURL(state, []); 78 | 79 | event.cookies.set("github_oauth_state", state, { 80 | path: "/", 81 | httpOnly: true, 82 | maxAge: 60 * 10, 83 | sameSite: "lax" 84 | }); 85 | 86 | return new Response(null, { 87 | status: 302, 88 | headers: { 89 | Location: url.toString() 90 | } 91 | }); 92 | } 93 | ``` 94 | 95 | ## Validate callback 96 | 97 | Create an API route in `routes/login/github/callback/+server.ts` to handle the callback. Check that the state in the URL matches the one that's stored. Then, validate the authorization code and stored code verifier. Use the access token to get the user's profile with the GitHub API. Check if the user is already registered; if not, create a new user. Finally, create a new session and set the session cookie to complete the authentication process. 98 | 99 | ```ts 100 | // routes/login/github/callback/+server.ts 101 | import { generateSessionToken, createSession, setSessionTokenCookie } from "$lib/server/session"; 102 | import { github } from "$lib/server/oauth"; 103 | 104 | import type { RequestEvent } from "@sveltejs/kit"; 105 | import type { OAuth2Tokens } from "arctic"; 106 | 107 | export async function GET(event: RequestEvent): Promise { 108 | const code = event.url.searchParams.get("code"); 109 | const state = event.url.searchParams.get("state"); 110 | const storedState = event.cookies.get("github_oauth_state") ?? null; 111 | if (code === null || state === null || storedState === null) { 112 | return new Response(null, { 113 | status: 400 114 | }); 115 | } 116 | if (state !== storedState) { 117 | return new Response(null, { 118 | status: 400 119 | }); 120 | } 121 | 122 | let tokens: OAuth2Tokens; 123 | try { 124 | tokens = await github.validateAuthorizationCode(code); 125 | } catch (e) { 126 | // Invalid code or client credentials 127 | return new Response(null, { 128 | status: 400 129 | }); 130 | } 131 | const githubUserResponse = await fetch("https://api.github.com/user", { 132 | headers: { 133 | Authorization: `Bearer ${tokens.accessToken()}` 134 | } 135 | }); 136 | const githubUser = await githubUserResponse.json(); 137 | const githubUserId = githubUser.id; 138 | const githubUsername = githubUser.login; 139 | 140 | // TODO: Replace this with your own DB query. 141 | const existingUser = await getUserFromGitHubId(githubUserId); 142 | 143 | if (existingUser) { 144 | const sessionToken = generateSessionToken(); 145 | const session = await createSession(sessionToken, user.id); 146 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 147 | return new Response(null, { 148 | status: 302, 149 | headers: { 150 | Location: "/" 151 | } 152 | }); 153 | } 154 | 155 | // TODO: Replace this with your own DB query. 156 | const user = await createUser(githubUserId, githubUsername); 157 | 158 | const sessionToken = generateSessionToken(); 159 | const session = await createSession(sessionToken, user.id); 160 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 161 | 162 | return new Response(null, { 163 | status: 302, 164 | headers: { 165 | Location: "/" 166 | } 167 | }); 168 | } 169 | ``` 170 | 171 | ## Get the current user 172 | 173 | If you implemented the middleware outlined in the [Session cookies in SvelteKit](/sessions/cookies/sveltekit) page, you can get the current session and user from `Locals`. 174 | 175 | ```ts 176 | // routes/+page.server.ts 177 | import { redirect } from "@sveltejs/kit"; 178 | 179 | import type { PageServerLoad } from "./$types"; 180 | 181 | export const load: PageServerLoad = async (event) => { 182 | if (!event.locals.user) { 183 | return redirect(302, "/login"); 184 | } 185 | 186 | return { 187 | user 188 | }; 189 | }; 190 | ``` 191 | 192 | ## Sign out 193 | 194 | Sign out users by invalidating their session. Make sure to remove the session cookie as well. 195 | 196 | ```ts 197 | // routes/+page.server.ts 198 | import { fail, redirect } from "@sveltejs/kit"; 199 | import { invalidateSession, deleteSessionTokenCookie } from "$lib/server/session"; 200 | 201 | import type { Actions, PageServerLoad } from "./$types"; 202 | 203 | export const load: PageServerLoad = async ({ locals }) => { 204 | // ... 205 | }; 206 | 207 | export const actions: Actions = { 208 | default: async (event) => { 209 | if (event.locals.session === null) { 210 | return fail(401); 211 | } 212 | await invalidateSession(event.locals.session.id); 213 | deleteSessionTokenCookie(event); 214 | return redirect(302, "/login"); 215 | } 216 | }; 217 | ``` 218 | 219 | ```svelte 220 | 221 | 224 | 225 |
226 | 227 |
228 | ``` 229 | -------------------------------------------------------------------------------- /pages/tutorials/google-oauth/astro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: Google OAuth in Astro" 3 | --- 4 | 5 | # Tutorial: Google OAuth in Astro 6 | 7 | _Before starting, make sure you've created the session and cookie API outlined in the [Sessions](/sessions/overview) page._ 8 | 9 | An [example project](https://github.com/lucia-auth/example-astro-google-oauth) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/example-astro-google-oauth). 10 | 11 | ``` 12 | git clone git@github.com:lucia-auth/example-astro-google-oauth.git 13 | ``` 14 | 15 | ## Create an OAuth App 16 | 17 | Create an Google OAuth client on the Cloud Console. Set the redirect URI to `http://localhost:4321/login/google/callback`. Copy and paste the client ID and secret to your `.env` file. 18 | 19 | ```bash 20 | # .env 21 | GOOGLE_CLIENT_ID="" 22 | GOOGLE_CLIENT_SECRET="" 23 | ``` 24 | 25 | ## Update database 26 | 27 | Update your user model to include the user's Google ID and username. 28 | 29 | ```ts 30 | interface User { 31 | id: number; 32 | googleId: string; 33 | name: string; 34 | } 35 | ``` 36 | 37 | ## Setup Arctic 38 | 39 | We recommend using [Arctic](https://arcticjs.dev) for implementing OAuth. Arctic is a lightweight OAuth client library that supports 50+ providers out of the box. 40 | 41 | ``` 42 | npm install arctic 43 | ``` 44 | 45 | Initialize the Google provider with the client ID and secret. 46 | 47 | ```ts 48 | import { Google } from "arctic"; 49 | 50 | export const google = new Google( 51 | import.meta.env.GOOGLE_CLIENT_ID, 52 | import.meta.env.GOOGLE_CLIENT_SECRET, 53 | "http://localhost:4321/login/google/callback" 54 | ); 55 | ``` 56 | 57 | ## Sign in page 58 | 59 | Create `pages/login/index.astro` and add a basic sign in button, which should be a link to `/login/google`. 60 | 61 | ```html 62 | 63 | 64 | 65 |

Sign in

66 | Sign in with Google 67 | 68 | 69 | ``` 70 | 71 | ## Create authorization URL 72 | 73 | Create an API route in `pages/login/google/index.ts`. Generate a new state and code verifier, and create a new authorization URL. Add the `openid` and `profile` scope to have access to the user's profile later on. Store the state and code verifier, and redirect the user to the authorization URL. The user will be redirected to Google's sign in page. 74 | 75 | ```ts 76 | // pages/login/google/index.ts 77 | import { generateState } from "arctic"; 78 | import { google } from "@lib/oauth"; 79 | 80 | import type { APIContext } from "astro"; 81 | 82 | export async function GET(context: APIContext): Promise { 83 | const state = generateState(); 84 | const codeVerifier = generateCodeVerifier(); 85 | const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "profile"]); 86 | 87 | context.cookies.set("google_oauth_state", state, { 88 | path: "/", 89 | secure: import.meta.env.PROD, 90 | httpOnly: true, 91 | maxAge: 60 * 10, // 10 minutes 92 | sameSite: "lax" 93 | }); 94 | context.cookies.set("google_code_verifier", codeVerifier, { 95 | path: "/", 96 | secure: import.meta.env.PROD, 97 | httpOnly: true, 98 | maxAge: 60 * 10, // 10 minutes 99 | sameSite: "lax" 100 | }); 101 | 102 | return context.redirect(url.toString()); 103 | } 104 | ``` 105 | 106 | ## Validate callback 107 | 108 | Create an API route in `pages/login/google/callback.ts` to handle the callback. Check that the state in the URL matches the one that's stored. Then, validate the authorization code and stored code verifier. If you passed the `openid` and `profile` scope, Google will return a token ID with the user's profile. Check if the user is already registered; if not, create a new user. Finally, create a new session and set the session cookie to complete the authentication process. 109 | 110 | ```ts 111 | // pages/login/google/callback.ts 112 | import { generateSessionToken, createSession, setSessionTokenCookie } from "@lib/server/session"; 113 | import { google } from "@lib/oauth"; 114 | import { decodeIdToken } from "arctic"; 115 | 116 | import type { APIContext } from "astro"; 117 | import type { OAuth2Tokens } from "arctic"; 118 | 119 | export async function GET(context: APIContext): Promise { 120 | const code = context.url.searchParams.get("code"); 121 | const state = context.url.searchParams.get("state"); 122 | const storedState = context.cookies.get("google_oauth_state")?.value ?? null; 123 | const codeVerifier = context.cookies.get("google_code_verifier")?.value ?? null; 124 | if (code === null || state === null || storedState === null || codeVerifier === null) { 125 | return new Response(null, { 126 | status: 400 127 | }); 128 | } 129 | if (state !== storedState) { 130 | return new Response(null, { 131 | status: 400 132 | }); 133 | } 134 | 135 | let tokens: OAuth2Tokens; 136 | try { 137 | tokens = await google.validateAuthorizationCode(code, codeVerifier); 138 | } catch (e) { 139 | // Invalid code or client credentials 140 | return new Response(null, { 141 | status: 400 142 | }); 143 | } 144 | const claims = decodeIdToken(tokens.idToken()); 145 | const googleUserId = claims.sub; 146 | const username = claims.name; 147 | 148 | // TODO: Replace this with your own DB query. 149 | const existingUser = await getUserFromGoogleId(googleUserId); 150 | 151 | if (existingUser !== null) { 152 | const sessionToken = generateSessionToken(); 153 | const session = await createSession(sessionToken, existingUser.id); 154 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 155 | return context.redirect("/"); 156 | } 157 | 158 | // TODO: Replace this with your own DB query. 159 | const user = await createUser(googleUserId, username); 160 | 161 | const sessionToken = generateSessionToken(); 162 | const session = await createSession(sessionToken, user.id); 163 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 164 | return context.redirect("/"); 165 | } 166 | ``` 167 | 168 | ## Get the current user 169 | 170 | If you implemented the middleware outlined in the [Session cookies in Astro](/sessions/cookies/astro) page, you can get the current session and user from `Locals`. 171 | 172 | ```ts 173 | if (Astro.locals.user === null) { 174 | return Astro.redirect("/login"); 175 | } 176 | 177 | const user = Astro.locals.user; 178 | ``` 179 | 180 | ## Sign out 181 | 182 | Sign out users by invalidating their session. Make sure to remove the session cookie as well. 183 | 184 | ```ts 185 | import { invalidateSession, deleteSessionTokenCookie } from "@lib/server/session"; 186 | 187 | import type { APIContext } from "astro"; 188 | 189 | export async function POST(context: APIContext): Promise { 190 | if (context.locals.session === null) { 191 | return new Response(null, { 192 | status: 401 193 | }); 194 | } 195 | await invalidateSession(context.locals.session.id); 196 | deleteSessionTokenCookie(context); 197 | return context.redirect("/login"); 198 | } 199 | ``` 200 | -------------------------------------------------------------------------------- /pages/tutorials/google-oauth/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: Google OAuth" 3 | --- 4 | 5 | # Tutorial: Google OAuth 6 | 7 | In this tutorial, you'll learn how to authenticate users with Google and persist sessions with the API you created. 8 | 9 | - [Astro](/tutorials/google-oauth/astro) 10 | - [Next.js](/tutorials/google-oauth/nextjs) 11 | - [SvelteKit](/tutorials/google-oauth/sveltekit) 12 | -------------------------------------------------------------------------------- /pages/tutorials/google-oauth/nextjs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: Google OAuth in Next.js" 3 | --- 4 | 5 | # Tutorial: Google OAuth in Next.js 6 | 7 | _Before starting, make sure you've created the session and cookie API outlined in the [Sessions](/sessions/overview) page._ 8 | 9 | An [example project](https://github.com/lucia-auth/example-nextjs-google-oauth) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/example-nextjs-google-oauth). 10 | 11 | ``` 12 | git clone git@github.com:lucia-auth/example-nextjs-google-oauth.git 13 | ``` 14 | 15 | ## Create an OAuth App 16 | 17 | Create an Google OAuth client on the Cloud Console. Set the redirect URI to `http://localhost:3000/login/google/callback`. Copy and paste the client ID and secret to your `.env` file. 18 | 19 | ```bash 20 | # .env 21 | GOOGLE_CLIENT_ID="" 22 | GOOGLE_CLIENT_SECRET="" 23 | ``` 24 | 25 | ## Update database 26 | 27 | Update your user model to include the user's Google ID and username. 28 | 29 | ```ts 30 | interface User { 31 | id: number; 32 | googleId: string; 33 | name: string; 34 | } 35 | ``` 36 | 37 | ## Setup Arctic 38 | 39 | ``` 40 | npm install arctic 41 | ``` 42 | 43 | Initialize the Google provider with the client ID, client secret, and redirect URI. 44 | 45 | ```ts 46 | import { Google } from "arctic"; 47 | 48 | export const google = new Google( 49 | process.env.GOOGLE_CLIENT_ID, 50 | process.env.GOOGLE_CLIENT_SECRET, 51 | "http://localhost:3000/login/google/callback" 52 | ); 53 | ``` 54 | 55 | ## Sign in page 56 | 57 | Create `app/login/page.tsx` and add a basic sign in button, which should be a link to `/login/google`. 58 | 59 | ```tsx 60 | // app/login/page.tsx 61 | export default async function Page() { 62 | return ( 63 | <> 64 |

Sign in

65 | Sign in with Google 66 | 67 | ); 68 | } 69 | ``` 70 | 71 | ## Create authorization URL 72 | 73 | Create an API route in `app/login/google/route.ts`. Generate a new state and code verifier, and create a new authorization URL. Add the `openid` and `profile` scope to have access to the user's profile later on. Store the state and code verifier, and redirect the user to the authorization URL. The user will be redirected to Google's sign in page. 74 | 75 | ```ts 76 | // app/login/google/route.ts 77 | import { generateState, generateCodeVerifier } from "arctic"; 78 | import { google } from "@/lib/auth"; 79 | import { cookies } from "next/headers"; 80 | 81 | export async function GET(): Promise { 82 | const state = generateState(); 83 | const codeVerifier = generateCodeVerifier(); 84 | const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "profile"]); 85 | 86 | const cookieStore = await cookies(); 87 | cookieStore.set("google_oauth_state", state, { 88 | path: "/", 89 | httpOnly: true, 90 | secure: process.env.NODE_ENV === "production", 91 | maxAge: 60 * 10, // 10 minutes 92 | sameSite: "lax" 93 | }); 94 | cookieStore.set("google_code_verifier", codeVerifier, { 95 | path: "/", 96 | httpOnly: true, 97 | secure: process.env.NODE_ENV === "production", 98 | maxAge: 60 * 10, // 10 minutes 99 | sameSite: "lax" 100 | }); 101 | 102 | return new Response(null, { 103 | status: 302, 104 | headers: { 105 | Location: url.toString() 106 | } 107 | }); 108 | } 109 | ``` 110 | 111 | ## Validate callback 112 | 113 | Create an Route Handlers in `app/login/google/callback/route.ts` to handle the callback. Check that the state in the URL matches the one that's stored. Then, validate the authorization code and stored code verifier. If you passed the `openid` and `profile` scope, Google will return a token ID with the user's profile. Check if the user is already registered; if not, create a new user. Finally, create a new session and set the session cookie to complete the authentication process. 114 | 115 | ```ts 116 | // app/login/google/callback/route.ts 117 | import { generateSessionToken, createSession, setSessionTokenCookie } from "@/lib/session"; 118 | import { google } from "@/lib/oauth"; 119 | import { cookies } from "next/headers"; 120 | import { decodeIdToken } from "arctic"; 121 | 122 | import type { OAuth2Tokens } from "arctic"; 123 | 124 | export async function GET(request: Request): Promise { 125 | const url = new URL(request.url); 126 | const code = url.searchParams.get("code"); 127 | const state = url.searchParams.get("state"); 128 | const cookieStore = await cookies(); 129 | const storedState = cookieStore.get("google_oauth_state")?.value ?? null; 130 | const codeVerifier = cookieStore.get("google_code_verifier")?.value ?? null; 131 | if (code === null || state === null || storedState === null || codeVerifier === null) { 132 | return new Response(null, { 133 | status: 400 134 | }); 135 | } 136 | if (state !== storedState) { 137 | return new Response(null, { 138 | status: 400 139 | }); 140 | } 141 | 142 | let tokens: OAuth2Tokens; 143 | try { 144 | tokens = await google.validateAuthorizationCode(code, codeVerifier); 145 | } catch (e) { 146 | // Invalid code or client credentials 147 | return new Response(null, { 148 | status: 400 149 | }); 150 | } 151 | const claims = decodeIdToken(tokens.idToken()); 152 | const googleUserId = claims.sub; 153 | const username = claims.name; 154 | 155 | // TODO: Replace this with your own DB query. 156 | const existingUser = await getUserFromGoogleId(googleUserId); 157 | 158 | if (existingUser !== null) { 159 | const sessionToken = generateSessionToken(); 160 | const session = await createSession(sessionToken, existingUser.id); 161 | await setSessionTokenCookie(sessionToken, session.expiresAt); 162 | return new Response(null, { 163 | status: 302, 164 | headers: { 165 | Location: "/" 166 | } 167 | }); 168 | } 169 | 170 | // TODO: Replace this with your own DB query. 171 | const user = await createUser(googleUserId, username); 172 | 173 | const sessionToken = generateSessionToken(); 174 | const session = await createSession(sessionToken, user.id); 175 | await setSessionTokenCookie(sessionToken, session.expiresAt); 176 | return new Response(null, { 177 | status: 302, 178 | headers: { 179 | Location: "/" 180 | } 181 | }); 182 | } 183 | ``` 184 | 185 | ## Validate requests 186 | 187 | Use the `getCurrentSession()` function from the [Session cookies in Next.js](/sessions/cookies/nextjs) page to get the current user and session. 188 | 189 | ```tsx 190 | import { redirect } from "next/navigation"; 191 | import { getCurrentSession } from "@/lib/session"; 192 | 193 | export default async function Page() { 194 | const { user } = await getCurrentSession(); 195 | if (user === null) { 196 | return redirect("/login"); 197 | } 198 | return

Hi, {user.name}!

; 199 | } 200 | ``` 201 | 202 | ## Sign out 203 | 204 | Sign out users by invalidating their session. Make sure to remove the session cookie as well. 205 | 206 | ```tsx 207 | import { getCurrentSession, invalidateSession, deleteSessionTokenCookie } from "@/lib/session"; 208 | import { redirect } from "next/navigation"; 209 | import { cookies } from "next/headers"; 210 | 211 | export default async function Page() { 212 | return ( 213 |
214 | 215 |
216 | ); 217 | } 218 | 219 | async function logout(): Promise { 220 | "use server"; 221 | const { session } = await getCurrentSession(); 222 | if (!session) { 223 | return { 224 | error: "Unauthorized" 225 | }; 226 | } 227 | 228 | await invalidateSession(session.id); 229 | await deleteSessionTokenCookie(); 230 | return redirect("/login"); 231 | } 232 | 233 | interface ActionResult { 234 | error: string | null; 235 | } 236 | ``` 237 | -------------------------------------------------------------------------------- /pages/tutorials/google-oauth/sveltekit.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tutorial: Google OAuth in SvelteKit" 3 | --- 4 | 5 | # Tutorial: Google OAuth in SvelteKit 6 | 7 | _Before starting, make sure you've created the session and cookie API outlined in the [Sessions](/sessions/overview) page._ 8 | 9 | An [example project](https://github.com/lucia-auth/example-sveltekit-google-oauth) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/example-sveltekit-google-oauth). 10 | 11 | ``` 12 | git clone git@github.com:lucia-auth/example-sveltekit-google-oauth.git 13 | ``` 14 | 15 | ## Create an OAuth App 16 | 17 | Create an Google OAuth client on the Cloud Console. Set the redirect URI to `http://localhost:5173/login/google/callback`. Copy and paste the client ID and secret to your `.env` file. 18 | 19 | ```bash 20 | # .env 21 | GOOGLE_CLIENT_ID="" 22 | GOOGLE_CLIENT_SECRET="" 23 | ``` 24 | 25 | ## Update database 26 | 27 | Update your user model to include the user's Google ID and username. 28 | 29 | ```ts 30 | interface User { 31 | id: number; 32 | googleId: string; 33 | name: string; 34 | } 35 | ``` 36 | 37 | ## Setup Arctic 38 | 39 | ``` 40 | npm install arctic 41 | ``` 42 | 43 | Initialize the Google provider with the client ID, client secret, and redirect URI. 44 | 45 | ```ts 46 | import { Google } from "arctic"; 47 | import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from "$env/static/private"; 48 | 49 | export const google = new Google( 50 | GOOGLE_CLIENT_ID, 51 | GOOGLE_CLIENT_SECRET, 52 | "http://localhost:5173/login/google/callback" 53 | ); 54 | ``` 55 | 56 | ## Sign in page 57 | 58 | Create `routes/login/+page.svelte` and add a basic sign in button, which should be a link to `/login/google`. 59 | 60 | ```svelte 61 | 62 |

Sign in

63 | Sign in with Google 64 | ``` 65 | 66 | ## Create authorization URL 67 | 68 | Create an API route in `routes/login/google/+server.ts`. Generate a new state and code verifier, and create a new authorization URL. Add the `openid` and `profile` scope to have access to the user's profile later on. Store the state and code verifier, and redirect the user to the authorization URL. The user will be redirected to Google's sign in page. 69 | 70 | ```ts 71 | // routes/login/google/+server.ts 72 | import { generateState, generateCodeVerifier } from "arctic"; 73 | import { google } from "$lib/server/oauth"; 74 | 75 | import type { RequestEvent } from "@sveltejs/kit"; 76 | 77 | export async function GET(event: RequestEvent): Promise { 78 | const state = generateState(); 79 | const codeVerifier = generateCodeVerifier(); 80 | const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "profile"]); 81 | 82 | event.cookies.set("google_oauth_state", state, { 83 | path: "/", 84 | httpOnly: true, 85 | maxAge: 60 * 10, // 10 minutes 86 | sameSite: "lax" 87 | }); 88 | event.cookies.set("google_code_verifier", codeVerifier, { 89 | path: "/", 90 | httpOnly: true, 91 | maxAge: 60 * 10, // 10 minutes 92 | sameSite: "lax" 93 | }); 94 | 95 | return new Response(null, { 96 | status: 302, 97 | headers: { 98 | Location: url.toString() 99 | } 100 | }); 101 | } 102 | ``` 103 | 104 | ## Validate callback 105 | 106 | Create an API route in `routes/login/google/callback/+server.ts` to handle the callback. Check that the state in the URL matches the one that's stored. Then, validate the authorization code and stored code verifier. If you passed the `openid` and `profile` scope, Google will return a token ID with the user's profile. Check if the user is already registered; if not, create a new user. Finally, create a new session and set the session cookie to complete the authentication process. 107 | 108 | ```ts 109 | // routes/login/google/callback/+server.ts 110 | import { generateSessionToken, createSession, setSessionTokenCookie } from "$lib/server/session"; 111 | import { google } from "$lib/server/oauth"; 112 | import { decodeIdToken } from "arctic"; 113 | 114 | import type { RequestEvent } from "@sveltejs/kit"; 115 | import type { OAuth2Tokens } from "arctic"; 116 | 117 | export async function GET(event: RequestEvent): Promise { 118 | const code = event.url.searchParams.get("code"); 119 | const state = event.url.searchParams.get("state"); 120 | const storedState = event.cookies.get("google_oauth_state") ?? null; 121 | const codeVerifier = event.cookies.get("google_code_verifier") ?? null; 122 | if (code === null || state === null || storedState === null || codeVerifier === null) { 123 | return new Response(null, { 124 | status: 400 125 | }); 126 | } 127 | if (state !== storedState) { 128 | return new Response(null, { 129 | status: 400 130 | }); 131 | } 132 | 133 | let tokens: OAuth2Tokens; 134 | try { 135 | tokens = await google.validateAuthorizationCode(code, codeVerifier); 136 | } catch (e) { 137 | // Invalid code or client credentials 138 | return new Response(null, { 139 | status: 400 140 | }); 141 | } 142 | const claims = decodeIdToken(tokens.idToken()); 143 | const googleUserId = claims.sub; 144 | const username = claims.name; 145 | 146 | // TODO: Replace this with your own DB query. 147 | const existingUser = await getUserFromGoogleId(googleUserId); 148 | 149 | if (existingUser !== null) { 150 | const sessionToken = generateSessionToken(); 151 | const session = await createSession(sessionToken, existingUser.id); 152 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 153 | return new Response(null, { 154 | status: 302, 155 | headers: { 156 | Location: "/" 157 | } 158 | }); 159 | } 160 | 161 | // TODO: Replace this with your own DB query. 162 | const user = await createUser(googleUserId, username); 163 | 164 | const sessionToken = generateSessionToken(); 165 | const session = await createSession(sessionToken, user.id); 166 | setSessionTokenCookie(event, sessionToken, session.expiresAt); 167 | return new Response(null, { 168 | status: 302, 169 | headers: { 170 | Location: "/" 171 | } 172 | }); 173 | } 174 | ``` 175 | 176 | ## Get the current user 177 | 178 | If you implemented the middleware outlined in the [Session cookies in SvelteKit](/sessions/cookies/sveltekit) page, you can get the current session and user from `Locals`. 179 | 180 | ```ts 181 | // routes/+page.server.ts 182 | import { redirect } from "@sveltejs/kit"; 183 | 184 | import type { PageServerLoad } from "./$types"; 185 | 186 | export const load: PageServerLoad = async (event) => { 187 | if (!event.locals.user) { 188 | return redirect(302, "/login"); 189 | } 190 | 191 | return { 192 | user 193 | }; 194 | }; 195 | ``` 196 | 197 | ## Sign out 198 | 199 | Sign out users by invalidating their session. Make sure to remove the session cookie as well. 200 | 201 | ```ts 202 | // routes/+page.server.ts 203 | import { fail, redirect } from "@sveltejs/kit"; 204 | import { invalidateSession, deleteSessionTokenCookie } from "$lib/server/session"; 205 | 206 | import type { Actions, PageServerLoad } from "./$types"; 207 | 208 | export const load: PageServerLoad = async ({ locals }) => { 209 | // ... 210 | }; 211 | 212 | export const actions: Actions = { 213 | default: async (event) => { 214 | if (event.locals.session === null) { 215 | return fail(401); 216 | } 217 | await invalidateSession(event.locals.session.id); 218 | deleteSessionTokenCookie(event); 219 | return redirect(302, "/login"); 220 | } 221 | }; 222 | ``` 223 | 224 | ```svelte 225 | 226 | 229 | 230 |
231 | 232 |
233 | ``` 234 | --------------------------------------------------------------------------------