From beac8bc09563ebfb86302db75d4d6c9f4094c65e Mon Sep 17 00:00:00 2001 From: pilcrow Date: Sat, 27 Jan 2024 22:29:56 +0900 Subject: [PATCH] Lucia v3 (#1258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rob Marscher Co-authored-by: Howard Dean Watts Co-authored-by: SkepticMystic <70717676+SkepticMystic@users.noreply.github.com> Co-authored-by: Aayush <21987529+aayushbtw@users.noreply.github.com> Co-authored-by: Alejandro Perez Pujante Co-authored-by: Corliansa Kusumah Co-authored-by: Gabriel Lucena Co-authored-by: Mihovil Ilakovac Co-authored-by: Linus <32105232+LinusOP@users.noreply.github.com> Co-authored-by: Andrew Smith Co-authored-by: Matt Lehrer Co-authored-by: Bogdan Mardale <80361889+bmardale@users.noreply.github.com> Co-authored-by: Žan Fras Co-authored-by: Mr. Mendez <56850299+JustMrMendez@users.noreply.github.com> Co-authored-by: Elliot Waite --- .github/ISSUE_TEMPLATE/bug_report.yaml | 12 +- .../documentation_bug_report.yaml | 16 - .github/ISSUE_TEMPLATE/feature_request.yaml | 15 + .github/workflows/auri.yaml | 2 - .github/workflows/docs.yaml | 33 + .github/workflows/v2-docs.yaml | 33 + .prettierignore | 13 +- .prettierrc.json | 1 + README.md | 40 +- docs/.gitignore | 2 + docs/malta.config.json | 56 ++ docs/pages/basics/configuration.md | 104 ++ docs/pages/basics/sessions.md | 177 ++++ docs/pages/basics/users.md | 74 ++ docs/pages/database/drizzle.md | 107 ++ docs/pages/database/index.md | 29 + docs/pages/database/kysely.md | 118 +++ docs/pages/database/mongodb.md | 40 + docs/pages/database/mongoose.md | 62 ++ docs/pages/database/mysql.md | 66 ++ docs/pages/database/postgresql.md | 64 ++ docs/pages/database/prisma.md | 41 + docs/pages/database/sqlite.md | 111 +++ docs/pages/getting-started/astro.md | 104 ++ docs/pages/getting-started/express.md | 59 ++ docs/pages/getting-started/index.md | 63 ++ docs/pages/getting-started/nextjs-app.md | 77 ++ docs/pages/getting-started/nextjs-pages.md | 85 ++ docs/pages/getting-started/nuxt.md | 111 +++ docs/pages/getting-started/solidstart.md | 108 +++ docs/pages/getting-started/sveltekit.md | 104 ++ docs/pages/guides/email-and-password/2fa.md | 109 +++ .../pages/guides/email-and-password/basics.md | 183 ++++ .../email-verification-codes.md | 181 ++++ .../email-verification-links.md | 159 +++ docs/pages/guides/email-and-password/index.md | 15 + .../email-and-password/login-throttling.md | 7 + .../email-and-password/password-reset.md | 118 +++ docs/pages/guides/improving-sessions.md | 7 + docs/pages/guides/oauth/account-linking.md | 93 ++ docs/pages/guides/oauth/basics.md | 183 ++++ docs/pages/guides/oauth/custom-providers.md | 96 ++ docs/pages/guides/oauth/index.md | 15 + docs/pages/guides/oauth/multiple-providers.md | 68 ++ docs/pages/guides/oauth/pkce.md | 73 ++ docs/pages/guides/passkeys.md | 7 + docs/pages/guides/troubleshooting.md | 61 ++ docs/pages/guides/validate-bearer-tokens.md | 29 + .../guides/validate-session-cookies/astro.md | 86 ++ .../guides/validate-session-cookies/elysia.md | 81 ++ .../validate-session-cookies/express.md | 69 ++ .../guides/validate-session-cookies/hono.md | 74 ++ .../guides/validate-session-cookies/index.md | 69 ++ .../validate-session-cookies/nextjs-app.md | 99 ++ .../validate-session-cookies/nextjs-pages.md | 84 ++ .../guides/validate-session-cookies/nuxt.md | 64 ++ .../validate-session-cookies/solidstart.md | 70 ++ .../validate-session-cookies/sveltekit.md | 90 ++ docs/pages/index.md | 25 + docs/pages/reference/main/Adapter.md | 35 + docs/pages/reference/main/Cookie.md | 7 + docs/pages/reference/main/DatabaseSession.md | 26 + .../main/DatabaseSessionAttributes.md | 13 + docs/pages/reference/main/DatabaseUser.md | 24 + .../reference/main/DatabaseUserAttributes.md | 13 + .../pages/reference/main/LegacyScrypt/hash.md | 23 + .../reference/main/LegacyScrypt/index.md | 38 + .../reference/main/LegacyScrypt/verify.md | 24 + .../main/Lucia/createBlankSessionCookie.md | 14 + .../reference/main/Lucia/createSession.md | 20 + .../main/Lucia/createSessionCookie.md | 14 + .../main/Lucia/deleteExpiredSessions.md | 13 + .../reference/main/Lucia/getUserSessions.md | 18 + docs/pages/reference/main/Lucia/index.md | 68 ++ .../reference/main/Lucia/invalidateSession.md | 17 + .../main/Lucia/invalidateUserSessions.md | 17 + .../reference/main/Lucia/readBearerToken.md | 17 + .../reference/main/Lucia/readSessionCookie.md | 17 + .../reference/main/Lucia/validateSession.md | 21 + docs/pages/reference/main/Scrypt/hash.md | 23 + docs/pages/reference/main/Scrypt/index.md | 40 + docs/pages/reference/main/Scrypt/verify.md | 24 + docs/pages/reference/main/Session.md | 26 + docs/pages/reference/main/TimeSpan.md | 7 + docs/pages/reference/main/User.md | 20 + docs/pages/reference/main/generateId.md | 26 + docs/pages/reference/main/index.md | 27 + .../reference/main/verifyRequestOrigin.md | 7 + docs/pages/tutorials/github-oauth/astro.md | 237 +++++ docs/pages/tutorials/github-oauth/index.md | 13 + .../tutorials/github-oauth/nextjs-app.md | 293 ++++++ .../tutorials/github-oauth/nextjs-pages.md | 324 +++++++ docs/pages/tutorials/github-oauth/nuxt.md | 270 ++++++ .../pages/tutorials/github-oauth/sveltekit.md | 260 +++++ .../tutorials/username-and-password/astro.md | 263 +++++ .../tutorials/username-and-password/index.md | 13 + .../username-and-password/nextjs-app.md | 331 +++++++ .../username-and-password/nextjs-pages.md | 394 ++++++++ .../tutorials/username-and-password/nuxt.md | 326 +++++++ .../username-and-password/sveltekit.md | 290 ++++++ docs/pages/upgrade-v3/index.md | 193 ++++ docs/pages/upgrade-v3/mongoose.md | 173 ++++ docs/pages/upgrade-v3/mysql.md | 98 ++ docs/pages/upgrade-v3/oauth.md | 152 +++ docs/pages/upgrade-v3/password.md | 77 ++ docs/pages/upgrade-v3/postgresql.md | 115 +++ docs/pages/upgrade-v3/prisma/index.md | 30 + docs/pages/upgrade-v3/prisma/mysql.md | 125 +++ docs/pages/upgrade-v3/prisma/postgresql.md | 128 +++ docs/pages/upgrade-v3/prisma/sqlite.md | 127 +++ docs/pages/upgrade-v3/sqlite.md | 145 +++ documentation/.gitignore | 21 - documentation/README.md | 17 - documentation/astro.config.mjs | 22 - documentation/content/blog/lucia-1.md | 46 - documentation/content/blog/lucia-2.md | 78 -- .../content/guidebook/drizzle-orm.md | 371 ------- .../guidebook/email-verification-codes.md | 123 --- .../email-verification-links/$express.md | 365 ------- .../email-verification-links/$nextjs-app.md | 691 ------------- .../email-verification-links/$nextjs-pages.md | 751 -------------- .../email-verification-links/$nuxt.md | 643 ------------ .../email-verification-links/$sveltekit.md | 577 ----------- .../email-verification-links/index.md | 396 -------- .../guidebook/github-oauth-native/electron.md | 311 ------ .../guidebook/github-oauth-native/expo.md | 189 ---- .../guidebook/github-oauth-native/index.md | 11 - .../guidebook/github-oauth-native/tauri.md | 317 ------ .../content/guidebook/github-oauth/$astro.md | 310 ------ .../guidebook/github-oauth/$express.md | 270 ------ .../content/guidebook/github-oauth/$hono.md | 247 ----- .../guidebook/github-oauth/$nextjs-app.md | 443 --------- .../guidebook/github-oauth/$nextjs-pages.md | 389 -------- .../content/guidebook/github-oauth/$nuxt.md | 409 -------- .../guidebook/github-oauth/$solidstart.md | 390 -------- .../guidebook/github-oauth/$sveltekit.md | 346 ------- .../content/guidebook/github-oauth/index.md | 321 ------ .../guidebook/improve-session-security.md | 148 --- documentation/content/guidebook/kysely.md | 161 --- .../content/guidebook/login-throttling.md | 137 --- .../guidebook/oauth-account-linking.md | 71 -- .../guidebook/password-reset-link/$express.md | 206 ---- .../password-reset-link/$nextjs-app.md | 361 ------- .../password-reset-link/$nextjs-pages.md | 338 ------- .../guidebook/password-reset-link/$nuxt.md | 281 ------ .../password-reset-link/$sveltekit.md | 267 ----- .../guidebook/password-reset-link/index.md | 213 ---- .../$astro.md | 376 ------- .../$express.md | 279 ------ .../$hono.md | 255 ----- .../$nextjs-app.md | 553 ----------- .../$nextjs-pages.md | 540 ----------- .../$nuxt.md | 463 --------- .../$solidstart.md | 471 --------- .../$sveltekit.md | 408 -------- .../index.md | 306 ------ .../content/guidebook/vercel-postgres.md | 50 - .../content/main/basics/configuration.md | 275 ------ documentation/content/main/basics/database.md | 111 --- .../content/main/basics/error-handling.md | 36 - .../main/basics/fallback-database-queries.md | 68 -- .../content/main/basics/handle-requests.md | 302 ------ documentation/content/main/basics/keys.md | 235 ----- documentation/content/main/basics/sessions.md | 243 ----- documentation/content/main/basics/users.md | 171 ---- .../main/basics/using-bearer-tokens.md | 66 -- .../content/main/basics/using-cookies.md | 127 --- documentation/content/main/contributing.md | 113 --- .../main/database-adapters/better-sqlite3.md | 100 -- .../main/database-adapters/cloudflare-d1.md | 119 --- .../content/main/database-adapters/ioredis.md | 57 -- .../content/main/database-adapters/libsql.md | 102 -- .../main/database-adapters/mongoose.md | 143 --- .../content/main/database-adapters/mysql2.md | 102 -- .../content/main/database-adapters/pg.md | 100 -- .../planetscale-serverless.md | 100 -- .../main/database-adapters/postgres.md | 98 -- .../content/main/database-adapters/prisma.md | 108 --- .../content/main/database-adapters/redis.md | 59 -- .../main/database-adapters/unstorage.md | 57 -- .../main/database-adapters/upstash-redis.md | 51 - .../content/main/getting-started/$astro.md | 133 --- .../content/main/getting-started/$elysia.md | 98 -- .../content/main/getting-started/$express.md | 161 --- .../content/main/getting-started/$fastify.md | 142 --- .../content/main/getting-started/$hono.md | 117 --- .../main/getting-started/$nextjs-app.md | 131 --- .../main/getting-started/$nextjs-pages.md | 127 --- .../content/main/getting-started/$nuxt.md | 140 --- .../content/main/getting-started/$remix.md | 158 --- .../main/getting-started/$solidstart.md | 131 --- .../main/getting-started/$sveltekit.md | 137 --- .../content/main/getting-started/index.md | 148 --- .../content/main/migrate/v2/index.md | 406 -------- .../content/main/migrate/v2/mongoose.md | 69 -- .../content/main/migrate/v2/mysql.md | 76 -- .../content/main/migrate/v2/postgresql.md | 55 -- .../content/main/migrate/v2/prisma.md | 71 -- .../content/main/migrate/v2/redis.md | 30 - .../content/main/migrate/v2/sqlite.md | 68 -- documentation/content/main/starter-guides.md | 32 - .../content/oauth/basics/handle-users.md | 78 -- .../content/oauth/basics/oauth2-pkce.md | 190 ---- documentation/content/oauth/basics/oauth2.md | 179 ---- documentation/content/oauth/basics/oidc.md | 19 - documentation/content/oauth/index.md | 60 -- .../content/oauth/providers/apple.md | 180 ---- .../content/oauth/providers/atlassian.md | 121 --- .../content/oauth/providers/auth0.md | 130 --- .../content/oauth/providers/azure-ad.md | 119 --- .../content/oauth/providers/bitbucket.md | 122 --- documentation/content/oauth/providers/box.md | 146 --- .../content/oauth/providers/cognito.md | 140 --- .../content/oauth/providers/discord.md | 125 --- .../content/oauth/providers/dropbox.md | 142 --- .../content/oauth/providers/facebook.md | 123 --- .../content/oauth/providers/github.md | 166 ---- .../content/oauth/providers/gitlab.md | 147 --- .../content/oauth/providers/google.md | 117 --- .../content/oauth/providers/kakao.md | 164 ---- .../content/oauth/providers/keycloak.md | 153 --- .../content/oauth/providers/lichess.md | 110 --- documentation/content/oauth/providers/line.md | 116 --- .../content/oauth/providers/linkedin.md | 120 --- documentation/content/oauth/providers/osu.md | 254 ----- .../content/oauth/providers/patreon.md | 121 --- .../content/oauth/providers/reddit.md | 252 ----- .../content/oauth/providers/salesforce.md | 132 --- .../content/oauth/providers/slack.md | 134 --- .../content/oauth/providers/spotify.md | 134 --- .../content/oauth/providers/strava.md | 129 --- .../content/oauth/providers/twitch.md | 117 --- .../content/oauth/providers/twitter.md | 110 --- .../content/reference/database-adapter.md | 408 -------- documentation/content/reference/index.md | 50 - .../reference/lucia/interfaces/auth.md | 917 ------------------ .../reference/lucia/interfaces/authrequest.md | 92 -- .../reference/lucia/interfaces/index.md | 221 ----- .../content/reference/lucia/modules/main.md | 80 -- .../reference/lucia/modules/middleware.md | 449 --------- .../reference/lucia/modules/polyfill/node.md | 10 - .../content/reference/lucia/modules/utils.md | 147 --- documentation/content/reference/middleware.md | 73 -- .../reference/oauth/interfaces/index.md | 26 - .../oauth/interfaces/oauth2providerauth.md | 53 - .../interfaces/oauth2providerauthwithpkce.md | 65 -- .../oauth/interfaces/provideruserauth.md | 68 -- .../content/reference/oauth/modules/main.md | 175 ---- .../reference/oauth/modules/providers.md | 107 -- documentation/integrations/markdown/index.ts | 27 - documentation/integrations/markdown/rehype.ts | 128 --- documentation/integrations/og/index.ts | 220 ----- .../integrations/og/inter-medium.ttf | Bin 314712 -> 0 bytes .../integrations/og/inter-semibold.ttf | Bin 315756 -> 0 bytes documentation/integrations/og/logo.png | Bin 1830 -> 0 bytes documentation/integrations/search/index.ts | 16 - documentation/package.json | 33 - documentation/public/guidebook-logo.svg | 8 - documentation/public/logo.svg | 8 - documentation/public/og/index.jpg | Bin 24977 -> 0 bytes documentation/src/components/CodeBlock.astro | 21 - documentation/src/components/Header.astro | 133 --- .../src/components/MarkdownStyle.astro | 104 -- documentation/src/components/Search.astro | 115 --- documentation/src/components/SelectLink.astro | 83 -- .../src/components/menus/MainMenu.astro | 52 - documentation/src/components/menus/Menu.astro | 125 --- .../src/components/menus/OAuthMenu.astro | 52 - .../src/components/menus/ReferenceMenu.astro | 33 - documentation/src/env.d.ts | 2 - documentation/src/icons/ArticleIcon.astro | 5 - documentation/src/icons/DiscordIcon.astro | 16 - documentation/src/icons/ExpandIcon.astro | 3 - documentation/src/icons/GithubIcon.astro | 6 - documentation/src/icons/MenuIcon.astro | 5 - documentation/src/icons/MoreIcon.astro | 5 - documentation/src/icons/Next.astro | 9 - documentation/src/icons/NotesIcon.astro | 5 - documentation/src/icons/Search.astro | 9 - documentation/src/layouts/BaseLayout.astro | 99 -- documentation/src/layouts/MainLayout.astro | 27 - documentation/src/layouts/OAuthLayout.astro | 27 - .../src/layouts/ReferenceLayout.astro | 27 - documentation/src/middleware.ts | 10 - documentation/src/pages/404.astro | 15 - documentation/src/pages/[...slug].astro | 62 -- documentation/src/pages/blog/[post].astro | 40 - documentation/src/pages/content.txt.ts | 36 - .../src/pages/guidebook/[...guidebook].astro | 67 -- documentation/src/pages/guidebook/index.astro | 54 -- documentation/src/pages/index.astro | 259 ----- documentation/src/pages/oauth/[...slug].astro | 63 -- .../src/pages/reference/[...slug].astro | 71 -- documentation/src/utils/build.ts | 13 - documentation/src/utils/content.ts | 138 --- documentation/src/utils/dom.ts | 10 - documentation/src/utils/github.ts | 41 - documentation/src/utils/search.ts | 104 -- documentation/src/utils/state.ts | 35 - documentation/src/utils/url.ts | 3 - documentation/tailwind.config.cjs | 33 - documentation/tsconfig.json | 14 - documentation/vercel.json | 73 -- packages/adapter-drizzle/.env.example | 4 + .../{oauth => adapter-drizzle}/.gitignore | 1 + .../.prettierignore | 0 packages/adapter-drizzle/CHANGELOG.md | 1 + packages/adapter-drizzle/README.md | 37 + packages/adapter-drizzle/package.json | 52 + packages/adapter-drizzle/src/drivers/mysql.ts | 183 ++++ .../adapter-drizzle/src/drivers/postgresql.ts | 185 ++++ .../adapter-drizzle/src/drivers/sqlite.ts | 196 ++++ packages/adapter-drizzle/src/index.ts | 7 + packages/adapter-drizzle/tests/mysql.ts | 78 ++ packages/adapter-drizzle/tests/postgresql.ts | 64 ++ packages/adapter-drizzle/tests/sqlite.ts | 44 + .../tsconfig.json | 1 + .../.env.example | 0 .../.gitignore | 0 .../.prettierignore | 0 packages/adapter-mongodb/CHANGELOG.md | 1 + packages/adapter-mongodb/README.md | 25 + .../package.json | 25 +- packages/adapter-mongodb/src/index.ts | 125 +++ packages/adapter-mongodb/tests/mongodb.ts | 33 + packages/adapter-mongodb/tests/mongoose.ts | 72 ++ .../{oauth => adapter-mongodb}/tsconfig.json | 1 + packages/adapter-mongoose/CHANGELOG.md | 173 ---- packages/adapter-mongoose/README.md | 25 - packages/adapter-mongoose/src/docs.ts | 24 - packages/adapter-mongoose/src/index.ts | 1 - packages/adapter-mongoose/src/lucia.d.ts | 6 - packages/adapter-mongoose/src/mongoose.ts | 228 ----- packages/adapter-mongoose/test/db.ts | 76 -- packages/adapter-mongoose/test/index.ts | 63 -- packages/adapter-mongoose/tsconfig.json | 15 - packages/adapter-mysql/CHANGELOG.md | 4 + packages/adapter-mysql/README.md | 7 +- packages/adapter-mysql/package.json | 14 +- packages/adapter-mysql/src/base.ts | 147 +++ packages/adapter-mysql/src/drivers/mysql2.ts | 354 +------ .../adapter-mysql/src/drivers/planetscale.ts | 296 +----- packages/adapter-mysql/src/index.ts | 4 +- packages/adapter-mysql/src/lucia.d.ts | 6 - packages/adapter-mysql/src/utils.ts | 29 - packages/adapter-mysql/test/mysql2/db.ts | 14 - packages/adapter-mysql/test/mysql2/index.ts | 40 - packages/adapter-mysql/test/mysql2/setup.ts | 37 - packages/adapter-mysql/test/planetscale/db.ts | 13 - .../adapter-mysql/test/planetscale/index.ts | 55 -- .../adapter-mysql/test/planetscale/setup.ts | 33 - packages/adapter-mysql/test/shared.ts | 11 - packages/adapter-mysql/tests/mysql2.ts | 50 + packages/adapter-mysql/tests/planetscale.ts | 48 + packages/adapter-mysql/tsconfig.json | 1 + packages/adapter-postgresql/.env.example | 2 +- packages/adapter-postgresql/CHANGELOG.md | 4 + packages/adapter-postgresql/README.md | 20 +- packages/adapter-postgresql/package.json | 14 +- packages/adapter-postgresql/src/base.ts | 150 +++ .../src/drivers/node-postgres.ts | 31 + packages/adapter-postgresql/src/drivers/pg.ts | 289 ------ .../src/drivers/postgres.ts | 267 ----- .../src/drivers/postgresjs.ts | 31 + packages/adapter-postgresql/src/index.ts | 4 +- packages/adapter-postgresql/src/lucia.d.ts | 6 - packages/adapter-postgresql/src/utils.ts | 50 - packages/adapter-postgresql/test/pg/db.ts | 11 - packages/adapter-postgresql/test/pg/index.ts | 55 -- packages/adapter-postgresql/test/pg/setup.ts | 33 - .../adapter-postgresql/test/postgres/db.ts | 9 - .../adapter-postgresql/test/postgres/index.ts | 53 - .../adapter-postgresql/test/postgres/setup.ts | 33 - packages/adapter-postgresql/test/shared.ts | 11 - .../adapter-postgresql/tests/node-postgres.ts | 47 + .../adapter-postgresql/tests/postgresjs.ts | 40 + packages/adapter-postgresql/tsconfig.json | 1 + packages/adapter-prisma/CHANGELOG.md | 4 + packages/adapter-prisma/README.md | 8 +- packages/adapter-prisma/package.json | 10 +- .../20231105134245_init/migration.sql | 26 + .../20231105134918_init/migration.sql | 25 + .../prisma/migrations/migration_lock.toml | 3 + packages/adapter-prisma/prisma/schema.prisma | 25 +- packages/adapter-prisma/src/index.ts | 161 ++- packages/adapter-prisma/src/lucia.d.ts | 6 - packages/adapter-prisma/src/prisma.ts | 284 ------ packages/adapter-prisma/test/index.ts | 46 - packages/adapter-prisma/tests/prisma.ts | 22 + packages/adapter-prisma/tsconfig.json | 1 + packages/adapter-session-redis/.env.example | 6 - packages/adapter-session-redis/.gitignore | 5 - packages/adapter-session-redis/CHANGELOG.md | 123 --- packages/adapter-session-redis/README.md | 36 - packages/adapter-session-redis/package.json | 68 -- .../src/drivers/ioredis.ts | 86 -- .../src/drivers/redis.ts | 84 -- .../src/drivers/upstash.ts | 94 -- packages/adapter-session-redis/src/index.ts | 3 - packages/adapter-session-redis/src/lucia.d.ts | 6 - .../adapter-session-redis/test/ioredis.ts | 57 -- packages/adapter-session-redis/test/redis.ts | 65 -- .../adapter-session-redis/test/upstash.ts | 63 -- packages/adapter-session-redis/tsconfig.json | 15 - packages/adapter-session-unstorage/.gitignore | 4 - packages/adapter-session-unstorage/.npmignore | 1 - .../adapter-session-unstorage/.prettierignore | 13 - .../adapter-session-unstorage/CHANGELOG.md | 23 - packages/adapter-session-unstorage/README.md | 21 - .../adapter-session-unstorage/package.json | 49 - .../adapter-session-unstorage/src/index.ts | 1 - .../adapter-session-unstorage/src/lucia.d.ts | 10 - .../src/unstorage.ts | 88 -- .../adapter-session-unstorage/test/index.ts | 40 - packages/adapter-sqlite/CHANGELOG.md | 4 + packages/adapter-sqlite/README.md | 26 +- packages/adapter-sqlite/package.json | 17 +- packages/adapter-sqlite/src/base.ts | 146 +++ .../src/drivers/better-sqlite3.ts | 226 +---- .../adapter-sqlite/src/drivers/bun-sqlite.ts | 39 + packages/adapter-sqlite/src/drivers/d1.ts | 294 +----- packages/adapter-sqlite/src/drivers/libsql.ts | 239 +---- packages/adapter-sqlite/src/index.ts | 7 +- packages/adapter-sqlite/src/lucia.d.ts | 6 - packages/adapter-sqlite/src/utils.ts | 29 - .../test/better-sqlite3/index.ts | 36 - packages/adapter-sqlite/test/d1/index.ts | 47 - packages/adapter-sqlite/test/db.ts | 9 - packages/adapter-sqlite/test/libsql/index.ts | 43 - packages/adapter-sqlite/test/main.db | Bin 32768 -> 0 bytes .../adapter-sqlite/tests/better-sqlite3.ts | 30 + packages/adapter-sqlite/tests/bun-sqlite.ts | 32 + packages/adapter-sqlite/tests/d1.ts | 24 + packages/adapter-sqlite/tests/db.ts | 7 + packages/adapter-sqlite/tests/libsql.ts | 40 + packages/adapter-sqlite/tsconfig.json | 1 + packages/adapter-test/CHANGELOG.md | 4 + packages/adapter-test/README.md | 8 +- packages/adapter-test/package.json | 9 +- packages/adapter-test/src/database.ts | 125 --- packages/adapter-test/src/index.ts | 97 +- packages/adapter-test/src/lucia.d.ts | 10 - packages/adapter-test/src/test.ts | 47 - packages/adapter-test/src/tests/main.ts | 368 ------- packages/adapter-test/src/tests/session.ts | 108 --- packages/adapter-test/tsconfig.json | 2 + packages/lucia/.prettierignore | 1 + packages/lucia/CHANGELOG.md | 6 +- packages/lucia/README.md | 6 +- packages/lucia/package.json | 24 +- packages/lucia/src/auth/adapter.ts | 73 -- packages/lucia/src/auth/cookie.ts | 61 -- packages/lucia/src/auth/database.ts | 23 - packages/lucia/src/auth/error.ts | 27 - packages/lucia/src/auth/index.ts | 690 ------------- packages/lucia/src/auth/request.ts | 227 ----- packages/lucia/src/auth/session.test.ts | 12 - packages/lucia/src/auth/session.ts | 9 - packages/lucia/src/core.ts | 226 +++++ packages/lucia/src/crypto.test.ts | 12 + packages/lucia/src/crypto.ts | 63 ++ packages/lucia/src/database.ts | 28 + packages/lucia/src/index.ts | 62 +- packages/lucia/src/lucia.d.ts | 5 - packages/lucia/src/middleware/index.ts | 432 --------- packages/lucia/src/polyfill/node.ts | 48 - packages/lucia/src/scrypt/index.test.ts | 7 +- packages/lucia/src/scrypt/index.ts | 121 +-- packages/lucia/src/utils/adapter.ts | 25 - packages/lucia/src/utils/cookie.ts | 138 --- packages/lucia/src/utils/crypto.test.ts | 22 - packages/lucia/src/utils/crypto.ts | 88 -- packages/lucia/src/utils/date.test.ts | 9 - packages/lucia/src/utils/date.ts | 9 - packages/lucia/src/utils/debug.test.ts | 13 - packages/lucia/src/utils/debug.ts | 148 --- packages/lucia/src/utils/index.ts | 8 - packages/lucia/src/utils/log.ts | 3 - packages/lucia/src/utils/request.ts | 16 - packages/lucia/src/utils/url.test.ts | 91 -- packages/lucia/src/utils/url.ts | 33 - packages/lucia/tsconfig.json | 1 + packages/oauth/.eslintignore | 13 - packages/oauth/.prettierignore | 13 - packages/oauth/CHANGELOG.md | 509 ---------- packages/oauth/README.md | 17 - packages/oauth/package.json | 51 - packages/oauth/src/ambient.d.ts | 6 - packages/oauth/src/core/oauth2.ts | 172 ---- packages/oauth/src/core/oidc.ts | 27 - packages/oauth/src/core/provider.ts | 62 -- packages/oauth/src/core/request.ts | 10 - packages/oauth/src/index.ts | 14 - packages/oauth/src/lucia.ts | 17 - packages/oauth/src/providers/apple.ts | 158 --- packages/oauth/src/providers/atlassian.ts | 133 --- packages/oauth/src/providers/auth0.ts | 168 ---- packages/oauth/src/providers/azure-ad.ts | 143 --- packages/oauth/src/providers/bitbucket.ts | 131 --- packages/oauth/src/providers/box.ts | 150 --- packages/oauth/src/providers/cognito.ts | 143 --- packages/oauth/src/providers/discord.ts | 137 --- packages/oauth/src/providers/dropbox.ts | 157 --- packages/oauth/src/providers/facebook.ts | 137 --- packages/oauth/src/providers/github.ts | 190 ---- packages/oauth/src/providers/gitlab.ts | 160 --- packages/oauth/src/providers/google.ts | 131 --- packages/oauth/src/providers/index.ts | 201 ---- packages/oauth/src/providers/kakao.ts | 167 ---- packages/oauth/src/providers/keycloak.ts | 267 ----- packages/oauth/src/providers/lichess.ts | 119 --- packages/oauth/src/providers/line.ts | 133 --- packages/oauth/src/providers/linkedin.ts | 135 --- packages/oauth/src/providers/osu.ts | 264 ----- packages/oauth/src/providers/patreon.ts | 143 --- packages/oauth/src/providers/reddit.ts | 256 ----- packages/oauth/src/providers/salesforce.ts | 149 --- packages/oauth/src/providers/slack.ts | 137 --- packages/oauth/src/providers/spotify.ts | 150 --- packages/oauth/src/providers/strava.ts | 148 --- packages/oauth/src/providers/twitch.ts | 135 --- packages/oauth/src/providers/twitter.ts | 126 --- packages/oauth/src/utils/crypto.ts | 9 - packages/oauth/src/utils/encode.ts | 53 - packages/oauth/src/utils/jwt.test.ts | 61 -- packages/oauth/src/utils/jwt.ts | 37 - packages/oauth/src/utils/request.ts | 45 - pnpm-workspace.yaml | 1 + 528 files changed, 12075 insertions(+), 42313 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/documentation_bug_report.yaml create mode 100644 .github/workflows/docs.yaml create mode 100644 .github/workflows/v2-docs.yaml create mode 100644 docs/.gitignore create mode 100644 docs/malta.config.json create mode 100644 docs/pages/basics/configuration.md create mode 100644 docs/pages/basics/sessions.md create mode 100644 docs/pages/basics/users.md create mode 100644 docs/pages/database/drizzle.md create mode 100644 docs/pages/database/index.md create mode 100644 docs/pages/database/kysely.md create mode 100644 docs/pages/database/mongodb.md create mode 100644 docs/pages/database/mongoose.md create mode 100644 docs/pages/database/mysql.md create mode 100644 docs/pages/database/postgresql.md create mode 100644 docs/pages/database/prisma.md create mode 100644 docs/pages/database/sqlite.md create mode 100644 docs/pages/getting-started/astro.md create mode 100644 docs/pages/getting-started/express.md create mode 100644 docs/pages/getting-started/index.md create mode 100644 docs/pages/getting-started/nextjs-app.md create mode 100644 docs/pages/getting-started/nextjs-pages.md create mode 100644 docs/pages/getting-started/nuxt.md create mode 100644 docs/pages/getting-started/solidstart.md create mode 100644 docs/pages/getting-started/sveltekit.md create mode 100644 docs/pages/guides/email-and-password/2fa.md create mode 100644 docs/pages/guides/email-and-password/basics.md create mode 100644 docs/pages/guides/email-and-password/email-verification-codes.md create mode 100644 docs/pages/guides/email-and-password/email-verification-links.md create mode 100644 docs/pages/guides/email-and-password/index.md create mode 100644 docs/pages/guides/email-and-password/login-throttling.md create mode 100644 docs/pages/guides/email-and-password/password-reset.md create mode 100644 docs/pages/guides/improving-sessions.md create mode 100644 docs/pages/guides/oauth/account-linking.md create mode 100644 docs/pages/guides/oauth/basics.md create mode 100644 docs/pages/guides/oauth/custom-providers.md create mode 100644 docs/pages/guides/oauth/index.md create mode 100644 docs/pages/guides/oauth/multiple-providers.md create mode 100644 docs/pages/guides/oauth/pkce.md create mode 100644 docs/pages/guides/passkeys.md create mode 100644 docs/pages/guides/troubleshooting.md create mode 100644 docs/pages/guides/validate-bearer-tokens.md create mode 100644 docs/pages/guides/validate-session-cookies/astro.md create mode 100644 docs/pages/guides/validate-session-cookies/elysia.md create mode 100644 docs/pages/guides/validate-session-cookies/express.md create mode 100644 docs/pages/guides/validate-session-cookies/hono.md create mode 100644 docs/pages/guides/validate-session-cookies/index.md create mode 100644 docs/pages/guides/validate-session-cookies/nextjs-app.md create mode 100644 docs/pages/guides/validate-session-cookies/nextjs-pages.md create mode 100644 docs/pages/guides/validate-session-cookies/nuxt.md create mode 100644 docs/pages/guides/validate-session-cookies/solidstart.md create mode 100644 docs/pages/guides/validate-session-cookies/sveltekit.md create mode 100644 docs/pages/index.md create mode 100644 docs/pages/reference/main/Adapter.md create mode 100644 docs/pages/reference/main/Cookie.md create mode 100644 docs/pages/reference/main/DatabaseSession.md create mode 100644 docs/pages/reference/main/DatabaseSessionAttributes.md create mode 100644 docs/pages/reference/main/DatabaseUser.md create mode 100644 docs/pages/reference/main/DatabaseUserAttributes.md create mode 100644 docs/pages/reference/main/LegacyScrypt/hash.md create mode 100644 docs/pages/reference/main/LegacyScrypt/index.md create mode 100644 docs/pages/reference/main/LegacyScrypt/verify.md create mode 100644 docs/pages/reference/main/Lucia/createBlankSessionCookie.md create mode 100644 docs/pages/reference/main/Lucia/createSession.md create mode 100644 docs/pages/reference/main/Lucia/createSessionCookie.md create mode 100644 docs/pages/reference/main/Lucia/deleteExpiredSessions.md create mode 100644 docs/pages/reference/main/Lucia/getUserSessions.md create mode 100644 docs/pages/reference/main/Lucia/index.md create mode 100644 docs/pages/reference/main/Lucia/invalidateSession.md create mode 100644 docs/pages/reference/main/Lucia/invalidateUserSessions.md create mode 100644 docs/pages/reference/main/Lucia/readBearerToken.md create mode 100644 docs/pages/reference/main/Lucia/readSessionCookie.md create mode 100644 docs/pages/reference/main/Lucia/validateSession.md create mode 100644 docs/pages/reference/main/Scrypt/hash.md create mode 100644 docs/pages/reference/main/Scrypt/index.md create mode 100644 docs/pages/reference/main/Scrypt/verify.md create mode 100644 docs/pages/reference/main/Session.md create mode 100644 docs/pages/reference/main/TimeSpan.md create mode 100644 docs/pages/reference/main/User.md create mode 100644 docs/pages/reference/main/generateId.md create mode 100644 docs/pages/reference/main/index.md create mode 100644 docs/pages/reference/main/verifyRequestOrigin.md create mode 100644 docs/pages/tutorials/github-oauth/astro.md create mode 100644 docs/pages/tutorials/github-oauth/index.md create mode 100644 docs/pages/tutorials/github-oauth/nextjs-app.md create mode 100644 docs/pages/tutorials/github-oauth/nextjs-pages.md create mode 100644 docs/pages/tutorials/github-oauth/nuxt.md create mode 100644 docs/pages/tutorials/github-oauth/sveltekit.md create mode 100644 docs/pages/tutorials/username-and-password/astro.md create mode 100644 docs/pages/tutorials/username-and-password/index.md create mode 100644 docs/pages/tutorials/username-and-password/nextjs-app.md create mode 100644 docs/pages/tutorials/username-and-password/nextjs-pages.md create mode 100644 docs/pages/tutorials/username-and-password/nuxt.md create mode 100644 docs/pages/tutorials/username-and-password/sveltekit.md create mode 100644 docs/pages/upgrade-v3/index.md create mode 100644 docs/pages/upgrade-v3/mongoose.md create mode 100644 docs/pages/upgrade-v3/mysql.md create mode 100644 docs/pages/upgrade-v3/oauth.md create mode 100644 docs/pages/upgrade-v3/password.md create mode 100644 docs/pages/upgrade-v3/postgresql.md create mode 100644 docs/pages/upgrade-v3/prisma/index.md create mode 100644 docs/pages/upgrade-v3/prisma/mysql.md create mode 100644 docs/pages/upgrade-v3/prisma/postgresql.md create mode 100644 docs/pages/upgrade-v3/prisma/sqlite.md create mode 100644 docs/pages/upgrade-v3/sqlite.md delete mode 100644 documentation/.gitignore delete mode 100644 documentation/README.md delete mode 100644 documentation/astro.config.mjs delete mode 100644 documentation/content/blog/lucia-1.md delete mode 100644 documentation/content/blog/lucia-2.md delete mode 100644 documentation/content/guidebook/drizzle-orm.md delete mode 100644 documentation/content/guidebook/email-verification-codes.md delete mode 100644 documentation/content/guidebook/email-verification-links/$express.md delete mode 100644 documentation/content/guidebook/email-verification-links/$nextjs-app.md delete mode 100644 documentation/content/guidebook/email-verification-links/$nextjs-pages.md delete mode 100644 documentation/content/guidebook/email-verification-links/$nuxt.md delete mode 100644 documentation/content/guidebook/email-verification-links/$sveltekit.md delete mode 100644 documentation/content/guidebook/email-verification-links/index.md delete mode 100644 documentation/content/guidebook/github-oauth-native/electron.md delete mode 100644 documentation/content/guidebook/github-oauth-native/expo.md delete mode 100644 documentation/content/guidebook/github-oauth-native/index.md delete mode 100644 documentation/content/guidebook/github-oauth-native/tauri.md delete mode 100644 documentation/content/guidebook/github-oauth/$astro.md delete mode 100644 documentation/content/guidebook/github-oauth/$express.md delete mode 100644 documentation/content/guidebook/github-oauth/$hono.md delete mode 100644 documentation/content/guidebook/github-oauth/$nextjs-app.md delete mode 100644 documentation/content/guidebook/github-oauth/$nextjs-pages.md delete mode 100644 documentation/content/guidebook/github-oauth/$nuxt.md delete mode 100644 documentation/content/guidebook/github-oauth/$solidstart.md delete mode 100644 documentation/content/guidebook/github-oauth/$sveltekit.md delete mode 100644 documentation/content/guidebook/github-oauth/index.md delete mode 100644 documentation/content/guidebook/improve-session-security.md delete mode 100644 documentation/content/guidebook/kysely.md delete mode 100644 documentation/content/guidebook/login-throttling.md delete mode 100644 documentation/content/guidebook/oauth-account-linking.md delete mode 100644 documentation/content/guidebook/password-reset-link/$express.md delete mode 100644 documentation/content/guidebook/password-reset-link/$nextjs-app.md delete mode 100644 documentation/content/guidebook/password-reset-link/$nextjs-pages.md delete mode 100644 documentation/content/guidebook/password-reset-link/$nuxt.md delete mode 100644 documentation/content/guidebook/password-reset-link/$sveltekit.md delete mode 100644 documentation/content/guidebook/password-reset-link/index.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$astro.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$express.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$hono.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-app.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-pages.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$nuxt.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$solidstart.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/$sveltekit.md delete mode 100644 documentation/content/guidebook/sign-in-with-username-and-password/index.md delete mode 100644 documentation/content/guidebook/vercel-postgres.md delete mode 100644 documentation/content/main/basics/configuration.md delete mode 100644 documentation/content/main/basics/database.md delete mode 100644 documentation/content/main/basics/error-handling.md delete mode 100644 documentation/content/main/basics/fallback-database-queries.md delete mode 100644 documentation/content/main/basics/handle-requests.md delete mode 100644 documentation/content/main/basics/keys.md delete mode 100644 documentation/content/main/basics/sessions.md delete mode 100644 documentation/content/main/basics/users.md delete mode 100644 documentation/content/main/basics/using-bearer-tokens.md delete mode 100644 documentation/content/main/basics/using-cookies.md delete mode 100644 documentation/content/main/contributing.md delete mode 100644 documentation/content/main/database-adapters/better-sqlite3.md delete mode 100644 documentation/content/main/database-adapters/cloudflare-d1.md delete mode 100644 documentation/content/main/database-adapters/ioredis.md delete mode 100644 documentation/content/main/database-adapters/libsql.md delete mode 100644 documentation/content/main/database-adapters/mongoose.md delete mode 100644 documentation/content/main/database-adapters/mysql2.md delete mode 100644 documentation/content/main/database-adapters/pg.md delete mode 100644 documentation/content/main/database-adapters/planetscale-serverless.md delete mode 100644 documentation/content/main/database-adapters/postgres.md delete mode 100644 documentation/content/main/database-adapters/prisma.md delete mode 100644 documentation/content/main/database-adapters/redis.md delete mode 100644 documentation/content/main/database-adapters/unstorage.md delete mode 100644 documentation/content/main/database-adapters/upstash-redis.md delete mode 100644 documentation/content/main/getting-started/$astro.md delete mode 100644 documentation/content/main/getting-started/$elysia.md delete mode 100644 documentation/content/main/getting-started/$express.md delete mode 100644 documentation/content/main/getting-started/$fastify.md delete mode 100644 documentation/content/main/getting-started/$hono.md delete mode 100644 documentation/content/main/getting-started/$nextjs-app.md delete mode 100644 documentation/content/main/getting-started/$nextjs-pages.md delete mode 100644 documentation/content/main/getting-started/$nuxt.md delete mode 100644 documentation/content/main/getting-started/$remix.md delete mode 100644 documentation/content/main/getting-started/$solidstart.md delete mode 100644 documentation/content/main/getting-started/$sveltekit.md delete mode 100644 documentation/content/main/getting-started/index.md delete mode 100644 documentation/content/main/migrate/v2/index.md delete mode 100644 documentation/content/main/migrate/v2/mongoose.md delete mode 100644 documentation/content/main/migrate/v2/mysql.md delete mode 100644 documentation/content/main/migrate/v2/postgresql.md delete mode 100644 documentation/content/main/migrate/v2/prisma.md delete mode 100644 documentation/content/main/migrate/v2/redis.md delete mode 100644 documentation/content/main/migrate/v2/sqlite.md delete mode 100644 documentation/content/main/starter-guides.md delete mode 100644 documentation/content/oauth/basics/handle-users.md delete mode 100644 documentation/content/oauth/basics/oauth2-pkce.md delete mode 100644 documentation/content/oauth/basics/oauth2.md delete mode 100644 documentation/content/oauth/basics/oidc.md delete mode 100644 documentation/content/oauth/index.md delete mode 100644 documentation/content/oauth/providers/apple.md delete mode 100644 documentation/content/oauth/providers/atlassian.md delete mode 100644 documentation/content/oauth/providers/auth0.md delete mode 100644 documentation/content/oauth/providers/azure-ad.md delete mode 100644 documentation/content/oauth/providers/bitbucket.md delete mode 100644 documentation/content/oauth/providers/box.md delete mode 100644 documentation/content/oauth/providers/cognito.md delete mode 100644 documentation/content/oauth/providers/discord.md delete mode 100644 documentation/content/oauth/providers/dropbox.md delete mode 100644 documentation/content/oauth/providers/facebook.md delete mode 100644 documentation/content/oauth/providers/github.md delete mode 100644 documentation/content/oauth/providers/gitlab.md delete mode 100644 documentation/content/oauth/providers/google.md delete mode 100644 documentation/content/oauth/providers/kakao.md delete mode 100644 documentation/content/oauth/providers/keycloak.md delete mode 100644 documentation/content/oauth/providers/lichess.md delete mode 100644 documentation/content/oauth/providers/line.md delete mode 100644 documentation/content/oauth/providers/linkedin.md delete mode 100644 documentation/content/oauth/providers/osu.md delete mode 100644 documentation/content/oauth/providers/patreon.md delete mode 100644 documentation/content/oauth/providers/reddit.md delete mode 100644 documentation/content/oauth/providers/salesforce.md delete mode 100644 documentation/content/oauth/providers/slack.md delete mode 100644 documentation/content/oauth/providers/spotify.md delete mode 100644 documentation/content/oauth/providers/strava.md delete mode 100644 documentation/content/oauth/providers/twitch.md delete mode 100644 documentation/content/oauth/providers/twitter.md delete mode 100644 documentation/content/reference/database-adapter.md delete mode 100644 documentation/content/reference/index.md delete mode 100644 documentation/content/reference/lucia/interfaces/auth.md delete mode 100644 documentation/content/reference/lucia/interfaces/authrequest.md delete mode 100644 documentation/content/reference/lucia/interfaces/index.md delete mode 100644 documentation/content/reference/lucia/modules/main.md delete mode 100644 documentation/content/reference/lucia/modules/middleware.md delete mode 100644 documentation/content/reference/lucia/modules/polyfill/node.md delete mode 100644 documentation/content/reference/lucia/modules/utils.md delete mode 100644 documentation/content/reference/middleware.md delete mode 100644 documentation/content/reference/oauth/interfaces/index.md delete mode 100644 documentation/content/reference/oauth/interfaces/oauth2providerauth.md delete mode 100644 documentation/content/reference/oauth/interfaces/oauth2providerauthwithpkce.md delete mode 100644 documentation/content/reference/oauth/interfaces/provideruserauth.md delete mode 100644 documentation/content/reference/oauth/modules/main.md delete mode 100644 documentation/content/reference/oauth/modules/providers.md delete mode 100644 documentation/integrations/markdown/index.ts delete mode 100644 documentation/integrations/markdown/rehype.ts delete mode 100644 documentation/integrations/og/index.ts delete mode 100644 documentation/integrations/og/inter-medium.ttf delete mode 100644 documentation/integrations/og/inter-semibold.ttf delete mode 100644 documentation/integrations/og/logo.png delete mode 100644 documentation/integrations/search/index.ts delete mode 100644 documentation/package.json delete mode 100644 documentation/public/guidebook-logo.svg delete mode 100644 documentation/public/logo.svg delete mode 100644 documentation/public/og/index.jpg delete mode 100644 documentation/src/components/CodeBlock.astro delete mode 100644 documentation/src/components/Header.astro delete mode 100644 documentation/src/components/MarkdownStyle.astro delete mode 100644 documentation/src/components/Search.astro delete mode 100644 documentation/src/components/SelectLink.astro delete mode 100644 documentation/src/components/menus/MainMenu.astro delete mode 100644 documentation/src/components/menus/Menu.astro delete mode 100644 documentation/src/components/menus/OAuthMenu.astro delete mode 100644 documentation/src/components/menus/ReferenceMenu.astro delete mode 100644 documentation/src/env.d.ts delete mode 100644 documentation/src/icons/ArticleIcon.astro delete mode 100644 documentation/src/icons/DiscordIcon.astro delete mode 100644 documentation/src/icons/ExpandIcon.astro delete mode 100644 documentation/src/icons/GithubIcon.astro delete mode 100644 documentation/src/icons/MenuIcon.astro delete mode 100644 documentation/src/icons/MoreIcon.astro delete mode 100644 documentation/src/icons/Next.astro delete mode 100644 documentation/src/icons/NotesIcon.astro delete mode 100644 documentation/src/icons/Search.astro delete mode 100644 documentation/src/layouts/BaseLayout.astro delete mode 100644 documentation/src/layouts/MainLayout.astro delete mode 100644 documentation/src/layouts/OAuthLayout.astro delete mode 100644 documentation/src/layouts/ReferenceLayout.astro delete mode 100644 documentation/src/middleware.ts delete mode 100644 documentation/src/pages/404.astro delete mode 100644 documentation/src/pages/[...slug].astro delete mode 100644 documentation/src/pages/blog/[post].astro delete mode 100644 documentation/src/pages/content.txt.ts delete mode 100644 documentation/src/pages/guidebook/[...guidebook].astro delete mode 100644 documentation/src/pages/guidebook/index.astro delete mode 100644 documentation/src/pages/index.astro delete mode 100644 documentation/src/pages/oauth/[...slug].astro delete mode 100644 documentation/src/pages/reference/[...slug].astro delete mode 100644 documentation/src/utils/build.ts delete mode 100644 documentation/src/utils/content.ts delete mode 100644 documentation/src/utils/dom.ts delete mode 100644 documentation/src/utils/github.ts delete mode 100644 documentation/src/utils/search.ts delete mode 100644 documentation/src/utils/state.ts delete mode 100644 documentation/src/utils/url.ts delete mode 100644 documentation/tailwind.config.cjs delete mode 100644 documentation/tsconfig.json delete mode 100644 documentation/vercel.json create mode 100644 packages/adapter-drizzle/.env.example rename packages/{oauth => adapter-drizzle}/.gitignore (89%) rename packages/{adapter-mongoose => adapter-drizzle}/.prettierignore (100%) create mode 100644 packages/adapter-drizzle/CHANGELOG.md create mode 100644 packages/adapter-drizzle/README.md create mode 100644 packages/adapter-drizzle/package.json create mode 100644 packages/adapter-drizzle/src/drivers/mysql.ts create mode 100644 packages/adapter-drizzle/src/drivers/postgresql.ts create mode 100644 packages/adapter-drizzle/src/drivers/sqlite.ts create mode 100644 packages/adapter-drizzle/src/index.ts create mode 100644 packages/adapter-drizzle/tests/mysql.ts create mode 100644 packages/adapter-drizzle/tests/postgresql.ts create mode 100644 packages/adapter-drizzle/tests/sqlite.ts rename packages/{adapter-session-unstorage => adapter-drizzle}/tsconfig.json (92%) rename packages/{adapter-mongoose => adapter-mongodb}/.env.example (100%) rename packages/{adapter-mongoose => adapter-mongodb}/.gitignore (100%) rename packages/{adapter-session-redis => adapter-mongodb}/.prettierignore (100%) create mode 100644 packages/adapter-mongodb/CHANGELOG.md create mode 100644 packages/adapter-mongodb/README.md rename packages/{adapter-mongoose => adapter-mongodb}/package.json (60%) create mode 100644 packages/adapter-mongodb/src/index.ts create mode 100644 packages/adapter-mongodb/tests/mongodb.ts create mode 100644 packages/adapter-mongodb/tests/mongoose.ts rename packages/{oauth => adapter-mongodb}/tsconfig.json (92%) delete mode 100644 packages/adapter-mongoose/CHANGELOG.md delete mode 100644 packages/adapter-mongoose/README.md delete mode 100644 packages/adapter-mongoose/src/docs.ts delete mode 100644 packages/adapter-mongoose/src/index.ts delete mode 100644 packages/adapter-mongoose/src/lucia.d.ts delete mode 100644 packages/adapter-mongoose/src/mongoose.ts delete mode 100644 packages/adapter-mongoose/test/db.ts delete mode 100644 packages/adapter-mongoose/test/index.ts delete mode 100644 packages/adapter-mongoose/tsconfig.json create mode 100644 packages/adapter-mysql/src/base.ts delete mode 100644 packages/adapter-mysql/src/lucia.d.ts delete mode 100644 packages/adapter-mysql/src/utils.ts delete mode 100644 packages/adapter-mysql/test/mysql2/db.ts delete mode 100644 packages/adapter-mysql/test/mysql2/index.ts delete mode 100644 packages/adapter-mysql/test/mysql2/setup.ts delete mode 100644 packages/adapter-mysql/test/planetscale/db.ts delete mode 100644 packages/adapter-mysql/test/planetscale/index.ts delete mode 100644 packages/adapter-mysql/test/planetscale/setup.ts delete mode 100644 packages/adapter-mysql/test/shared.ts create mode 100644 packages/adapter-mysql/tests/mysql2.ts create mode 100644 packages/adapter-mysql/tests/planetscale.ts create mode 100644 packages/adapter-postgresql/src/base.ts create mode 100644 packages/adapter-postgresql/src/drivers/node-postgres.ts delete mode 100644 packages/adapter-postgresql/src/drivers/pg.ts delete mode 100644 packages/adapter-postgresql/src/drivers/postgres.ts create mode 100644 packages/adapter-postgresql/src/drivers/postgresjs.ts delete mode 100644 packages/adapter-postgresql/src/lucia.d.ts delete mode 100644 packages/adapter-postgresql/src/utils.ts delete mode 100644 packages/adapter-postgresql/test/pg/db.ts delete mode 100644 packages/adapter-postgresql/test/pg/index.ts delete mode 100644 packages/adapter-postgresql/test/pg/setup.ts delete mode 100644 packages/adapter-postgresql/test/postgres/db.ts delete mode 100644 packages/adapter-postgresql/test/postgres/index.ts delete mode 100644 packages/adapter-postgresql/test/postgres/setup.ts delete mode 100644 packages/adapter-postgresql/test/shared.ts create mode 100644 packages/adapter-postgresql/tests/node-postgres.ts create mode 100644 packages/adapter-postgresql/tests/postgresjs.ts create mode 100644 packages/adapter-prisma/prisma/migrations/20231105134245_init/migration.sql create mode 100644 packages/adapter-prisma/prisma/migrations/20231105134918_init/migration.sql create mode 100644 packages/adapter-prisma/prisma/migrations/migration_lock.toml delete mode 100644 packages/adapter-prisma/src/lucia.d.ts delete mode 100644 packages/adapter-prisma/src/prisma.ts delete mode 100644 packages/adapter-prisma/test/index.ts create mode 100644 packages/adapter-prisma/tests/prisma.ts delete mode 100644 packages/adapter-session-redis/.env.example delete mode 100644 packages/adapter-session-redis/.gitignore delete mode 100644 packages/adapter-session-redis/CHANGELOG.md delete mode 100644 packages/adapter-session-redis/README.md delete mode 100644 packages/adapter-session-redis/package.json delete mode 100644 packages/adapter-session-redis/src/drivers/ioredis.ts delete mode 100644 packages/adapter-session-redis/src/drivers/redis.ts delete mode 100644 packages/adapter-session-redis/src/drivers/upstash.ts delete mode 100644 packages/adapter-session-redis/src/index.ts delete mode 100644 packages/adapter-session-redis/src/lucia.d.ts delete mode 100644 packages/adapter-session-redis/test/ioredis.ts delete mode 100644 packages/adapter-session-redis/test/redis.ts delete mode 100644 packages/adapter-session-redis/test/upstash.ts delete mode 100644 packages/adapter-session-redis/tsconfig.json delete mode 100644 packages/adapter-session-unstorage/.gitignore delete mode 100644 packages/adapter-session-unstorage/.npmignore delete mode 100644 packages/adapter-session-unstorage/.prettierignore delete mode 100644 packages/adapter-session-unstorage/CHANGELOG.md delete mode 100644 packages/adapter-session-unstorage/README.md delete mode 100644 packages/adapter-session-unstorage/package.json delete mode 100644 packages/adapter-session-unstorage/src/index.ts delete mode 100644 packages/adapter-session-unstorage/src/lucia.d.ts delete mode 100644 packages/adapter-session-unstorage/src/unstorage.ts delete mode 100644 packages/adapter-session-unstorage/test/index.ts create mode 100644 packages/adapter-sqlite/src/base.ts create mode 100644 packages/adapter-sqlite/src/drivers/bun-sqlite.ts delete mode 100644 packages/adapter-sqlite/src/lucia.d.ts delete mode 100644 packages/adapter-sqlite/src/utils.ts delete mode 100644 packages/adapter-sqlite/test/better-sqlite3/index.ts delete mode 100644 packages/adapter-sqlite/test/d1/index.ts delete mode 100644 packages/adapter-sqlite/test/db.ts delete mode 100644 packages/adapter-sqlite/test/libsql/index.ts delete mode 100644 packages/adapter-sqlite/test/main.db create mode 100644 packages/adapter-sqlite/tests/better-sqlite3.ts create mode 100644 packages/adapter-sqlite/tests/bun-sqlite.ts create mode 100644 packages/adapter-sqlite/tests/d1.ts create mode 100644 packages/adapter-sqlite/tests/db.ts create mode 100644 packages/adapter-sqlite/tests/libsql.ts delete mode 100644 packages/adapter-test/src/database.ts delete mode 100644 packages/adapter-test/src/lucia.d.ts delete mode 100644 packages/adapter-test/src/test.ts delete mode 100644 packages/adapter-test/src/tests/main.ts delete mode 100644 packages/adapter-test/src/tests/session.ts delete mode 100644 packages/lucia/src/auth/adapter.ts delete mode 100644 packages/lucia/src/auth/cookie.ts delete mode 100644 packages/lucia/src/auth/database.ts delete mode 100644 packages/lucia/src/auth/error.ts delete mode 100644 packages/lucia/src/auth/index.ts delete mode 100644 packages/lucia/src/auth/request.ts delete mode 100644 packages/lucia/src/auth/session.test.ts delete mode 100644 packages/lucia/src/auth/session.ts create mode 100644 packages/lucia/src/core.ts create mode 100644 packages/lucia/src/crypto.test.ts create mode 100644 packages/lucia/src/crypto.ts create mode 100644 packages/lucia/src/database.ts delete mode 100644 packages/lucia/src/lucia.d.ts delete mode 100644 packages/lucia/src/middleware/index.ts delete mode 100644 packages/lucia/src/polyfill/node.ts delete mode 100644 packages/lucia/src/utils/adapter.ts delete mode 100644 packages/lucia/src/utils/cookie.ts delete mode 100644 packages/lucia/src/utils/crypto.test.ts delete mode 100644 packages/lucia/src/utils/crypto.ts delete mode 100644 packages/lucia/src/utils/date.test.ts delete mode 100644 packages/lucia/src/utils/date.ts delete mode 100644 packages/lucia/src/utils/debug.test.ts delete mode 100644 packages/lucia/src/utils/debug.ts delete mode 100644 packages/lucia/src/utils/index.ts delete mode 100644 packages/lucia/src/utils/log.ts delete mode 100644 packages/lucia/src/utils/request.ts delete mode 100644 packages/lucia/src/utils/url.test.ts delete mode 100644 packages/lucia/src/utils/url.ts delete mode 100644 packages/oauth/.eslintignore delete mode 100644 packages/oauth/.prettierignore delete mode 100644 packages/oauth/CHANGELOG.md delete mode 100644 packages/oauth/README.md delete mode 100644 packages/oauth/package.json delete mode 100644 packages/oauth/src/ambient.d.ts delete mode 100644 packages/oauth/src/core/oauth2.ts delete mode 100644 packages/oauth/src/core/oidc.ts delete mode 100644 packages/oauth/src/core/provider.ts delete mode 100644 packages/oauth/src/core/request.ts delete mode 100644 packages/oauth/src/index.ts delete mode 100644 packages/oauth/src/lucia.ts delete mode 100644 packages/oauth/src/providers/apple.ts delete mode 100644 packages/oauth/src/providers/atlassian.ts delete mode 100644 packages/oauth/src/providers/auth0.ts delete mode 100644 packages/oauth/src/providers/azure-ad.ts delete mode 100644 packages/oauth/src/providers/bitbucket.ts delete mode 100644 packages/oauth/src/providers/box.ts delete mode 100644 packages/oauth/src/providers/cognito.ts delete mode 100644 packages/oauth/src/providers/discord.ts delete mode 100644 packages/oauth/src/providers/dropbox.ts delete mode 100644 packages/oauth/src/providers/facebook.ts delete mode 100644 packages/oauth/src/providers/github.ts delete mode 100644 packages/oauth/src/providers/gitlab.ts delete mode 100644 packages/oauth/src/providers/google.ts delete mode 100644 packages/oauth/src/providers/index.ts delete mode 100644 packages/oauth/src/providers/kakao.ts delete mode 100644 packages/oauth/src/providers/keycloak.ts delete mode 100644 packages/oauth/src/providers/lichess.ts delete mode 100644 packages/oauth/src/providers/line.ts delete mode 100644 packages/oauth/src/providers/linkedin.ts delete mode 100644 packages/oauth/src/providers/osu.ts delete mode 100644 packages/oauth/src/providers/patreon.ts delete mode 100644 packages/oauth/src/providers/reddit.ts delete mode 100644 packages/oauth/src/providers/salesforce.ts delete mode 100644 packages/oauth/src/providers/slack.ts delete mode 100644 packages/oauth/src/providers/spotify.ts delete mode 100644 packages/oauth/src/providers/strava.ts delete mode 100644 packages/oauth/src/providers/twitch.ts delete mode 100644 packages/oauth/src/providers/twitter.ts delete mode 100644 packages/oauth/src/utils/crypto.ts delete mode 100644 packages/oauth/src/utils/encode.ts delete mode 100644 packages/oauth/src/utils/jwt.test.ts delete mode 100644 packages/oauth/src/utils/jwt.ts delete mode 100644 packages/oauth/src/utils/request.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b8272362c..3eb7175ee 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -13,22 +13,20 @@ body: label: Package description: What package is affected? options: - - lucia-auth - - ​@lucia-auth/oauth - - ​@lucia-auth/tokens - - ​@lucia-auth/adapter-mongoose + - lucia + - ​@lucia-auth/adapter-test + - ​@lucia-auth/adapter-mongodb - ​@lucia-auth/adapter-mysql - ​@lucia-auth/adapter-postgresql - ​@lucia-auth/adapter-prisma - ​@lucia-auth/adapter-sqlite - - ​@lucia-auth/session-adapter-redis - - ​@lucia-auth/adapter-test + - ​@lucia-auth/session-drizzle validations: required: true - type: textarea id: description attributes: label: Describe the bug - description: Also tell us, what was the expected behavior? Reproduction is helpful for weeeird bugs! + description: Also tell us, what was the expected behavior? Reproduction will be super helpful! validations: required: true diff --git a/.github/ISSUE_TEMPLATE/documentation_bug_report.yaml b/.github/ISSUE_TEMPLATE/documentation_bug_report.yaml deleted file mode 100644 index 2df8de07b..000000000 --- a/.github/ISSUE_TEMPLATE/documentation_bug_report.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: Documentation Bug Report -description: Report a bug regarding the docs website -title: "[Docs-Bug]: " -labels: ["bug", "documentation"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this bug report! - - type: textarea - id: description - attributes: - label: Describe the bug - description: A screenshot or a video are helpful as well! - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index e42c64822..b75ea9d8a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -7,6 +7,21 @@ body: attributes: value: | Thanks for taking the time to fill out this feature request! + - type: dropdown + id: package + attributes: + label: Package + options: + - lucia + - ​@lucia-auth/adapter-test + - ​@lucia-auth/adapter-mongodb + - ​@lucia-auth/adapter-mysql + - ​@lucia-auth/adapter-postgresql + - ​@lucia-auth/adapter-prisma + - ​@lucia-auth/adapter-sqlite + - ​@lucia-auth/session-drizzle + validations: + required: true - type: textarea id: description attributes: diff --git a/.github/workflows/auri.yaml b/.github/workflows/auri.yaml index ea7c408d8..ad4787473 100644 --- a/.github/workflows/auri.yaml +++ b/.github/workflows/auri.yaml @@ -7,8 +7,6 @@ on: env: AURI_GITHUB_TOKEN: ${{secrets.AURI_GITHUB_TOKEN}} NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} - CF_DEPLOY_HOOK: ${{secrets.CF_DEPLOY_HOOK}} - VERCEL_PREVIEW_DEPLOY_HOOK: ${{secrets.VERCEL_PREVIEW_DEPLOY_HOOK}} jobs: auri: diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 000000000..141085c63 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,33 @@ +name: "Publish v3 docs" +on: + push: + branches: + - v3 + +env: + CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_PAGES_API_TOKEN}} + +jobs: + publish-docs: + name: Publish docs + runs-on: ubuntu-latest + steps: + - name: setup actions + uses: actions/checkout@v3 + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: 20.5.1 + registry-url: https://registry.npmjs.org + - name: install malta + working-directory: docs + run: | + curl -o malta.tgz -L https://github.com/pilcrowonpaper/malta/releases/latest/download/linux-amd64.tgz + tar -xvzf malta.tgz + - name: build + working-directory: docs + run: ./linux-amd64/malta build + - name: install wrangler + run: npm i -g wrangler + - name: deploy + run: wrangler pages deploy docs/dist --project-name lucia-v3 --branch main diff --git a/.github/workflows/v2-docs.yaml b/.github/workflows/v2-docs.yaml new file mode 100644 index 000000000..86921c670 --- /dev/null +++ b/.github/workflows/v2-docs.yaml @@ -0,0 +1,33 @@ +name: "Publish v2 docs" +on: + push: + branches: + - v2 + +env: + CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_PAGES_API_TOKEN}} + +jobs: + publish-docs: + name: Publish docs + runs-on: ubuntu-latest + steps: + - name: setup actions + uses: actions/checkout@v3 + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: 20.5.1 + registry-url: https://registry.npmjs.org + - name: Install PNPM + run: npm i -g pnpm + - name: Install dependencies + run: pnpm i + - name: Build + working-directory: documentation + run: pnpm build + - name: Install wrangler + run: npm i -g wrangler + - name: deploy + working-directory: documentation + run: wrangler pages deploy dist --project-name lucia-v2 --branch v2 diff --git a/.prettierignore b/.prettierignore index 54d9cdc41..a762d65d6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,18 +8,11 @@ node_modules !.env.example /.vscode /.auri/*.md -/test-apps/**/.svelte-kit -/test-apps/**/.next -/test-apps/**/.nuxt -/examples/**/.svelte-kit -/examples/**/.next -/examples/**/.nuxt -/examples/**/.solid -/packages/**/dist -/documentation/dist + +docs/dist +packages/*/dist # Ignore files for PNPM, NPM and YARN pnpm-lock.yaml package-lock.json yarn.lock - diff --git a/.prettierrc.json b/.prettierrc.json index dd9ec0cff..0b6089ccd 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,6 @@ { "useTabs": true, + "printWidth": 100, "trailingComma": "none", "plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-astro"], "overrides": [ diff --git a/README.md b/README.md index a76dd51ec..7387e272d 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,23 @@ # Lucia -Lucia is a simple and flexible user and session management library that provides an -abstraction layer between your app and your database. It's bare-bones by design, keeping -everything easy to use and understand. +Lucia is an auth library written in TypeScript that abstracts away the complexity of handling sessions. It works alongside your database to provide an API that's easy to use, understand, and extend. -### Code sample - -Working with Lucia looks something like this. In the code below, you're creating a new user with an email/password method, creating a new session, and creating a cookie that you can set to the user. +- No more endless configuration and callbacks +- Fully typed +- Works in any runtime - Node.js, Bun, Deno, Cloudflare Workers +- Extensive database support out of the box ```ts -const user = await auth.createUser({ - key: { - providerId: "email", - providerUserId: email, - password - }, - attributes: { - email - } -}); -const session = await auth.createSession({ - userId: user.userId, - attributes: {} -}); -const sessionCookie = auth.createSessionCookie(session); +import { Lucia } from "lucia"; + +const lucia = new Lucia(new Adapter(db)); + +const session = await lucia.createSession(userId, {}); +await lucia.validateSession(session.id); ``` +Lucia is an open source library released under the MIT license, with the help of [100+ contributors](https://github.com/lucia-auth/lucia/graphs/contributors)! + ## Resources **[Documentation](https://lucia-auth.com)** @@ -45,9 +37,3 @@ npm i lucia pnpm add lucia yarn add lucia ``` - -## Attributions - -This project would not have been possible without our contributors, thank you! - -Logo by [@dawidmachon](https://github.com/dawidmachon), licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/). diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..4582bd3e7 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +dist +.DS_Store \ No newline at end of file diff --git a/docs/malta.config.json b/docs/malta.config.json new file mode 100644 index 000000000..c111bd929 --- /dev/null +++ b/docs/malta.config.json @@ -0,0 +1,56 @@ +{ + "name": "Lucia", + "description": "Lucia is an open source auth library that abstracts away the complexity of handling sessions.", + "domain": "https://v3.lucia-auth.com", + "twitter": "@lucia_auth", + "sidebar": [ + { + "title": "Start here", + "pages": [ + ["Getting-started", "/getting-started"], + ["Database", "/database"], + ["Upgrade to v3", "/upgrade-v3"], + ["v2 documentation", "https://v2.lucia-auth.com"] + ] + }, + { + "title": "Tutorials", + "pages": [ + ["GitHub OAuth", "/tutorials/github-oauth"], + ["Username and password", "/tutorials/username-and-password"] + ] + }, + { + "title": "Basics", + "pages": [ + ["Sessions", "/basics/sessions"], + ["Users", "/basics/users"], + ["Configuration", "/basics/configuration"] + ] + }, + { + "title": "Guides", + "pages": [ + ["Validate session cookies", "/guides/validate-session-cookies"], + ["Validate bearer tokens", "/guides/validate-bearer-tokens"], + ["OAuth", "/guides/oauth"], + ["Email and password", "/guides/email-and-password"], + ["Troubleshooting", "/guides/troubleshooting"], + ["Passkeys", "/guides/passkeys"], + ["Improving sessions", "/guides/improving-sessions"] + ] + }, + { + "title": "API reference", + "pages": [["lucia", "/reference/main", "code"]] + }, + { + "title": "Community", + "pages": [ + ["Discord", "https://discord.com/invite/PwrK3kpVR3"], + ["GitHub", "https://github.com/lucia-auth/lucia"], + ["Twitter", "https://twitter.com/lucia_auth"] + ] + } + ] +} diff --git a/docs/pages/basics/configuration.md b/docs/pages/basics/configuration.md new file mode 100644 index 000000000..fcd2f36e7 --- /dev/null +++ b/docs/pages/basics/configuration.md @@ -0,0 +1,104 @@ +--- +title: "Configuration" +--- + +# Configuration + +This page shows all the options for [`Lucia`](/reference/main/Lucia) to configure Lucia. + +```ts +interface Options { + sessionExpiresIn?: TimeSpan; + sessionCookie?: SessionCookieOptions; + getSessionAttributes?: ( + databaseSessionAttributes: DatabaseSessionAttributes + ) => _SessionAttributes; + getUserAttributes?: (databaseUserAttributes: DatabaseUserAttributes) => _UserAttributes; +} +``` + +## `sessionExpiresIn` + +Configures how long a session stays valid for inactive users. Session expirations are automatically extended for active users. Also see [`TimeSpan`](/reference/main/TimeSpan). + +```ts +import { Lucia, TimeSpan } from "lucia"; + +const lucia = new Lucia(adapter, { + sessionExpiresIn: new TimeSpan(2, "w") +}); +``` + +## `sessionCookie` + +Configures the session cookie. + +```ts +import { Lucia } from "lucia"; + +const lucia = new Lucia(adapter, { + sessionCookie: { + name: "session", + expires: false, // session cookies have very long lifespan (2 years) + attributes: { + secure: true, + sameSite: "strict", + domain: "example.com" + } + } +}); +``` + +## `getSessionAttributes()` + +Transforms database session attributes, which is typed as `DatabaseSessionAttributes`. The returned object is added to the `Session` object. + +```ts +import { Lucia } from "lucia"; + +const lucia = new Lucia(adapter, { + getSessionAttributes: (attributes) => { + return { + country: attributes.country + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseSessionAttributes: DatabaseSessionAttributes; + } +} + +interface DatabaseSessionAttributes { + country: string; +} +``` + +## `getUserAttributes()` + +Transforms database user attributes, which is typed as `DatabaseUserAttributes`. The returned object is added to the `User` object. + +```ts +import { Lucia } from "lucia"; + +const lucia = new Lucia(adapter, { + getUserAttributes: (attributes) => { + return { + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} +``` diff --git a/docs/pages/basics/sessions.md b/docs/pages/basics/sessions.md new file mode 100644 index 000000000..3631282a3 --- /dev/null +++ b/docs/pages/basics/sessions.md @@ -0,0 +1,177 @@ +--- +title: "Sessions" +--- + +# Sessions + +Sessions allow Lucia to keep track of requests made by authenticated users. The ID can be stored in a cookie or used as a traditional token manually added to each request. They should be created and stored on registration and login, validated on every request, and deleted on sign out. + +```ts +interface Session extends SessionAttributes { + id: string; + userId: string; + expiresAt: Date; + fresh: boolean; +} +``` + +## Session lifetime + +Sessions do not have an absolute expiration. The expiration gets extended whenever they're used. This ensures that active users remain signed in, while inactive users are signed out. + +More specifically, if the session expiration is set to 30 days (default), Lucia will extend the expiration by another 30 days when there are less than 15 days (half of the expiration) until expiration. You can configure the expiration with the `sessionExpiresIn` configuration. + +```ts +import { Lucia, TimeSpan } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionExpiresIn: new TimeSpan(2, "w") // 2 weeks +}); +``` + +## Define session attributes + +Defining custom session attributes requires 2 steps. First, add the required columns to the session table. You can type it by declaring the `Register.DatabaseSessionAttributes` type. + +```ts +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseSessionAttributes: DatabaseSessionAttributes; + } + interface DatabaseSessionAttributes { + ip_country: string; + } +} +``` + +You can then include them in the session object with the `getSessionAttributes()` configuration. + +```ts +const lucia = new Lucia(adapter, { + getSessionAttributes: (attributes) => { + return { + ipCountry: attributes.ip_country + }; + } +}); + +const session = await lucia.createSession(); +session.ipCountry; +``` + +We do not automatically expose all database columns as + +1. Each project has its own code styling rules +2. You generally don't want to expose sensitive data (even worse if you send the entire session object to the client) + +## Create sessions + +You can create a new session with `Lucia.createSession()`, which takes a user ID and an empty object. + +```ts +const session = await lucia.createSession(userId, {}); +``` + +If you have database attributes defined, pass their values as the second argument. + +```ts +const session = await lucia.createSession(userId, { + country: "us" +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseSessionAttributes: DatabaseSessionAttributes; + } +} + +interface DatabaseSessionAttributes { + country: string; +} +``` + +## Validate sessions + +Use `Lucia.validateSession()` to validate a session using its ID. This will return an object containing a session and user. Both of these will be `null` if the session is invalid. + +```ts +const { session, user } = await lucia.validateSession(sessionId); +``` + +If `Session.fresh` is `true`, it indicates the session expiration has been extended and you should set a new session cookie. If you cannot always set a new session cookie due to limitations of your framework, set the [`sessionCookie.expires`](/basics/configuration#sessioncookie) option to `false`. + +```ts +const { session } = await lucia.validateSession(sessionId); +if (session && session.fresh) { + // set session cookie +} +``` + +You can use [`Lucia.readSessionCookie()`](/reference/main/Lucia/readSessionCookie) and [`Lucia.readBearerToken()`](/reference/main/Lucia/readBearerToken) to get the session ID from the `Cookie` and `Authorization` header respectively. + +```ts +const sessionId = lucia.readSessionCookie("auth_session=abc"); +const sessionId = lucia.readBearerToken("Bearer abc"); +``` + +See the [Validate session cookies](/guides/validate-session-cookies) and [Validate bearer tokens](/guides/validate-bearer-tokens) guide for a full example of validating session cookies. + +## Session cookies + +### Create session cookies + +You can create a session cookie for a session with [`Lucia.createSessionCookie()`](/reference/main/Lucia/createSessionCookie). It takes a session and returns a new [`Cookie`](/reference/main/Cookie) instance. You can either use [`Cookie.serialize()`](https://oslo.js.org/reference/cookie/Cookie/serialize) to create `Set-Cookie` HTTP header value, or use your framework's API by accessing the name, value, and session. + +```ts +const sessionCookie = lucia.createSessionCookie(session.id); + +// set cookie directly +headers.set("Set-Cookie", sessionCookie.serialize()); +// use your framework's cookie utility +setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); +``` + +### Delete session cookie + +You can delete a session cookie by setting a blank cookie created using [`Lucia.createBlankSessionCookie()`](/reference/main/Lucia/createBlankSessionCookie). + +```ts +const sessionCookie = lucia.createBlankSessionCookie(); + +headers.set("Set-Cookie", sessionCookie.serialize()); +setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); +``` + +## Invalidate sessions + +Use `Lucia.invalidateSession()` to invalidate a session. This should be used to sign out users. This will succeed even if the session ID is invalid. + +```ts +await lucia.invalidateSession(sessionId); +``` + +### Invalidate all user sessions + +Use `Lucia.invalidateUserSessions()` to invalidate all sessions belonging to a user. + +```ts +await lucia.invalidateUserSessions(userId); +``` + +## Get all user sessions + +Use `Lucia.getUserSessions()` to get all sessions belonging to a user. This will return an empty array if the user does not exist. Invalid sessions will be omitted from the array. + +```ts +const sessions = await lucia.getUserSessions(userId); +``` + +## Delete all expired sessions + +Use `Lucia.deleteExpiredSessions()` to delete all expired sessions in the database. We recommend setting up a cron-job to clean up your database on a set interval. + +```ts +await lucia.deleteExpiredSessions(); +``` diff --git a/docs/pages/basics/users.md b/docs/pages/basics/users.md new file mode 100644 index 000000000..b5b26f5aa --- /dev/null +++ b/docs/pages/basics/users.md @@ -0,0 +1,74 @@ +--- +title: "Users" +--- + +# Users + +While Lucia does not provide APIs for creating and managing users, it still interacts with the user table. + +```ts +interface Session extends UserAttributes { + id: string; +} +``` + +## Create users + +When creating users, you can use `generateId()` to generate user IDs, which takes the length of the output string. This will generate a cryptographically secure random string consisting of lowercase letters and numbers. + +```ts +import { generateId } from "lucia"; + +await db.createUser({ + id: generateId(15) +}); +``` + +Use Oslo's [`generateRandomString()`](https://oslo.js.org/reference/crypto/generateRandomString) if you're looking for a more customizable option. + +```ts +import { generateRandomString, alphabet } from "oslo/crypto"; + +await db.createUser({ + id: generateRandomString(15, alphabet("a-z", "A-Z", "0-9")) +}); +``` + +## Define user attributes + +Defining custom session attributes requires 2 steps. First, add the required columns to the user table. You can type it by declaring the `Register.DatabaseUserAttributes` type (must be an interface). + +```ts +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} +``` + +You can then include them in the user object with the `getUserAttributes()` configuration. + +```ts +const lucia = new Lucia(adapter, { + getUserAttributes: (attributes) => { + return { + username + }; + } +}); + +const { user } = await lucia.validateSession(); +if (user) { + const username = user.username; +} +``` + +We do not automatically expose all database columns as + +1. Each project has its own code styling rules +2. You generally don't want to expose sensitive data such as hashed passwords (even worse if you send the entire user object to the client) diff --git a/docs/pages/database/drizzle.md b/docs/pages/database/drizzle.md new file mode 100644 index 000000000..bf8741dd5 --- /dev/null +++ b/docs/pages/database/drizzle.md @@ -0,0 +1,107 @@ +--- +title: "Drizzle ORM" +--- + +# Drizzle ORM + +Adapters for Drizzle ORM are provided by `@lucia-auth/adapter-drizzle`. Supports MySQL, PostgreSQL, and SQLite. You're free to rename the underlying table and column names as long as the field names are the same (e.g. `expiresAt`). + +``` +npm install @lucia-auth/adapter-drizzle@beta +``` + +## MySQL + +`DrizzleMySQLAdapter` takes a `Database` instance, the session table, and the user table. You can change the `varchar` length. `session(id)` should be able to hold at least 40 chars. + +```ts +import { DrizzleMySQLAdapter } from "@lucia-auth/adapter-drizzle"; + +import mysql from "mysql2/promise"; +import { mysqlTable, varchar, datetime } from "drizzle-orm/mysql-core"; +import { drizzle } from "drizzle-orm/mysql2"; + +const connection = await mysql.createConnection(); +const db = drizzle(connection); + +const userTable = mysqlTable("user", { + id: varchar("id", { + length: 255 + }).primaryKey() +}); + +const sessionTable = mysqlTable("session", { + id: varchar("id", { + length: 255 + }).primaryKey(), + userId: varchar("user_id", { + length: 255 + }) + .notNull() + .references(() => userTable.id), + expiresAt: datetime("expires_at").notNull() +}); + +const adapter = new DrizzleMySQLAdapter(db, sessionTable, userTable); +``` + +## PostgreSQL + +`DrizzlePostgreSQLAdapter` takes a `Database` instance, the session table, and the user table. + +```ts +import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle"; + +import pg from "pg"; +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { drizzle } from "drizzle-orm/node-postgres"; + +const pool = new pg.Pool(); +const db = drizzle(pool); + +const userTable = pgTable("user", { + id: text("id").primaryKey() +}); + +const sessionTable = pgTable("session", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: timestamp("expires_at", { + withTimezone: true, + mode: "date" + }).notNull() +}); + +const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, userTable); +``` + +## SQLite + +`DrizzleSQLiteAdapter` takes a `Database` instance, the session table, and the user table. + +```ts +import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; + +import sqlite from "better-sqlite3"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { drizzle } from "drizzle-orm/better-sqlite3"; + +const sqliteDB = sqlite(":memory:"); +const db = drizzle(sqliteDB); + +const userTable = sqliteTable("user", { + id: text("id").notNull().primaryKey() +}); + +const sessionTable = sqliteTable("session", { + id: text("id").notNull().primaryKey(), + userId: text("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: integer("expires_at").notNull() +}); + +const adapter = new DrizzleSQLiteAdapter(db, sessionTable, userTable); +``` diff --git a/docs/pages/database/index.md b/docs/pages/database/index.md new file mode 100644 index 000000000..0668636d2 --- /dev/null +++ b/docs/pages/database/index.md @@ -0,0 +1,29 @@ +--- +title: "Database" +--- + +# Database + +A database is required for storing your users and sessions. Lucia connects to your database via an adapter, which provides a set of basic, standardized querying methods that Lucia can use. + +```ts +import { Lucia } from "lucia"; +import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite"; + +const lucia = new Lucia(new BetterSqlite3Adapter(db)); +``` + +See [`Adapter`](/reference/main/Adapter) for building your own adapters. + +## Database setup + +Refer to these guides on setting up your database, ORMs, and query builders: + +- [Drizzle ORM](/database/drizzle) +- [Kysely](/database/kysely) +- [MongoDB](/database/mongodb) +- [Mongoose](/database/mongoose) +- [MySQL](/database/mysql): `mysql2`, PlanetScale serverless +- [PostgreSQL](/database/postgresql): node-postgres (`pg`), Postgres.js (`postgres`) +- [Prisma](/database/prisma) +- [SQLite](/database/sqlite): `better-sqlite3`, Bun SQLite (`bun:sqlite`), Cloudflare D1, LibSQL (Turso) diff --git a/docs/pages/database/kysely.md b/docs/pages/database/kysely.md new file mode 100644 index 000000000..8d56760bf --- /dev/null +++ b/docs/pages/database/kysely.md @@ -0,0 +1,118 @@ +--- +title: "Kysely" +--- + +# Kysely + +Lucia doesn't provide an adapter for Kysely but does provide adapters for drivers supported by Kysely. + +## MySQL + +See the [MySQL](/database/mysql) page for the schema. + +```ts +import { Lucia } from "lucia"; +import { Mysql2Adapter } from "@lucia-auth/adapter-mysql"; + +import { createPool } from "mysql2/promise"; +import { Kysely, MysqlDialect } from "kysely"; + +const pool = createPool(); + +const db = new Kysely({ + dialect: new MysqlDialect({ + pool: pool.pool // IMPORTANT NOT TO JUST PASS `pool` + }) +}); + +const adapter = new Mysql2Adapter(pool, tableNames); + +interface Database { + user: UserTable; + session: SessionTable; +} + +interface UserTable { + id: string; +} + +interface SessionTable { + id: string; + user_id: string; + expires_at: Date; +} +``` + +## PostgreSQL + +See the [PostgreSQL](/database/postgresql) page for the schema. + +```ts +import { Lucia } from "lucia"; +import { NodePostgresAdapter } from "@lucia-auth/adapter-postgresql"; + +import { Pool } from "pg"; +import { Kysely, PostgresDialect } from "kysely"; + +const pool = new Pool(); + +const db = new Kysely({ + dialect: new PostgresDialect({ + pool + }) +}); + +const adapter = new NodePostgresAdapter(pool, tableNames); + +interface Database { + user: UserTable; + session: SessionTable; +} + +interface UserTable { + id: string; +} + +interface SessionTable { + id: string; + user_id: string; + expires_at: Date; +} +``` + +## SQLite + +See the [SQLite](/database/sqlite) page for the schema. + +```ts +import { Lucia } from "lucia"; +import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite"; + +import sqlite from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; + +const sqliteDatabase = sqlite(); + +export const db = new Kysely({ + dialect: new SqliteDialect({ + database: sqliteDatabase + }) +}); + +const adapter = new BetterSqlite3Adapter(sqliteDatabase, tableNames); + +interface Database { + user: UserTable; + session: SessionTable; +} + +interface UserTable { + id: string; +} + +interface SessionTable { + id: string; + user_id: string; + expires_at: number; +} +``` diff --git a/docs/pages/database/mongodb.md b/docs/pages/database/mongodb.md new file mode 100644 index 000000000..dd623829f --- /dev/null +++ b/docs/pages/database/mongodb.md @@ -0,0 +1,40 @@ +--- +title: "MongoDB" +--- + +# MongoDB + +The `@lucia-auth/adapter-mongodb` package provides adapters for MongoDB. + +``` +npm install @lucia-auth/adapter-mongodb@beta +``` + +## Usage + +You must handle the database connection manually. + +```ts +import { Lucia } from "lucia"; +import { MongoDBAdapter } from "@lucia-auth/adapter-mongodb"; +import { Collection, MongoClient } from "mongodb"; + +const client = new MongoClient(); +await client.connect(); + +const db = client.db(); +const User = db.collection("users") as Collection; +const Session = db.collection("sessions") as Collection; + +const adapter = new MongodbAdapter(Session, User); + +interface UserDoc { + _id: string; +} + +interface Session { + _id: string; + expires_at: Date; + user_id: string; +} +``` diff --git a/docs/pages/database/mongoose.md b/docs/pages/database/mongoose.md new file mode 100644 index 000000000..a8a91dd82 --- /dev/null +++ b/docs/pages/database/mongoose.md @@ -0,0 +1,62 @@ +--- +title: "Mongoose" +--- + +# Mongoose + +You can use the [MongoDB adapter](/database/mongodb) from the `@lucia-auth/adapter-mongodb` package with Mongoose. + +``` +npm install @lucia-auth/adapter-mongodb@beta +``` + +## Usage + +You must handle the database connection manually. + +```ts +import { Lucia } from "lucia"; +import { MongoDBAdapter } from "@lucia-auth/adapter-mongodb"; +import mongoose from "mongoose"; + +await mongoose.connect(); + +const User = mongoose.model( + "User", + new mongoose.Schema( + { + _id: { + type: String, + required: true + } + } as const, + { _id: false } + ) +); + +const Session = mongoose.model( + "Session", + new mongoose.Schema( + { + _id: { + type: String, + required: true + }, + user_id: { + type: String, + required: true + }, + expires_at: { + type: Date, + required: true + } + } as const, + { _id: false } + ) +); + +const adapter = new MongodbAdapter( + mongoose.connection.collection("sessions"), + mongoose.connection.collection("users") +); +``` diff --git a/docs/pages/database/mysql.md b/docs/pages/database/mysql.md new file mode 100644 index 000000000..9bbdb7849 --- /dev/null +++ b/docs/pages/database/mysql.md @@ -0,0 +1,66 @@ +--- +title: "MySQL" +--- + +# MySQL + +`@lucia-auth/adapter-mysql` package provides adapters for MySQL drivers: + +- `mysql2` +- PlanetScale serverless + +``` +npm install @lucia-auth/adapter-mysql@beta +``` + +## Schema + +You can change the `varchar` length as necessary. `session(id)` should be able to hold at least 40 chars. + +```sql +CREATE TABLE user ( + id VARCHAR(255) PRIMARY KEY +) + +CREATE TABLE user_session ( + id VARCHAR(255) PRIMARY KEY, + expires_at DATETIME NOT NULL, + user_id VARCHAR(255) NOT NULL REFERENCES user(id) +) +``` + +## Drivers + +### `mysql2` + +`Mysql2Adapter` takes a `Pool` or `Connection` instance from `mysql2/promises` and a list of table names. + +```ts +import { Lucia } from "lucia"; +import { Mysql2Adapter } from "@lucia-auth/adapter-mysql"; +import mysql from "mysql2/promise"; + +const pool = mysql.createPool(); + +const adapter = new Mysql2Adapter(pool, { + user: "user", + session: "user_session" +}); +``` + +### PlanetScale serverless + +`PlanetScaleAdapter` takes a `Connection` instance and a list of table names. + +```ts +import { Lucia } from "lucia"; +import { PlanetScaleAdapter } from "@lucia-auth/adapter-mysql"; +import { connect } from "@planetscale/database"; + +const connection = connect(); + +const adapter = new PlanetScaleAdapter(connection, { + user: "user", + session: "user_session" +}); +``` diff --git a/docs/pages/database/postgresql.md b/docs/pages/database/postgresql.md new file mode 100644 index 000000000..342d19be4 --- /dev/null +++ b/docs/pages/database/postgresql.md @@ -0,0 +1,64 @@ +--- +title: "PostgreSQL" +--- + +# PostgreSQL + +`@lucia-auth/adapter-postgresql` package provides adapters for PostgreSQL drivers: + +- node-postgres (`pg`) +- Postgres.js (`postgres`) + +``` +npm install @lucia-auth/adapter-postgresql@beta +``` + +## Schema + +```sql +CREATE TABLE auth_user ( + id TEXT PRIMARY KEY +) + +CREATE TABLE user_session ( + id TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + user_id TEXT NOT NULL REFERENCES auth_user(id) +) +``` + +## Drivers + +### node-postgres + +`NodePostgresAdapter` takes a `Pool` or `Client` instance and a list of table names. + +```ts +import { Lucia } from "lucia"; +import { NodePostgresAdapter } from "@lucia-auth/adapter-postgresql"; +import pg from "pg"; + +const pool = new pg.Pool(); + +const adapter = new NodePostgresAdapter(pool, { + user: "auth_user", + session: "user_session" +}); +``` + +### Postgres.js + +`PostgresJsAdapter` takes a `Sql` instance and a list of table names. + +```ts +import { Lucia } from "lucia"; +import { PostgresJsAdapter } from "@lucia-auth/adapter-postgresql"; +import postgres from "postgres"; + +const sql = postgres(); + +const adapter = new PostgresJsAdapter(sql, { + user: "auth_user", + session: "user_session" +}); +``` diff --git a/docs/pages/database/prisma.md b/docs/pages/database/prisma.md new file mode 100644 index 000000000..4c15a1700 --- /dev/null +++ b/docs/pages/database/prisma.md @@ -0,0 +1,41 @@ +--- +title: "Prisma" +--- + +# Prisma + +The `@lucia-auth/adapter-prisma` package provides adapters for Prisma. + +``` +npm install @lucia-auth/adapter-prisma@beta +``` + +## Schema + +```prisma +model User { + id String @id + sessions Session[] +} + +model Session { + id String @id + userId String + expiresAt DateTime + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +## Usage + +`PrismaAdapter` takes a session and user model. + +```ts +import { Lucia } from "lucia"; +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; +import { PrismaClient } from "@prisma/client"; + +const client = new PrismaClient(); + +const adapter = new PrismaAdapter(client.session, client.user); +``` diff --git a/docs/pages/database/sqlite.md b/docs/pages/database/sqlite.md new file mode 100644 index 000000000..982965365 --- /dev/null +++ b/docs/pages/database/sqlite.md @@ -0,0 +1,111 @@ +--- +title: "SQLite" +--- + +# SQLite + +The `@lucia-auth/adapter-sqlite` package provides adapters for SQLites drivers: + +- `better-sqlite3` +- Bun SQLite (`bun:sqlite`) +- Cloudflare D1 +- LibSQL (Turso) + +``` +npm install @lucia-auth/adapter-sqlite@beta +``` + +## Schema + +```sql +CREATE TABLE user ( + id TEXT NOT NULL PRIMARY KEY +) + +CREATE TABLE session ( + id TEXT NOT NULL PRIMARY KEY, + expires_at INTEGER NOT NULL, + user_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) +) +``` + +## Drivers + +### `better-sqlite3` + +`BetterSqlite3Adapter` takes a `Database` instance and a list of table names. + +```ts +import { Lucia } from "lucia"; +import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite"; +import sqlite from "better-sqlite3"; + +const db = sqlite(); + +const adapter = new BetterSqlite3Adapter(db, { + user: "user", + session: "session" +}); +``` + +### Bun SQLite + +`BunSQLiteAdapter` takes a `Database` instance and a list of table names. + +```ts +import { Lucia } from "lucia"; +import { BunSQLiteAdapter } from "@lucia-auth/adapter-sqlite"; +import { Database } from "bun:sqlite"; + +const db = new Database(); + +const adapter = new BunSQLiteAdapter(db, { + user: "user", + session: "session" +}); +``` + +### Cloudflare D1 + +`D1Adapter` takes a `D1Database` instance and a list of table names. + +Since the D1 binding is included with the request, create an `initializeLucia()` function to create a new `Lucia` instance on every request. + +```ts +import { Lucia } from "lucia"; +import { D1Adapter } from "@lucia-auth/adapter-sqlite"; + +export function initializeLucia(D1: D1Database) { + const adapter = new D1Adapter(D1, { + user: "user", + session: "session" + }); + return new Lucia(adapter); +} + +declare module "lucia" { + interface Register { + Auth: ReturnType; + } +} +``` + +### LibSQL + +`LibSQLAdapter` takes a `D1Database` instance and a list of table names. + +```ts +import { Lucia } from "lucia"; +import { LibSQLAdapter } from "@lucia-auth/adapter-sqlite"; +import { createClient } from "@libsql/client"; + +const db = createClient({ + url: "file:test/main.db" +}); + +const adapter = new LibSQLAdapter(db, { + user: "user", + session: "session" +}); +``` diff --git a/docs/pages/getting-started/astro.md b/docs/pages/getting-started/astro.md new file mode 100644 index 000000000..69c7009e5 --- /dev/null +++ b/docs/pages/getting-started/astro.md @@ -0,0 +1,104 @@ +--- +title: "Getting started in Astro" +--- + +# Getting started in Astro + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure to configure the `sessionCookie` option and register your `Lucia` instance type + +```ts +// src/auth.ts +import { Lucia } from "lucia"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + // set to `true` when using HTTPS + secure: import.meta.env.PROD + } + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Set up middleware + +We recommend setting up a middleware to validate requests. The validated user will be available as `local.user`. You can just copy-paste the code into `src/middleware.ts`. + +It's a bit verbose, but it just reads the session cookie, validates it, and sets a new cookie if necessary. Since Astro doesn't implement CSRF protection out of the box, it must be implemented. If you're curious about what's happening here, see the [Validating requests](/basics/validate-session-cookies/astro) page. + +```ts +// src/middleware.ts +import { lucia } from "./auth"; +import { verifyRequestOrigin } from "lucia"; +import { defineMiddleware } from "astro:middleware"; + +export const onRequest = defineMiddleware(async (context, next) => { + if (context.request.method !== "GET") { + const originHeader = context.request.headers.get("Origin"); + const hostHeader = context.request.headers.get("Host"); + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return new Response(null, { + status: 403 + }); + } + } + + const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) { + context.locals.user = null; + context.locals.session = null; + return next(); + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + context.locals.session = session; + context.locals.user = user; + return next(); +}); +``` + +Make sure sure to type `App.Locals` as well. + +```ts +// src/env.d.ts + +/// +declare namespace App { + interface Locals { + session: import("lucia").Session | null; + user: import("lucia").User | null; + } +} +``` + +## Next steps + +You can learn all the concepts and APIs by reading the [Basics section](/basics/sessions) in the docs. If you prefer writing code immediately, check out the [Tutorials](/tutorials) page or the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +If you have any questions, [join our Discord server](https://discord.com/invite/PwrK3kpVR3)! diff --git a/docs/pages/getting-started/express.md b/docs/pages/getting-started/express.md new file mode 100644 index 000000000..66384e7ed --- /dev/null +++ b/docs/pages/getting-started/express.md @@ -0,0 +1,59 @@ +--- +title: "Getting started in Express" +--- + +# Getting started in Express + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure you configure the `sessionCookie` option and register your `Lucia` instance type. + +```ts +import { Lucia } from "lucia"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + // set to `true` when using HTTPS + secure: process.env.NODE_ENV === "production" + } + } +}); + +// IMPORTANT! +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Polyfill + +If you're using Node.js 18 or below, you'll need to polyfill the Web Crypto API. This is not required in Node.js 20, CouldFlare Workers, Deno, Bun, and Vercel Edge Functions. This can be done either by importing `webcrypto`, or by enabling an experimental flag. + +```ts +import { webcrypto } from "node:crypto"; + +globalThis.crypto = webcrypto as Crypto; +``` + +``` +node --experimental-web-crypto index.js +``` + +## Next steps + +You can learn all the concepts and APIs by reading the [Basics section](/basics/sessions) in the docs. If you prefer writing code immediately, check out the [Tutorials](/tutorials) page or the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +If you have any questions, [join our Discord server](https://discord.com/invite/PwrK3kpVR3)! diff --git a/docs/pages/getting-started/index.md b/docs/pages/getting-started/index.md new file mode 100644 index 000000000..3a6389e38 --- /dev/null +++ b/docs/pages/getting-started/index.md @@ -0,0 +1,63 @@ +--- +title: "Getting started" +--- + +# Getting started + +A framework-specific guide is also available for: + +- [Astro](/getting-started/astro) +- [Express](/getting-started/express) +- [Next.js App router](/getting-started/nextjs-app) +- [Next.js Pages router](/getting-started/nextjs-pages) +- [Nuxt](/getting-started/nuxt) +- [SolidStart](/getting-started/solidstart) +- [SvelteKit](/getting-started/sveltekit) + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure you configure the `sessionCookie` option and register your `Lucia` instance type. + +```ts +import { Lucia } from "lucia"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + // set to `true` when using HTTPS + secure: process.env.NODE_ENV === "production" + } + } +}); + +// IMPORTANT! +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Polyfill + +If you're using Node.js 18 or below, you'll need to polyfill the Web Crypto API. This is not required in Node.js 20, CouldFlare Workers, Deno, Bun, and Vercel Edge Functions. This can be done either by importing `webcrypto`, or by enabling an experimental flag. + +```ts +import { webcrypto } from "node:crypto"; + +globalThis.crypto = webcrypto as Crypto; +``` + +``` +node --experimental-web-crypto index.js +``` diff --git a/docs/pages/getting-started/nextjs-app.md b/docs/pages/getting-started/nextjs-app.md new file mode 100644 index 000000000..5f6f647e0 --- /dev/null +++ b/docs/pages/getting-started/nextjs-app.md @@ -0,0 +1,77 @@ +--- +title: "Getting started in Next.js App router" +--- + +# Getting started in Next.js App router + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides in the docs use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure you configure the `sessionCookie` option and register your `Lucia` instance type. + +```ts +// src/auth.ts +import { Lucia } from "lucia"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + // this sets cookies with super long expiration + // since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages + expires: false, + attributes: { + // set to `true` when using HTTPS + secure: process.env.NODE_ENV === "production" + } + } +}); + +// IMPORTANT! +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Polyfill + +If you're using Node.js 18 or below, you'll need to polyfill the Web Crypto API. This is not required in Node.js 20, CouldFlare Workers, Deno, Bun, and Vercel Edge Functions. This can be done either by importing `webcrypto`, or by enabling an experimental flag. + +```ts +import { webcrypto } from "node:crypto"; + +globalThis.crypto = webcrypto as Crypto; +``` + +``` +node --experimental-web-crypto index.js +``` + +## Update configuration + +If you've installed Oslo, mark its dependencies as external to prevent it from getting bundled. This is only required when using the `oslo/password` module. + +```ts +// next.config.ts +const nextConfig = { + webpack: (config) => { + config.externals.push("@node-rs/argon2", "@node-rs/bcrypt"); + return config; + } +}; +``` + +## Next steps + +You can learn all the concepts and APIs by reading the [Basics section](/basics/sessions) in the docs. If you prefer writing code immediately, check out the [Tutorials](/tutorials) page or the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +If you have any questions, [join our Discord server](https://discord.com/invite/PwrK3kpVR3)! diff --git a/docs/pages/getting-started/nextjs-pages.md b/docs/pages/getting-started/nextjs-pages.md new file mode 100644 index 000000000..34b45232a --- /dev/null +++ b/docs/pages/getting-started/nextjs-pages.md @@ -0,0 +1,85 @@ +--- +title: "Getting started in Next.js Pages router" +--- + +# Getting started in Next.js Pages router + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure you configure the `sessionCookie` option and register your `Lucia` instance type. + +```ts +// src/auth.ts +import { Lucia } from "lucia"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + // set to `true` when using HTTPS + secure: process.env.NODE_ENV === "production" + } + } +}); + +// IMPORTANT! +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Polyfill + +If you're using Node.js 18 or below, you'll need to polyfill the Web Crypto API. This is not required in Node.js 20, CouldFlare Workers, Deno, Bun, and Vercel Edge Functions. This can be done either by importing `webcrypto`, or by enabling an experimental flag. + +```ts +import { webcrypto } from "node:crypto"; + +globalThis.crypto = webcrypto as Crypto; +``` + +``` +node --experimental-web-crypto index.js +``` + +## Set up middleware + +If you're planning to use cookies, you must implement CSRF protection. + +```ts +// middleware.ts +import { verifyRequestOrigin } from "lucia"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest): Promise { + if (request.method === "GET") { + return NextResponse.next(); + } + const originHeader = request.headers.get("Origin"); + const hostHeader = request.headers.get("Host"); + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return new NextResponse(null, { + status: 403 + }); + } + return NextResponse.next(); +} +``` + +## Next steps + +You can learn all the concepts and APIs by reading the [Basics section](/basics/sessions) in the docs. If you prefer writing code immediately, check out the [Tutorials](/tutorials) page or the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +If you have any questions, [join our Discord server](https://discord.com/invite/PwrK3kpVR3)! diff --git a/docs/pages/getting-started/nuxt.md b/docs/pages/getting-started/nuxt.md new file mode 100644 index 000000000..821e33dcf --- /dev/null +++ b/docs/pages/getting-started/nuxt.md @@ -0,0 +1,111 @@ +--- +title: "Getting started in Nuxt" +--- + +# Getting started in Nuxt + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure you configure the `sessionCookie` option and register your `Lucia` instance type. + +- Configure the `sessionCookie` option +- Register your `Lucia` instance type + +```ts +// server/utils/auth.ts +import { Lucia } from "lucia"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + // IMPORTANT! + attributes: { + // set to `true` when using HTTPS + secure: !process.dev + } + } +}); + +// IMPORTANT! +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Polyfill + +If you're using Node.js 18 or below, you'll need to polyfill the Web Crypto API. This is not required in Node.js 20, CouldFlare Workers, Deno, Bun, and Vercel Edge Functions. This can be done either by importing `webcrypto`, or by enabling an experimental flag. + +```ts +import { webcrypto } from "node:crypto"; + +globalThis.crypto = webcrypto as Crypto; +``` + +``` +node --experimental-web-crypto index.js +``` + +## Set up middleware + +We recommend setting up a middleware to validate requests. The validated user will be available as `event.context.user`. You can just copy-paste the code into `server/middleware/auth.ts`. + +It's a bit verbose, but it just reads the session cookie, validates it, and sets a new cookie if necessary. Since Nuxt doesn't implement CSRF protection out of the box, it must be implemented. If you're curious about what's happening here, see the [Validating requests](/basics/validate-session-cookies/nuxt) page. + +```ts +// server/middleware/auth.ts +import { verifyRequestOrigin } from "lucia"; + +import type { Session, User } from "lucia"; + +export default defineEventHandler(async (event) => { + if (event.method !== "GET") { + const originHeader = getHeader(event, "Origin") ?? null; + const hostHeader = getHeader(event, "Host") ?? null; + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return event.node.res.writeHead(403).end(); + } + } + + const sessionId = getCookie(event, lucia.sessionCookieName) ?? null; + if (!sessionId) { + event.context.session = null; + event.context.user = null; + return; + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + appendResponseHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + } + if (!session) { + appendResponseHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize()); + } + event.context.session = session; + event.context.user = user; +}); + +declare module "h3" { + interface H3EventContext { + user: User | null; + session: Session | null; + } +} +``` + +## Next steps + +You can learn all the concepts and APIs by reading the [Basics section](/basics/sessions) in the docs. If you prefer writing code immediately, check out the [Tutorials](/tutorials) page or the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +If you have any questions, [join our Discord server](https://discord.com/invite/PwrK3kpVR3)! diff --git a/docs/pages/getting-started/solidstart.md b/docs/pages/getting-started/solidstart.md new file mode 100644 index 000000000..9c81a0b08 --- /dev/null +++ b/docs/pages/getting-started/solidstart.md @@ -0,0 +1,108 @@ +--- +title: "Getting started in SolidStart" +--- + +# Getting started in SolidStart + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure to configure the `sessionCookie` option and register your `Lucia` instance type + +```ts +// src/lib/auth.ts +import { Lucia } from "lucia"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + // set to `true` when using HTTPS + secure: import.meta.env.PROD + } + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Set up middleware + +We recommend setting up a middleware to validate requests. The validated user will be available as `context.user`. You can just copy-paste the code into `src/middleware.ts`. + +It's a bit verbose, but it just reads the session cookie, validates it, and sets a new cookie if necessary. Since SolidStart doesn't implement CSRF protection out of the box, it must be implemented when working with cookies. If you're curious about what's happening here, see the [Validating requests](/basics/validate-session-cookies/solidstart) page. + +```ts +// src/middleware.ts +import { createMiddleware, appendHeader, getCookie, getHeader } from "@solidjs/start/server"; +import { Session, User, verifyRequestOrigin } from "lucia"; +import { lucia } from "./lib/auth"; + +export default createMiddleware({ + onRequest: async (event) => { + if (event.node.req.method !== "GET") { + const originHeader = getHeader(event, "Origin") ?? null; + const hostHeader = getHeader(event, "Host") ?? null; + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + event.node.res.writeHead(403).end(); + return; + } + } + + const sessionId = getCookie(event, lucia.sessionCookieName) ?? null; + if (!sessionId) { + event.context.session = null; + event.context.user = null; + return; + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + appendHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + } + if (!session) { + appendHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize()); + } + event.context.session = session; + event.context.user = user; + } +}); + +declare module "vinxi/server" { + interface H3EventContext { + user: User | null; + session: Session | null; + } +} +``` + +Make sure to declare the middleware module in the config. + +```ts +// vite.config.ts +import { defineConfig } from "@solidjs/start/config"; + +export default defineConfig({ + start: { + middleware: "./src/middleware.ts" + } +}); +``` + +## Next steps + +You can learn all the concepts and APIs by reading the [Basics section](/basics/sessions) in the docs. If you prefer writing code immediately, check out the [Tutorials](/tutorials) page or the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +If you have any questions, [join our Discord server](https://discord.com/invite/PwrK3kpVR3)! diff --git a/docs/pages/getting-started/sveltekit.md b/docs/pages/getting-started/sveltekit.md new file mode 100644 index 000000000..128077849 --- /dev/null +++ b/docs/pages/getting-started/sveltekit.md @@ -0,0 +1,104 @@ +--- +title: "Getting started in Sveltekit" +--- + +# Getting started in Sveltekit + +## Installation + +Install Lucia using your package manager of your choice. While not strictly necessary, we recommend installing [Oslo](https://oslo.js.org), which Lucia is built on, for various auth utilities (which a lot of the guides use). + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Import `Lucia` and initialize it with your adapter. Refer to the [Database](/database) page to learn how to set up your database and initialize the adapter. Make sure to configure the `sessionCookie` option and register your `Lucia` instance type + +```ts +// src/lib/server/auth.ts +import { Lucia } from "lucia"; +import { dev } from "$app/environment"; + +const adapter = new BetterSQLite3Adapter(db); // your adapter + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + // set to `true` when using HTTPS + secure: !dev + } + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Setup hooks + +We recommend setting up a handle hook to validate requests. The validated user will be available as `local.user`. + +If you're curious about what's happening here, see the [Validating requests](/basics/validate-session-cookies/sveltekit) page. + +```ts +// src/hooks.server.ts +import { lucia } from "$lib/server/auth"; +import type { Handle } from "@sveltejs/kit"; + +export const handle: Handle = async ({ event, resolve }) => { + const sessionId = event.cookies.get(lucia.sessionCookieName); + if (!sessionId) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + // sveltekit types deviates from the de-facto standard + // you can use 'as any' too + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + } + event.locals.user = user; + event.locals.session = session; + return resolve(event); +}; +``` + +Make sure sure to type `App.Locals` as well. + +```ts +// src/app.d.ts +declare global { + namespace App { + interface Locals { + user: import("lucia").User | null; + session: import("lucia").Session | null; + } + } +} + +export {}; +``` + +## Next steps + +You can learn all the concepts and APIs by reading the [Basics section](/basics/sessions) in the docs. If you prefer writing code immediately, check out the [Tutorials](/tutorials) page or the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +If you have any questions, [join our Discord server](https://discord.com/invite/PwrK3kpVR3)! diff --git a/docs/pages/guides/email-and-password/2fa.md b/docs/pages/guides/email-and-password/2fa.md new file mode 100644 index 000000000..fa6ad9936 --- /dev/null +++ b/docs/pages/guides/email-and-password/2fa.md @@ -0,0 +1,109 @@ +--- +title: "Two-factor authorization" +--- + +# Two-factor authorization + +The guide covers how to implement two-factor authorization using time-based OTP (TOTP) and authenticator apps. + +## Update database + +Update the user table to include `two_factor_secret` column. You can of course store the secret in its own table. + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: env === "PRODUCTION" // set `Secure` flag in HTTPS + } + }, + getUserAttributes: (attributes) => { + return { + // ... + // don't expose the secret + // rather expose whether if the user has setup 2fa + setupTwoFactor: attributes.two_factor_secret !== null + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } + interface DatabaseUserAttributes { + two_factor_secret: string | null; + } +} +``` + +## Create QR code + +When the user signs up, set `two_factor_secret` to `null` to indicate the user has yet to set up two-factor authorization. + +```ts +app.post("/signup", async () => { + // ... + + const userId = generateId(); + + await db.table("user").insert({ + id: userId, + two_factor_secret: null + // ... + }); + + // ... +}); +``` + +Generate a new secret (minimum 20 bytes) and create a new key URI with [`createTOTPKeyURI()`](https://oslo.js.org/reference/otp/createTOTPKeyURI). The user should scan the QR code using their authenticator app. + +```ts +import { encodeHex } from "oslo/encoding"; +import { createTOTPKeyURI } from "oslo/otp"; + +const { user } = await lucia.validateSession(sessionId); +if (!user) { + return new Response(null, { + status: 401 + }); +} + +const twoFactorSecret = crypto.getRandomValues(new Uint8Array(20)); +await db + .table("user") + .where("id", "=", user.id) + .update({ + two_factor_secret: encodeHex(twoFactorSecret) + }); + +// pass the website's name and the user identifier (e.g. email, username) +const uri = createTOTPKeyURI("my-app", user.email, twoFactorSecret); + +// use any image generator +const qrcode = createQRCode(uri); +``` + +## Validate OTP + +Validate TOTP with [`TOTPController`](https://oslo.js.org/reference/otp/TOTPController) using the stored user's secret. + +```ts +import { decodeHex } from "oslo/encoding"; +import { TOTPController } from "oslo/otp"; + +let otp: string; + +const { user } = await lucia.validateSession(sessionId); +if (!user) { + return new Response(null, { + status: 401 + }); +} + +const result = await db.table("user").where("id", "=", user.id).get("two_factor_secret"); +const validOTP = await new TOTPController().verify(otp, decodeHex(result.two_factor_secret)); +``` diff --git a/docs/pages/guides/email-and-password/basics.md b/docs/pages/guides/email-and-password/basics.md new file mode 100644 index 000000000..0631a2734 --- /dev/null +++ b/docs/pages/guides/email-and-password/basics.md @@ -0,0 +1,183 @@ +--- +title: "Password basics" +--- + +# Password basics + +This page covers how to implement a password-based auth with Lucia. If you're looking for a step-by-step, framework-specific tutorial, you may want to check out the [Username and password](/tutorials/username-and-password) tutorial. Keep in mind that email-based auth requires more than just passwords! + +## Update database + +Add a unique `email` and `hashed_password` column to the user table. + +| column | type | attributes | +| ----------------- | -------- | ---------- | +| `email` | `string` | unique | +| `hashed_password` | `string` | | + +Declare the type with `DatabaseUserAttributes` and add the attributes to the user object using the `getUserAttributes()` configuration. + +```ts +// auth.ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: env === "PRODUCTION" // set `Secure` flag in HTTPS + } + }, + getUserAttributes: (attributes) => { + return { + // we don't need to expose the hashed password! + email: attributes.email + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } + interface DatabaseUserAttributes { + email: string; + } +} +``` + +## Email check + +Before creating routes, create a basic utility to verify emails. Emails are notoriously complicated, so here we're just checking if an `@` exists with at least 1 character on each side. We just need to check for obvious typos here. For verifying emails, see the [email verification]() page. + +```ts +export function isValidEmail(email: string): boolean { + return /.+@.+/.test(email); +} +``` + +## Register user + +Create a `/signup` route. This will accept POST requests with an email and password. Hash the password, create a new user, and create a new session. + +```ts +import { lucia } from "./auth.js"; +import { generateId } from "lucia"; +import { Argon2id } from "oslo/password"; + +app.post("/signup", async (request: Request) => { + const formData = await request.formData(); + const email = formData.get("email"); + if (!email || typeof email !== "string" || !isValidEmail(email)) { + return new Response("Invalid email", { + status: 400 + }); + } + const password = formData.get("password"); + if (!password || typeof password !== "string" || password.length < 6) { + return new Response("Invalid password", { + status: 400 + }); + } + + const hashedPassword = await new Argon2id().hash(password); + const userId = generateId(15); + + try { + await db.table("user").insert({ + id: userId, + email, + hashed_password: hashedPassword + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); + } catch { + // db error, email taken, etc + return new Response("Email already used", { + status: 400 + }); + } +}); +``` + +### Hashing passwords + +`oslo/password` currently provides [`Argon2id`](https://oslo.js.org/reference/password/Argon2id), [`Scrypt`](https://oslo.js.org/reference/password/Scrypt), and [`Bcrypt`](https://oslo.js.org/reference/password/Bcrypt). These rely on the fastest available libraries but only work in Node.js. Passwords are salted and hashed using settings recommended by OWASP. + +```ts +import { Argon2id, Scrypt, Bcrypt } from "oslo/password"; +``` + +For Bun, we recommend using [`Bun.password`](https://bun.sh/docs/api/hashing), which also uses Argon2id by default. For other runtimes, Lucia provides a pure-JS implementation of Scrypt with [`Scrypt`](/reference/main/Scrypt) that works in any environment. However, we do not recommend this for Node.js as it can be 2~3 times slower than the Node-only version. If you're migrating from Lucia v2, you should use [`LegacyScrypt`](/reference/main/LegacyScrypt). + +```ts +import { Scrypt, LegacyScrypt } from "lucia"; +``` + +## Sign in user + +Create a `/login` route. This will accept POST requests with an email and password. Get the user with the email, verify the password against the hash, and create a new session. + +```ts +import { lucia } from "./auth.js"; +import { generateId } from "lucia"; +import { Argon2id } from "oslo/password"; + +app.post("/login", async (request: Request) => { + const formData = await request.formData(); + const email = formData.get("email"); + if (!email || typeof email !== "string") { + return new Response("Invalid email", { + status: 400 + }); + } + const password = formData.get("password"); + if (!password || typeof password !== "string") { + return new Response(null, { + status: 400 + }); + } + + const user = await db.table("user").where("email", "=", email).get(); + + if (!user) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid emails from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid emails. + // However, valid emails can be already be revealed with the signup page + // and a similar timing issue can likely be found in password reset implementation. + // It will also be much more resource intensive. + // Since protecting against this is none-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If emails/usernames are public, you may outright tell the user that the username is invalid. + return new Response("Invalid email or password", { + status: 400 + }); + } + + const validPassword = await new Argon2id().verify(user.hashed_password, password); + if (!validPassword) { + return new Response("Invalid email or password", { + status: 400 + }); + } + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); +}); +``` diff --git a/docs/pages/guides/email-and-password/email-verification-codes.md b/docs/pages/guides/email-and-password/email-verification-codes.md new file mode 100644 index 000000000..bb2607ba1 --- /dev/null +++ b/docs/pages/guides/email-and-password/email-verification-codes.md @@ -0,0 +1,181 @@ +--- +title: "Email verification codes" +--- + +# Email verification codes + +## Update database + +### User table + +Add a `email_verified` column (boolean). + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: env === "PRODUCTION" // set `Secure` flag in HTTPS + } + }, + getUserAttributes: (attributes) => { + return { + emailVerified: attributes.email_verified, + email: attributes.email + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } + interface DatabaseUserAttributes { + email: string; + email_verified: boolean; + } +} +``` + +### Email verification code table + +Create a table for storing for email verification codes. + +| column | type | attributes | +| ------------ | -------- | ------------------- | +| `id` | any | auto increment, etc | +| `code` | `string` | | +| `user_id` | `string` | unique | +| `email` | `string` | | +| `expires_at` | `Date` | | + +## Generate verification code + +The code should be valid for few minutes and linked to a single email. + +```ts +import { TimeSpan, createDate } from "oslo"; +import { generateRandomString, alphabet } from "oslo/crypto"; + +async function generateEmailVerificationCode(userId: string, email: string): Promise { + await db.table("email_verification_code").where("user_id", "=", userId).deleteAll(); + const code = generateRandomString(8, alphabet("0-9")); + await db.table("email_verification_code").insert({ + user_id: userId, + email, + code, + expires_at: createDate(new TimeSpan(5, "m")) // 5 minutes + }); + return code; +} +``` + +You can also use alphanumeric codes. + +```ts +const code = generateRandomString(6, alphabet("0-9", "A-Z")); +``` + +When a user signs up, set `email_verified` to `false`, create and send a verification code, and create a new session. + +```ts +import { generateId } from "lucia"; +import { encodeHex } from "oslo/encoding"; + +app.post("/signup", async () => { + // ... + + const userId = generateId(); + + await db.table("user").insert({ + id: userId, + email, + hashed_password: hashedPassword, + email_verified: false + }); + + const verificationCode = await generateEmailVerificationCode(userId, email); + await verificationCode(email, verificationCode); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); +}); +``` + +When resending verification emails, make sure to implement rate limiting based on user ID and IP address. + +## Verify code and email + +**Make sure to implement throttling to prevent brute-force attacks**. + +Validate the verification code by comparing it against your database and checking the expiration and email. Make sure to invalidate all user sessions. + +```ts +import { isWithinExpiration } from "oslo"; + +app.post("/email-verification", async () => { + // ... + const { user } = await lucia.validateSession(sessionId); + if (!user) { + return new Response(null, { + status: 401 + }); + } + const code = formData.get("code"); + // check for length + if (typeof code !== "string" || code.length !== 8) { + return new Response(null, { + status: 400 + }); + } + + await db.beginTransaction(); + const databaseCode = await db + .table("email_verification_code") + .where("user_id", "=", user.id) + .get(); + if (databaseCode) { + await db.table("email_verification_code").where("id", "=", databaseCode.id).delete(); + } + await db.commit(); + + if (!databaseCode || databaseCode.code !== code) { + return new Response(null, { + status: 400 + }); + } + if (!isWithinExpiration(databaseCode.expires_at)) { + return new Response(null, { + status: 400 + }); + } + if (!user || user.email !== databaseCode.email) { + return new Response(null, { + status: 400 + }); + } + + await lucia.invalidateUserSessions(user.id); + await db.table("user").where("id", "=", user.id).update({ + email_verified: true + }); + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); +}); +``` diff --git a/docs/pages/guides/email-and-password/email-verification-links.md b/docs/pages/guides/email-and-password/email-verification-links.md new file mode 100644 index 000000000..559943f4a --- /dev/null +++ b/docs/pages/guides/email-and-password/email-verification-links.md @@ -0,0 +1,159 @@ +--- +title: "Email verification links" +--- + +# Email verification links + +We recommend using [email verification codes](/guides/email-and-password/email-verification-codes) instead as it's more user-friendly. + +## Update database + +### User table + +Add a `email_verified` column (boolean). + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: env === "PRODUCTION" // set `Secure` flag in HTTPS + } + }, + getUserAttributes: (attributes) => { + return { + emailVerified: attributes.email_verified, + email: attributes.email + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } + interface DatabaseUserAttributes { + email: string; + email_verified: boolean; + } +} +``` + +### Email verification token table + +Create a table for storing for email verification tokens. + +| column | type | attributes | +| ------------ | -------- | ----------- | +| `id` | `string` | primary key | +| `user_id` | `string` | | +| `email` | `string` | | +| `expires_at` | `Date` | | + +## Create verification token + +The token should be valid for at most few hours and linked to a single email. + +```ts +import { TimeSpan, createDate } from "oslo"; + +async function createEmailVerificationToken(userId: string, email: string): Promise { + // optionally invalidate all existing tokens + await db.table("email_verification_token").where("user_id", "=", userId).deleteAll(); + const tokenId = generateId(40); + await db.table("email_verification_token").insert({ + id: tokenId, + user_id: userId, + email, + expires_at: createDate(new TimeSpan(2, "h")) + }); + return tokenId; +} +``` + +When a user signs up, set `email_verified` to `false`, create and send a verification token, and create a new session. You can either store the token as part of the pathname or inside the search params of the verification endpoint. + +```ts +import { generateId } from "lucia"; +import { encodeHex } from "oslo/encoding"; + +app.post("/signup", async () => { + // ... + + const userId = generateId(); + + await db.table("user").insert({ + id: userId, + email, + hashed_password: hashedPassword, + email_verified: false + }); + + const verificationToken = await createEmailVerificationToken(userId, email); + const verificationLink = "http://localhost:3000/email-verification/" + verificationToken; + await sendVerificationEmail(email, verificationLink); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); +}); +``` + +When resending verification emails, make sure to implement rate limiting based on user ID and IP address. + +## Verify token and email + +Extract the email verification token from the URL and validate by checking the expiration date and email. If the token is valid, invalidate all existing user sessions and create a new session. Make sure to invalidate all user sessions. + +```ts +import { isWithinExpiration } from "oslo"; + +app.get("email-verification/:token", async () => { + // ... + + // check your framework's API + const verificationToken = params.token; + + await db.beginTransaction(); + const token = await db + .table("email_verification_token") + .where("id", "=", verificationToken) + .get(); + await db.table("email_verification_token").where("id", "=", verificationToken).delete(); + await db.commit(); + + if (!token || !isWithinExpiration(token.expires_at)) { + return new Response(null, { + status: 400 + }); + } + const user = await db.table("user").where("id", "=", token.user_id).get(); + if (!user || user.email !== token.email) { + return new Response(null, { + status: 400 + }); + } + + await lucia.invalidateUserSessions(user.id); + await db.table("user").where("id", "=", user.id).update({ + email_verified: true + }); + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); +}); +``` diff --git a/docs/pages/guides/email-and-password/index.md b/docs/pages/guides/email-and-password/index.md new file mode 100644 index 000000000..d876ac776 --- /dev/null +++ b/docs/pages/guides/email-and-password/index.md @@ -0,0 +1,15 @@ +--- +title: "Email and password" +--- + +# Email and password + +Email-based auth requires a lot of components so be prepared to do some work! For a step-by-step, framework-specific tutorial to learn the basics of password-based auth and Lucia, see the [Username and password](/tutorials/username-and-password) tutorial. + +- [Password basics](/guides/email-and-password/basics) +- Email verification + - [Email verification codes](/guides/email-and-password/email-verification-codes) (preferred) + - [Email verification links](/guides/email-and-password/email-verification-links) +- [Password reset](/guides/email-and-password/password-reset) +- [Login throttling](/guides/email-and-password/login-throttling) +- [Two-factor authorization](/guides/email-and-password/2fa) diff --git a/docs/pages/guides/email-and-password/login-throttling.md b/docs/pages/guides/email-and-password/login-throttling.md new file mode 100644 index 000000000..f96bfc05f --- /dev/null +++ b/docs/pages/guides/email-and-password/login-throttling.md @@ -0,0 +1,7 @@ +--- +title: "Login throttling" +--- + +# Login throttling + +_Work in progress_ diff --git a/docs/pages/guides/email-and-password/password-reset.md b/docs/pages/guides/email-and-password/password-reset.md new file mode 100644 index 000000000..44092f559 --- /dev/null +++ b/docs/pages/guides/email-and-password/password-reset.md @@ -0,0 +1,118 @@ +--- +title: "Password reset" +--- + +# Password reset + +Allow users to reset their password by sending them a reset link to their inbox. + +## Update database + +Create a table for storing for password reset tokens. + +| column | type | attributes | +| ------------ | -------- | ----------- | +| `id` | `string` | primary key | +| `user_id` | `string` | | +| `expires_at` | `Date` | | + +## Create verification token + +The token should be valid for at most few hours. + +```ts +import { TimeSpan, createDate } from "oslo"; +import { generateId } from "lucia"; + +async function createPasswordResetToken(userId: string): Promise { + // optionally invalidate all existing tokens + await db.table("password_reset_token").where("user_id", "=", userId).deleteAll(); + const tokenId = generateId(40); + await db.table("password_reset_token").insert({ + id: tokenId, + user_id: userId, + expires_at: createDate(new TimeSpan(2, "h")) + }); + return tokenId; +} +``` + +When a user requests a password reset email, check if the email is valid and create a new link. + +```ts +import { generateId } from "lucia"; + +app.post("/reset-password", async () => { + let email: string; + + // ... + + const user = await db.table("user").where("email", "=", email).get(); + if (!user || !user.email_verified) { + return new Response("Invalid email", { + status: 400 + }); + } + + const verificationToken = await createPasswordResetToken(userId); + const verificationLink = "http://localhost:3000/reset-password/" + verificationToken; + + await sendPasswordResetToken(email, verificationLink); + return new Response(null, { + status: 200 + }); +}); +``` + +Make sure to implement rate limiting based on IP addresses. + +## Verify token + +Extract the verification token from the URL and validate by checking the expiration date. If the token is valid, invalidate all existing user sessions, update the database, and create a new session. + +```ts +import { isWithinExpirationDate } from "oslo"; +import { Argon2id } from "oslo/password"; + +app.post("/reset-password/:token", async () => { + let password = formData.get("password"); + if (typeof password !== "string" || password.length < 8) { + return new Response(null, { + status: 400 + }); + } + // check your framework's API + const verificationToken = params.token; + + // ... + + await db.beginTransaction(); + const token = await db.table("password_reset_token").where("id", "=", verificationToken).get(); + if (token) { + await db.table("password_reset_token").where("id", "=", verificationToken).delete(); + } + await db.commit(); + + if (!token || !isWithinExpirationDate(token.expires_at)) { + return new Response(null, { + status: 400 + }); + } + + await lucia.invalidateUserSessions(user.id); + const hashedPassword = await new Argon2id().hash(password); + await db.table("user").where("id", "=", user.id).update({ + hashed_password: hashedPassword + }); + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); +}); +``` diff --git a/docs/pages/guides/improving-sessions.md b/docs/pages/guides/improving-sessions.md new file mode 100644 index 000000000..0105be940 --- /dev/null +++ b/docs/pages/guides/improving-sessions.md @@ -0,0 +1,7 @@ +--- +title: "Improving sessions" +--- + +# Improving sessions + +_Work in progress_ diff --git a/docs/pages/guides/oauth/account-linking.md b/docs/pages/guides/oauth/account-linking.md new file mode 100644 index 000000000..341c19ac8 --- /dev/null +++ b/docs/pages/guides/oauth/account-linking.md @@ -0,0 +1,93 @@ +--- +title: "Account linking" +--- + +# Account linking + +This guide uses the database schema shown in the [Multiple OAuth providers](/guides/oauth/multiple-providers) guide. + +## Automatic + +In general, you'd want to link accounts with the same email. Keep in mind that the email can be not verified and you should always assume it isn't. Make sure to verify that the email has been verified. + +```ts +import { generateId } from "lucia"; + +const tokens = await github.validateAuthorizationCode(code); +const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } +}); +const githubUser = await userResponse.json(); + +const emailsResponse = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } +}); +const emails = await emailsResponse.json(); + +const primaryEmail = emails.find((email) => email.primary) ?? null; +if (!primaryEmail) { + return new Response("No primary email address", { + status: 400 + }); +} +if (!primaryEmail.verified) { + return new Response("Unverified email", { + status: 400 + }); +} + +const existingUser = await db.table("user").where("email", "=", primaryEmail.email).get(); +if (existingUser) { + await db.table("oauth_account").insert({ + provider_id: "github", + provider_user_id: githubUser.id, + user_id: existingUser.id + }); +} else { + const userId = generateId(); + await db.beginTransaction(); + await db.table("user").insert({ + id: userId, + email: primaryEmail.email + }); + await db.table("oauth_account").insert({ + provider_id: "github", + provider_user_id: githubUser.id, + user_id: userId + }); + await db.commitTransaction(); +} +``` + +## Manual + +Another approach is to let users manually add OAuth accounts from their profile/settings page. You'd want to setup another OAuth flow, and instead of creating a new user, add a new OAuth account tied to the authenticated user. + +```ts +const { user } = await lucia.validateSession(); +if (!user) { + return new Response(null, { + status: 401 + }); +} + +const tokens = await github.validateAuthorizationCode(code); +const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } +}); +const githubUser = await userResponse.json(); + +// TODO: check if github account is already linked to a user + +await db.table("oauth_account").insert({ + provider_id: "github", + provider_user_id: githubUser.id, + user_id: user.id +}); +``` diff --git a/docs/pages/guides/oauth/basics.md b/docs/pages/guides/oauth/basics.md new file mode 100644 index 000000000..aa31b885f --- /dev/null +++ b/docs/pages/guides/oauth/basics.md @@ -0,0 +1,183 @@ +--- +title: "OAuth basics" +--- + +# OAuth basics + +For a step-by-step, framework-specific tutorial, see the [GitHub OAuth](/tutorials) tutorial. + +We recommend using [Arctic](https://github.com/pilcrowonpaper/arctic) for implementing OAuth 2.0. It is a lightweight library that provides APIs for creating authorization URLs, validating callbacks, and refreshing access tokens. This is the easiest way to implement OAuth with Lucia and it supports most major providers. This page will use GitHub, and while most providers have similar APIs, there might be some minor differences between them. + +``` +npm install arctic +``` + +For this guide, the callback URL is `/login/github/callback`, for example `http://localhost:3000/login/github/callback`. + +## Update database + +Add a `username` and a unique `github_id` column to the user table. + +| column | type | attributes | +| ----------- | -------- | ---------- | +| `username` | `string` | | +| `github_id` | `number` | unique | + +Declare the type with `DatabaseUserAttributes` and add the attributes to the user object using the `getUserAttributes()` configuration. + +```ts +// auth.ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: env === "PRODUCTION" // set `Secure` flag in HTTPS + } + }, + getUserAttributes: (attributes) => { + return { + githubId: attributes.github_id, + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } + interface DatabaseUserAttributes { + github_id: number; + username: string; + } +} +``` + +## Initialize OAuth provider + +Import `GitHub` from Arctic and initialize it with the client ID and secret. + +```ts +// auth.ts +import { GitHub } from "arctic"; + +export const github = new GitHub(clientId, clientSecret); +``` + +## Creating authorization URL + +Create a route to handle authorization. Generate a new state, create a new authorization URL with `createAuthorizationURL()`, store the state, and redirect the user to the authorization URL. The user will be prompted to sign in with GitHub. + +```ts +import { github } from "./auth.js"; +import { generateState } from "arctic"; +import { serializeCookie } from "oslo/cookie"; + +app.get("/login/github", async (): Promise => { + const state = generateState(); + const url = await github.createAuthorizationURL(state); + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + "Set-Cookie": serializeCookie("github_oauth_state", state, { + httpOnly: true, + secure: env === "PRODUCTION", // set `Secure` flag in HTTPS + maxAge: 60 * 10, // 10 minutes + path: "/" + }) + } + }); +}); +``` + +You can now create a sign in button with just an anchor tag. + +```html +Sign in with GitHub +``` + +## Validate callback + +In the callback route, first get the state from the cookie and the search params and compare them. Validate the authorization code in the search params with `validateAuthorizationCode()`. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the code or credentials are invalid. After validating the code, get the user's profile using the access token. Check if the user is already registered with the GitHub ID, and create a new user if they aren't. Finally, create a new session and set the session cookie. + +```ts +import { github, lucia } from "./auth.js"; +import { OAuth2RequestError } from "arctic"; +import { generateId } from "lucia"; +import { parseCookies } from "oslo/cookie"; + +app.get("/login/github/callback", async (request: Request): Promise => { + const cookies = parseCookies(request.headers.get("Cookie") ?? ""); + const stateCookie = cookies.get("github_oauth_state") ?? null; + + const url = new URL(request.url); + const state = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + + // verify state + if (!state || !stateCookie || !code || stateCookie !== state) { + return new Response(null, { + status: 400 + }); + } + + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + }); + const githubUserResult: GitHubUserResult = await githubUserResponse.json(); + + const existingUser = await db.table("user").where("github_id", "=", githubUserResult.id).get(); + + if (existingUser) { + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); + } + + const userId = generateId(15); + await db.table("user").insert({ + id: userId, + username: githubUserResult.login, + github_id: githubUserResult.id + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); + } catch (e) { + console.log(e); + if (e instanceof OAuth2RequestError) { + // bad verification code, invalid credentials, etc + return new Response(null, { + status: 400 + }); + } + return new Response(null, { + status: 500 + }); + } +}); + +interface GitHubUserResult { + id: number; + login: string; // username +} +``` diff --git a/docs/pages/guides/oauth/custom-providers.md b/docs/pages/guides/oauth/custom-providers.md new file mode 100644 index 000000000..78e2b13ea --- /dev/null +++ b/docs/pages/guides/oauth/custom-providers.md @@ -0,0 +1,96 @@ +--- +title: "Custom OAuth 2.0 providers" +--- + +# Custom OAuth 2.0 providers + +If you're looking to implement OAuth 2.0 for a provider that Arctic doesn't support, we recommend using Oslo's [`OAuth2Client`](https://oslo.js.org/reference/oauth2/OAuth2Client). + +## Initialization + +Pass your client ID and the provider's authorization and token endpoint to initialize the client. You can optionally pass the redirect URI. + +```ts +import { OAuth2Client } from "oslo/oauth2"; + +const authorizeEndpoint = "https://github.com/login/oauth/authorize"; +const tokenEndpoint = "https://github.com/login/oauth/access_token"; + +const oauth2Client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { + redirectURI: "http://localhost:3000/login/github/callback" +}); +``` + +## Create authorization URL + +Create an authorization URL with [`OAuth2Client.createAuthorizationURL()`](https://oslo.js.org/reference/oauth2/OAuth2Client/createAuthorizationURL). This optionally accepts a `state`, `codeVerifier` for PKCE flows, and `scope`. + +```ts +import { generateState, generateCodeVerifier } from "oslo/oauth2"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); // for PKCE flow + +const url = await oauth2Client.createAuthorizationURL({ + state, + scope: ["user:email"], + codeVerifier +}); +``` + +## Validate authorization callback + +Use [`OAuth2Client.validateAuthorizationCode()`](https://oslo.js.org/reference/oauth2/OAuth2Client/validateAuthorizationCode) to validate authorization codes. By default, it sends the client secret, if provided, using the HTTP basic auth scheme. To send it inside the request body (i.e. search params), set the `authenticateWith` option to `"request_body"`. + +This throws an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) on error responses. + +You can add additional response JSON fields by passing a type. + +```ts +try { + const { accessToken, refreshToken } = await oauth2Client.validateAuthorizationCode<{ + refreshToken: string; + }>(code, { + credentials: clientSecret, + authenticateWith: "request_body" + }); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // see https://www.rfc-editor.org/rfc/rfc6749#section-5.2 + const { request, message, description } = e; + } + // unknown error +} +``` + +For PKCE flow, pass the `codeVerifier` as an option. + +```ts +await oauth2Client.validateAuthorizationCode<{ + refreshToken: string; +}>(code, { + credentials: clientSecret, + codeVerifier +}); +``` + +## Refresh access tokens + +Use [`OAuth2Client.validateAuthorizationCode()`](https://oslo.js.org/reference/oauth2/OAuth2Client/refreshAccessToken) to refresh an access token. The API is similar to `validateAuthorizationCode()` and it also throws an `OAuth2RequestError` on error responses. + +```ts +try { + const { accessToken, refreshToken } = await oauth2Client.refreshAccessToken<{ + refreshToken: string; + }>(code, { + credentials: clientSecret, + authenticateWith: "request_body" + }); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // see https://www.rfc-editor.org/rfc/rfc6749#section-5.2 + const { request, message, description } = e; + } + // unknown error +} +``` diff --git a/docs/pages/guides/oauth/index.md b/docs/pages/guides/oauth/index.md new file mode 100644 index 000000000..18033dbe0 --- /dev/null +++ b/docs/pages/guides/oauth/index.md @@ -0,0 +1,15 @@ +--- +title: "OAuth" +--- + +# OAuth + +OAuth, or social sign in, is the easiest way to implement authentication as you won't have to worry about email verification, passwords, and two-factor authorization. + +For a step-by-step, framework-specific tutorial, see the [GitHub OAuth](/tutorials/github-oauth) tutorial. + +- [OAuth basics](/guides/oauth/basics) +- [Multiple OAuth providers](/guides/oauth/multiple-providers) +- [PKCE](/guides/oauth/pkce) +- [Account linking](/guides/oauth/account-linking) +- [Custom OAuth providers](/guides/oauth/custom-providers) diff --git a/docs/pages/guides/oauth/multiple-providers.md b/docs/pages/guides/oauth/multiple-providers.md new file mode 100644 index 000000000..085e4cd7e --- /dev/null +++ b/docs/pages/guides/oauth/multiple-providers.md @@ -0,0 +1,68 @@ +--- +title: "Multiple OAuth providers" +--- + +# Multiple OAuth providers + +## Database + +To support multiple OAuth sign-in methods, we can store the OAuth credentials in its own OAuth account table instead of the user table. Here, the combination of `provider_id` and `provider_user_id` should be unique (composite primary key). + +| column | type | description | +| ------------------ | -------- | -------------- | +| `provider_id` | `string` | OAuth provider | +| `provider_user_id` | `string` | OAuth user ID | +| `user_id` | `string` | user ID | + +Here's an example with SQLite: + +```sql +CREATE TABLE oauth_account { + provider_id TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + user_id TEXT NOT NULL, + PRIMARY KEY (provider_id, provider_user_id), + FOREIGN KEY (user_id) REFERENCES user(id) +} +``` + +We can then remove the `github_id` column etc from the user table. + +## Validating callback + +Instead of the user table, we can now use the OAuth account table to check if a user is already registered. If not, in a transaction, create the user and OAuth account. + +```ts +const tokens = await githubAuth.validateAuthorizationCode(code); +const githubUser = await githubAuth.getUser(tokens.accessToken); + +const existingAccount = await db + .table("oauth_account") + .where("provider_id", "=", "github") + .where("provider_user_id", "=", githubUser.id) + .get(); + +if (existingAccount) { + const session = await lucia.createSession(existingAccount.user_id, {}); + + // ... +} + +const userId = generateId(15); + +await db.beginTransaction(); +await db.table("user").insert({ + id: userId, + username: github.login +}); +await db.table("oauth_account").insert({ + provider_id "github", + provider_user_id: githubUser.id, + user_id: userId +}); +await db.commit(); + +const session = await lucia.createSession(userId, {}); + +// ... +``` diff --git a/docs/pages/guides/oauth/pkce.md b/docs/pages/guides/oauth/pkce.md new file mode 100644 index 000000000..a284bc8d9 --- /dev/null +++ b/docs/pages/guides/oauth/pkce.md @@ -0,0 +1,73 @@ +--- +title: "PKCE flow" +--- + +# PKCE flow + +## Create authorization URL + +Create a code verifier with `generateCodeVerifier()`, pass it to `createAuthorizationURL()`, and store it as a cookie alongside the state. + +```ts +import { twitterAuth } from "./auth.js"; +import { generateState, generateCodeVerifier } from "arctic"; +import { serializeCookie } from "oslo/cookie"; + +app.get("/login/twitter", async (): Promise => { + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = await twitterAuth.createAuthorizationURL(codeVerifier, state); + + const headers = new Headers(); + headers.append( + "Set-Cookie", + serializeCookie("twitter_oauth_state", state, { + httpOnly: true, + secure: env === "PRODUCTION", // set `Secure` flag in HTTPS + maxAge: 60 * 10, // 10 minutes + path: "/" + }) + ); + headers.append( + "Set-Cookie", + serializeCookie("code_verifier", codeVerifier, { + httpOnly: true, + secure: env === "PRODUCTION", + maxAge: 60 * 10, + path: "/" + }) + ); + + // ... +}); +``` + +## Validate callback + +Get the code verifier stored as a cookie and use it alongside the authorization code to validate the callback. + +```ts +import { twitterAuth, lucia } from "./auth.js"; +import { parseCookies } from "oslo/cookie"; + +app.get("/login/twitter/callback", async (request: Request): Promise => { + const cookies = parseCookies(request.headers.get("Cookie") ?? ""); + const stateCookie = cookies.get("twitter_oauth_state") ?? null; + const codeVerifier = cookies.get("code_verifier") ?? null; + + const url = new URL(request.url); + const state = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + + // verify state + if (!state || !stateCookie || !code || stateCookie !== state || !codeVerifier) { + return new Response(null, { + status: 400 + }); + } + + const tokens = await twitterAuth.validateAuthorizationCode(code, codeVerifier); + + // ... +}); +``` diff --git a/docs/pages/guides/passkeys.md b/docs/pages/guides/passkeys.md new file mode 100644 index 000000000..87d81c83d --- /dev/null +++ b/docs/pages/guides/passkeys.md @@ -0,0 +1,7 @@ +--- +title: "Passkeys" +--- + +# Passkeys + +_Work in progress_ diff --git a/docs/pages/guides/troubleshooting.md b/docs/pages/guides/troubleshooting.md new file mode 100644 index 000000000..9d9c32ae2 --- /dev/null +++ b/docs/pages/guides/troubleshooting.md @@ -0,0 +1,61 @@ +--- +title: "Troubleshooting" +--- + +# Troubleshooting + +Here are some common issues and how to resolve them. Feel free to ask for help in our Discord server. + +## `User` and `Session` are typed as `any` + +Make sure you've registered your types. Check that the `typeof lucia` is indeed an instance of `Lucia` (not a function that returns `Lucia`) and that there are no TS errors (including `@ts-ignore`) when declaring `Lucia`. `Register` must be an `interface`, not a `type`. + +```ts +import { Lucia } from "lucia"; + +const lucia = new Lucia(adapter, { + // no ts errors +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + } +} +``` + +## Session cookies are not set in `localhost` + +By default, session cookies have a `Secure` flag, which requires HTTPS. You can disable it for development with the `sessionCookie.attributes.secure` configuration. + +```ts +import { Lucia } from "lucia"; + +const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !devMode // disable when `devMode` is `true` + } + } +}); +``` + +## Can't validate POST requests + +Check your CSRF protection implementation. If you're using the code provided by the documentation, check the `Origin` and `Host` header. The hostname must match exactly. You can add additional domains to the array to allow more domains. + +```ts +import { verifyRequestOrigin } from "lucia"; + +verifyRequestOrigin(originHeader, [hostHeader, "api.example.com" /*...*/]); +``` + +## `crypto` is not defined + +You're likely using a runtime that doesn't support the Web Crypto API, such as Node.js 18 and below. Polyfill it by importing `webcrypto`. + +```ts +import { webcrypto } from "node:crypto"; + +globalThis.crypto = webcrypto as Crypto; +``` diff --git a/docs/pages/guides/validate-bearer-tokens.md b/docs/pages/guides/validate-bearer-tokens.md new file mode 100644 index 000000000..97f8753c1 --- /dev/null +++ b/docs/pages/guides/validate-bearer-tokens.md @@ -0,0 +1,29 @@ +--- +title: "Validate bearer tokens" +--- + +# Validate bearer tokens + +For apps that can't use cookies, store the session ID in localstorage and send it to the server as a bearer token. + +```ts +fetch("https://api.example.com", { + headers: { + Authorization: `Bearer ${sessionId}` + } +}); +``` + +In the server, you can use [`Lucia.readBearerToken()`](/reference/main/Lucia/readBearerToken) to get the session ID from the authorization header and validate the session with [`Lucia.validateSession()`](/reference/main/Lucia/validateSession). + +```ts +const authorizationHeader = request.headers.get("Authorization"); +const sessionId = lucia.readBearerToken(authorizationHeader ?? ""); +if (!sessionId) { + return new Response(null, { + status: 401 + }); +} + +const { session, user } = await lucia.validateSession(sessionId); +``` diff --git a/docs/pages/guides/validate-session-cookies/astro.md b/docs/pages/guides/validate-session-cookies/astro.md new file mode 100644 index 000000000..967da98a2 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/astro.md @@ -0,0 +1,86 @@ +--- +title: "Validate session cookies in Astro" +--- + +# Validate session cookies in Astro + +**CSRF protection must be implemented when using cookies and forms.** This can be easily done by comparing the `Origin` and `Host` header. + +We recommend creating a middleware to validate requests and store the current user inside `locals`. You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +// src/middleware.ts +import { lucia } from "./auth"; +import { verifyRequestOrigin } from "lucia"; +import { defineMiddleware } from "astro:middleware"; + +export const onRequest = defineMiddleware(async (context, next) => { + if (context.request.method !== "GET") { + const originHeader = request.headers.get("Origin"); + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = request.headers.get("Host"); + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return new Response(null, { + status: 403 + }); + } + } + + const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) { + context.locals.user = null; + context.locals.session = null; + return next(); + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + context.locals.user = user; + context.locals.user = session; + return next(); +}); +``` + +Make sure sure to type `App.Locals` as well. + +```ts +// src/env.d.ts + +/// +declare namespace App { + interface Locals { + user: import("lucia").User; + session: import("lucia").Session; + } +} +``` + +This will allow you to access the current user inside `.astro` pages and API routes. + +```ts +--- +if (!Astro.locals.user) { + return Astro.redirect("/login") +} +--- +``` + +```ts +import { lucia } from "$lib/server/auth"; + +export function GET(context: APIContext): Promise { + if (!context.locals.user) { + return new Response(null, { + status: 401 + }); + } + // ... +} +``` diff --git a/docs/pages/guides/validate-session-cookies/elysia.md b/docs/pages/guides/validate-session-cookies/elysia.md new file mode 100644 index 000000000..83aa12567 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/elysia.md @@ -0,0 +1,81 @@ +--- +title: "Validate session cookies in Elysia" +--- + +# Validate session cookies in Elysia + +**CSRF protection must be implemented when using cookies and forms.** This can be easily done by comparing the `Origin` and `Host` header. + +We recommend creating a middleware to validate requests and store the current user inside `Context` with `App.derive()`. You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +// src/middleware.ts +import { verifyRequestOrigin } from "lucia"; + +import type { User, Session } from "lucia"; + +const app = new Elysia().derive( + async ( + context + ): Promise<{ + user: User | null; + session: Session | null; + }> => { + // CSRF check + if (context.request.method !== "GET") { + const originHeader = context.request.headers.get("Origin"); + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = context.request.headers.get("Host"); + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return { + user: null, + session: null + }; + } + } + + // use headers instead of Cookie API to prevent type coercion + const cookieHeader = context.request.headers.get("Cookie") ?? ""; + const sessionId = lucia.readSessionCookie(cookieHeader); + if (!sessionId) { + return { + user: null, + session: null + }; + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookie[sessionCookie.name].set({ + value: sessionCookie.value, + ...sessionCookie.attributes + }); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + context.cookie[sessionCookie.name].set({ + value: sessionCookie.value, + ...sessionCookie.attributes + }); + } + return { + user, + session + }; + } +); +``` + +This will allow you to access the current user with `Context.user`. + +```ts +app.get("/", async (context) => { + if (!context.user) { + return new Response(null, { + status: 401 + }); + } + // ... +}); +``` diff --git a/docs/pages/guides/validate-session-cookies/express.md b/docs/pages/guides/validate-session-cookies/express.md new file mode 100644 index 000000000..85b736942 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/express.md @@ -0,0 +1,69 @@ +--- +title: "Validate session cookies in Express" +--- + +# Validate session cookies in Express + +**CSRF protection must be implemented when using cookies and forms.** This can be easily done by comparing the `Origin` and `Host` header. + +We recommend creating 2 middleware for CSRF protection and validating requests. You can get the cookie with `Lucia.readSessionCookie()` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +// src/middleware.ts +import { lucia } from "./auth.js"; +import { verifyRequestOrigin } from "lucia"; + +import type { User } from "lucia"; + +app.use((req, res, next) => { + if (req.method === "GET") { + return next(); + } + const originHeader = req.headers.origin ?? null; + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = req.headers.host ?? null; + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return res.status(403).end(); + } +}); + +app.use((req, res, next) => { + const sessionId = lucia.readSessionCookie(req.headers.cookie ?? ""); + if (!sessionId) { + res.locals.user = null; + res.locals.session = null; + return next(); + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + res.appendHeader("Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + } + if (!session) { + res.appendHeader("Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + } + res.locals.user = user; + res.locals.session = session; + return next(); +}); + +declare global { + namespace Express { + interface Locals { + user: User | null; + session: Session | null; + } + } +} +``` + +This will allow you to access the current user with `res.locals`. + +```ts +app.get("/", (req, res) => { + if (!res.locals.user) { + return res.status(403).end(); + } + // ... +}); +``` diff --git a/docs/pages/guides/validate-session-cookies/hono.md b/docs/pages/guides/validate-session-cookies/hono.md new file mode 100644 index 000000000..809103ae4 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/hono.md @@ -0,0 +1,74 @@ +--- +title: "Validate session cookies in Hono" +--- + +# Validate session cookies in Hono + +**CSRF protection must be implemented when using cookies and forms.** This can be easily done by comparing the `Origin` and `Host` header. + +We recommend creating 2 middleware for CSRF protection and validating requests. You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +// src/middleware.ts +import { lucia } from "./auth.js"; +import { verifyRequestOrigin } from "lucia"; +import { getCookie } from "hono/cookie"; + +import type { User } from "lucia"; + +const app = new Hono<{ + Variables: { + user: User | null; + }; +}>(); + +app.use("*", (c, next) => { + // CSRF middleware + if (c.req.method === "GET") { + return next(); + } + const originHeader = c.req.headers.get("Origin"); + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = c.req.headers.get("Host"); + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return c.body(null, 403); + } + return next(); +}); + +app.use("*", (c, next) => { + const sessionId = getCookie(lucia.sessionCookieName) ?? null; + if (!sessionId) { + c.set("user", null); + c.set("session", null); + return next(); + } + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + // use `header()` instead of `setCookie()` to avoid TS errors + c.header("Set-Cookie", lucia.createSessionCookie(session.id).serialize(), { + append: true + }); + } + if (!session) { + c.header("Set-Cookie", lucia.createBlankSessionCookie().serialize(), { + append: true + }); + } + c.set("user", user); + c.set("session", session); + return next(); +}); +``` + +This will allow you to access the current user with `Context.get()`. + +```ts +app.get("/", async (c) => { + const user = c.get("user"); + if (!user) { + return c.body(null, 401); + } + // ... +}); +``` diff --git a/docs/pages/guides/validate-session-cookies/index.md b/docs/pages/guides/validate-session-cookies/index.md new file mode 100644 index 000000000..db6bdac92 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/index.md @@ -0,0 +1,69 @@ +--- +title: "Validate session cookies" +--- + +# Validate session cookies + +This guide is also available for: + +- [Astro](/guides/validate-session-cookies/astro) +- [Elysia](/guides/validate-session-cookies/elysia) +- [Express](/guides/validate-session-cookies/express) +- [Hono](/guides/validate-session-cookies/hono) +- [Next.js App router](/guides/validate-session-cookies/nextjs-app) +- [Next.js Pages router](/guides/validate-session-cookies/nextjs-pages) +- [Nuxt](/guides/validate-session-cookies/nuxt) +- [SolidStart](/guides/validate-session-cookies/solidstart) +- [SvelteKit](/guides/validate-session-cookies/sveltekit) + +**CSRF protection must be implemented when using cookies and forms.** This can be easily done by comparing the `Origin` and `Host` header. + +For non-GET requests, check the request origin. You can use `readSessionCookie()` to get the session cookie from a HTTP `Cookie` header, and validate it with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +import { verifyRequestOrigin } from "lucia"; + +// Only required in non-GET requests (POST, PUT, DELETE, PATCH, etc) +const originHeader = request.headers.get("Origin"); +// NOTE: You may need to use `X-Forwarded-Host` instead +const hostHeader = request.headers.get("Host"); +if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return new Response(null, { + status: 403 + }); +} + +const cookieHeader = request.headers.get("Cookie"); +const sessionId = lucia.readSessionCookie(cookieHeader ?? ""); +if (!sessionId) { + return new Response(null, { + status: 401 + }); +} + +const headers = new Headers(); + +const { session, user } = await lucia.validateSession(sessionId); +if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + headers.append("Set-Cookie", sessionCookie.serialize()); +} + +if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + headers.append("Set-Cookie", sessionCookie.serialize()); +} +``` + +If your framework provides utilities for cookies, you can get the session cookie name with `Lucia.sessionCookieName`. + +```ts +const sessionId = getCookie(lucia.sessionCookieName); +``` + +When setting cookies you can get the cookies name, value, and attributes from the `Cookie` object. + +```ts +const sessionCookie = lucia.createSessionCookie(sessionId); +setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); +``` diff --git a/docs/pages/guides/validate-session-cookies/nextjs-app.md b/docs/pages/guides/validate-session-cookies/nextjs-app.md new file mode 100644 index 000000000..472678fd5 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/nextjs-app.md @@ -0,0 +1,99 @@ +--- +title: "Validate session cookies in Next.js App router" +--- + +# Validate session cookies in Next.js App router + +You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. We have to wrap it inside a try/catch block since Next.js doesn't allow you to set cookies when rendering the page. This is a known issue but Vercel has yet to acknowledge or address the issue. + +We recommend wrapping the function with [`cache()`](https://nextjs.org/docs/app/building-your-application/caching#react-cache-function) so it can be called multiple times without incurring multiple database calls. + +**CSRF protection is only handled by Next.js when using form actions.** If you're using API routes, it must be implemented by yourself (see below). + +```ts +import { lucia } from "@/utils/auth"; +import { cookies } from "next/headers"; + +const getUser = cache(async () => { + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) return null; + const { user, session } = await lucia.validateSession(sessionId); + try { + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + } catch { + // Next.js throws error when attempting to set cookies when rendering page + } + return user; +}); +``` + +Set `sessionCookie.expires` option to `false` so the session cookie persists for a longer period. + +```ts +import { Lucia } from "lucia"; + +const lucia = new Lucia(adapter, { + sessionCookie: { + expires: false, + attributes: { + // set to `true` when using HTTPS + secure: process.env.NODE_ENV === "production" + } + } +}); +``` + +You can now use `getUser()` in server components, including server actions. + +```ts +// app/api/page.tsx +import { redirect } from "next/navigation"; + +async function Page() { + const user = await getUser(); + if (!user) { + redirect("/login"); + } + // ... + async function action() { + "use server"; + const user = await getUser(); + if (!user) { + redirect("/login"); + } + // ... + } + // ... +} +``` + +For API routes, since Next.js does not implement CSRF protection for API routes, **CSRF protection must be implemented when dealing with forms** if you're dealing with forms. This can be easily done by comparing the `Origin` and `Host` header. We recommend using middleware for this. + +```ts +// middleware.ts +import { verifyRequestOrigin } from "lucia"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest): Promise { + if (request.method === "GET") { + return NextResponse.next(); + } + const originHeader = request.headers.get("Origin"); + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = request.headers.get("Host"); + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return new NextResponse(null, { + status: 403 + }); + } + return NextResponse.next(); +} +``` diff --git a/docs/pages/guides/validate-session-cookies/nextjs-pages.md b/docs/pages/guides/validate-session-cookies/nextjs-pages.md new file mode 100644 index 000000000..5d5af47b4 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/nextjs-pages.md @@ -0,0 +1,84 @@ +--- +title: "Validate session cookies in Next.js Pages router" +--- + +# Validate session cookies in Next.js Pages router + +When working with cookies, **CSRF protection must be implemented**. This can be easily done by comparing the `Origin` and `Host` header. While CSRF protection is strictly not necessary when using JSON requests, it should be implemented in Next.js as it doesn't differentiate between JSON and form submissions. We recommend using middleware for this. + +```ts +// middleware.ts +import { verifyRequestOrigin } from "lucia"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest): Promise { + if (request.method === "GET") { + return NextResponse.next(); + } + const originHeader = request.headers.get("Origin"); + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = request.headers.get("Host"); + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return new NextResponse(null, { + status: 403 + }); + } + return NextResponse.next(); +} +``` + +You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +import { verifyRequestOrigin } from "lucia"; + +import type { NextApiRequest, NextApiResponse } from "next"; + +async function validateRequest(req: NextApiRequest, res: NextApiResponse): Promise { + const sessionId = req.cookies.get(lucia.sessionCookieName); + if (!sessionId) { + return null; + } + const { session, user } = await lucia.validateSession(sessionId); + if (!session) { + res.setHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize()); + } + if (session && session.fresh) { + res.setHeader("Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + } + return user; +} +``` + +You can now get the current user inside `getServerSideProps()` by passing the request and response. + +```ts +import type { GetServerSidePropsContext } from "next"; + +export function getServerSideProps(context: GetServerSidePropsContext) { + const user = await validateRequest(context.req, context.res); + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false + } + }; + } + // ... +} +``` + +```ts +import type { NextApiRequest, NextApiResponse } from "next"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const user = await validateRequest(req, res); + if (!user) { + return res.status(401).end(); + } +} + +export default handler; +``` diff --git a/docs/pages/guides/validate-session-cookies/nuxt.md b/docs/pages/guides/validate-session-cookies/nuxt.md new file mode 100644 index 000000000..9547bd803 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/nuxt.md @@ -0,0 +1,64 @@ +--- +title: "Validate session cookies in Nuxt" +--- + +# Validate session cookies in Nuxt + +**CSRF protection must be implemented when using cookies and forms.** This can be easily done by comparing the `Origin` and `Host` header. + +We recommend creating a middleware to validate requests and store the current user inside `context`. You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +// server/middleware/auth.ts +import { verifyRequestOrigin } from "lucia"; + +import type { Session, User } from "lucia"; + +export default defineEventHandler(async (event) => { + if (event.method !== "GET") { + const originHeader = getHeader(event, "Origin") ?? null; + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = getHeader(event, "Host") ?? null; + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return event.node.res.writeHead(403).end(); + } + } + + const sessionId = getCookie(event, lucia.sessionCookieName) ?? null; + if (!sessionId) { + event.context.session = null; + event.context.user = null; + return; + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + appendResponseHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + } + if (!session) { + appendResponseHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize()); + } + event.context.session = session; + event.context.user = user; +}); + +declare module "h3" { + interface H3EventContext { + user: User | null; + session: Session | null; + } +} +``` + +This will allow you to access the current user inside API routes. + +```ts +export default defineEventHandler(async (event) => { + if (!event.context.user) { + throw createError({ + statusCode: 401 + }); + } + // ... +}); +``` diff --git a/docs/pages/guides/validate-session-cookies/solidstart.md b/docs/pages/guides/validate-session-cookies/solidstart.md new file mode 100644 index 000000000..f59be02a7 --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/solidstart.md @@ -0,0 +1,70 @@ +--- +title: "Validate session cookies in SolidStart" +--- + +# Validate session cookies in SolidStart + +**CSRF protection must be implemented when using cookies and forms.** This can be easily done by comparing the `Origin` and `Host` header. + +We recommend creating a middleware to validate requests and store the current user inside `context`. You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +// src/middleware.ts +import { createMiddleware, appendHeader, getCookie, getHeader } from "@solidjs/start/server"; +import { Session, User, verifyRequestOrigin } from "lucia"; +import { lucia } from "./lib/auth"; + +export default defineEventHandler((event) => { + if (context.request.method !== "GET") { + const originHeader = getHeader(event, "Origin") ?? null; + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = getHeader(event, "Host") ?? null; + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return event.node.res.writeHead(403).end(); + } + } + + const sessionId = getCookie(event, lucia.sessionCookieName) ?? null; + if (!sessionId) { + event.context.user = null; + return; + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + appendResponseHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + } + if (!session) { + appendResponseHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize()); + } + event.context.user = user; +}); + +declare module "vinxi/server" { + interface H3EventContext { + user: User | null; + session: Session | null; + } +} +``` + +Make sure to declare the middleware module in the config. + +```ts +// vite.config.ts +import { defineConfig } from "@solidjs/start/config"; + +export default defineConfig({ + start: { + middleware: "./src/middleware.ts" + } +}); +``` + +This will allow you to access the current user inside server contexts. + +```ts +import { getRequestEvent } from "solid-js/web"; + +const user = getRequestEvent()!.context.user; +``` diff --git a/docs/pages/guides/validate-session-cookies/sveltekit.md b/docs/pages/guides/validate-session-cookies/sveltekit.md new file mode 100644 index 000000000..2fb7c157e --- /dev/null +++ b/docs/pages/guides/validate-session-cookies/sveltekit.md @@ -0,0 +1,90 @@ +--- +title: "Validate session cookies in SvelteKit" +--- + +# Validate session cookies in SvelteKit + +SvelteKit has basic CSRF protection by default. We recommend creating a handle hook to validate requests and store the current user inside `locals`. You can get the cookie name with `Lucia.sessionCookieName` and validate the session cookie with `Lucia.validateSession()`. Make sure to delete the session cookie if it's invalid and create a new session cookie when the expiration gets extended, which is indicated by `Session.fresh`. + +```ts +// src/hooks.server.ts +import { lucia } from "$lib/server/auth"; + +import type { Handle } from "@sveltejs/kit"; + +export const handle: Handle = async ({ event, resolve }) => { + const sessionId = event.cookies.get(lucia.sessionCookieName); + if (!sessionId) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + event.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + event.locals.user = user; + event.locals.session = session; + return resolve(event); +}; +``` + +Make sure sure to type `App.Locals` as well. + +```ts +// src/app.d.ts +declare global { + namespace App { + interface Locals { + user: import("lucia").User; + session: import("lucia").Session; + } + } +} +``` + +This will allow you to access the current user inside server load functions, actions, and API routes. + +```ts +// +page.server.ts +import { lucia } from "$lib/server/auth"; +import { fail, redirect } from "@sveltejs/kit"; + +import type { Actions, PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) { + redirect("/login"); + } + // ... +}; + +export const actions: Actions = { + default: async (event) => { + if (!event.locals.user) { + throw fail(401); + } + // ... + } +}; +``` + +```ts +// +server.ts +import { lucia } from "$lib/server/auth"; + +export function GET(event: RequestEvent): Promise { + if (!event.locals.user) { + return new Response(null, { + status: 401 + }); + } + // ... +} +``` diff --git a/docs/pages/index.md b/docs/pages/index.md new file mode 100644 index 000000000..875380023 --- /dev/null +++ b/docs/pages/index.md @@ -0,0 +1,25 @@ +--- +title: "Lucia documentation" +--- + +# Lucia documentation + +Lucia is an auth library for your server that abstracts away the complexity of handling sessions. It works alongside your database to provide an API that's easy to use, understand, and extend. [Get started →](/getting-started) + +- No more endless configuration and callbacks +- Fully typed +- Works in any runtime - Node.js, Bun, Deno, Cloudflare Workers +- Extensive database support out of the box + +```ts +import { Lucia } from "lucia"; + +const lucia = new Lucia(new Adapter(db)); + +const session = await lucia.createSession(userId, {}); +await lucia.validateSession(session.id); +``` + +Lucia is an open source library released under the MIT license, with the help of [100+ contributors](https://github.com/lucia-auth/lucia/graphs/contributors)! Join us on [Discord](https://discord.com/invite/PwrK3kpVR3) if you have any questions. + +> In case you missed the news, we've recently released Lucia 3.0! [Read the announcement](). diff --git a/docs/pages/reference/main/Adapter.md b/docs/pages/reference/main/Adapter.md new file mode 100644 index 000000000..654a9a53c --- /dev/null +++ b/docs/pages/reference/main/Adapter.md @@ -0,0 +1,35 @@ +--- +title: "Adapter" +--- + +# `Adapter` + +Represents a database adapter. + +## Definition + +```ts +//$ DatabaseSession=/reference/main/DatabaseSession +//$ DatabaseUser=/reference/main/DatabaseUser +interface Adapter { + deleteExpiredSessions(): Promise; + deleteSession(sessionId: string): Promise; + deleteUserSessions(userId: string): Promise; + getSessionAndUser( + sessionId: string + ): Promise<[session: $$DatabaseSession | null, user: $$DatabaseUser | null]>; + getUserSessions(userId: string): Promise<$$DatabaseSession[]>; + setSession(session: $$DatabaseSession): Promise; + updateSessionExpiration(sessionId: string, expiresAt: Date): Promise; +} +``` + +### Methods + +- `deleteExpiredSessions`: Deletes all sessions where `expires_at` is equal to or less than current timestamp (machine time) +- `deleteSession()`: Deletes the session +- `deleteUserSessions()`: Deletes all sessions linked to the user +- `getSessionAndUser()`: Returns the session and the user linked to the session +- `getUserSessions()`: Returns all sessions linked to a user +- `setSession()`: Inserts the session +- `updateSessionExpiration()`: Updates the `expires_at` field of the session diff --git a/docs/pages/reference/main/Cookie.md b/docs/pages/reference/main/Cookie.md new file mode 100644 index 000000000..690b9b75f --- /dev/null +++ b/docs/pages/reference/main/Cookie.md @@ -0,0 +1,7 @@ +--- +title: "Cookie" +--- + +# `Cookie` + +See [`Cookie`](https://oslo.js.org/reference/cookie/Cookie) from `oslo/cookie`. diff --git a/docs/pages/reference/main/DatabaseSession.md b/docs/pages/reference/main/DatabaseSession.md new file mode 100644 index 000000000..84da0a24b --- /dev/null +++ b/docs/pages/reference/main/DatabaseSession.md @@ -0,0 +1,26 @@ +--- +title: "DatabaseSession" +--- + +# `DatabaseSession` + +Represents a session stored in a database. + +## Definition + +```ts +//$ DatabaseSessionAttributes=/reference/main/DatabaseSessionAttributes +interface DatabaseSession { + id: string; + userId: string; + expiresAt: Date; + attributes: $$DatabaseSessionAttributes; +} +``` + +### Properties + +- `id` +- `userId` +- `expiresAt` +- `attributes` diff --git a/docs/pages/reference/main/DatabaseSessionAttributes.md b/docs/pages/reference/main/DatabaseSessionAttributes.md new file mode 100644 index 000000000..b18e40bb8 --- /dev/null +++ b/docs/pages/reference/main/DatabaseSessionAttributes.md @@ -0,0 +1,13 @@ +--- +title: "DatabaseSessionAttributes" +--- + +# `DatabaseSessionAttributes` + +Additional data stored in the session table. + +## Definition + +```ts +interface DatabaseSessionAttributes {} +``` diff --git a/docs/pages/reference/main/DatabaseUser.md b/docs/pages/reference/main/DatabaseUser.md new file mode 100644 index 000000000..bddef69b9 --- /dev/null +++ b/docs/pages/reference/main/DatabaseUser.md @@ -0,0 +1,24 @@ +--- +title: "DatabaseUser" +--- + +# `DatabaseUser` + +Represents a session stored in a database. + +## Definition + +```ts +//$ DatabaseUserAttributes=/reference/main/DatabaseUserAttributes +interface DatabaseUser { + id: string; + attributes: DatabaseUserAttributes; +} +``` + +### Properties + +- `id` +- `userId` +- `expiresAt` +- `attributes` diff --git a/docs/pages/reference/main/DatabaseUserAttributes.md b/docs/pages/reference/main/DatabaseUserAttributes.md new file mode 100644 index 000000000..e7a31a962 --- /dev/null +++ b/docs/pages/reference/main/DatabaseUserAttributes.md @@ -0,0 +1,13 @@ +--- +title: "DatabaseUserAttributes" +--- + +# `DatabaseUserAttributes` + +Additional data stored in the user table. + +## Definition + +```ts +interface DatabaseUserAttributes {} +``` diff --git a/docs/pages/reference/main/LegacyScrypt/hash.md b/docs/pages/reference/main/LegacyScrypt/hash.md new file mode 100644 index 000000000..81a0852f0 --- /dev/null +++ b/docs/pages/reference/main/LegacyScrypt/hash.md @@ -0,0 +1,23 @@ +--- +title: "LegacyScrypt.hash()" +--- + +# `LegacyScrypt.hash()` + +Method of [`LegacyScrypt`](/reference/main/LegacyScrypt). Hashes the provided password with scrypt. + +## Definition + +```ts +function hash(password: string): Promise; +``` + +### Parameters + +- `password` + +## Example + +```ts +const hash = await scrypt.hash(password); +``` diff --git a/docs/pages/reference/main/LegacyScrypt/index.md b/docs/pages/reference/main/LegacyScrypt/index.md new file mode 100644 index 000000000..2bedc39a6 --- /dev/null +++ b/docs/pages/reference/main/LegacyScrypt/index.md @@ -0,0 +1,38 @@ +--- +title: "LegacyScrypt" +--- + +# `LegacyScrypt` + +A pure JS implementation of Scrypt for projects that used Lucia v1/v2. For new projects, use [`Scrypt`](/reference/main/Scrypt). + +The output hash is a combination of the scrypt hash and the 32-bytes salt, in the format of `:`. + +## Constructor + +```ts +function constructor(options?: { N?: number; r?: number; p?: number; dkLen?: number }): this; +``` + +### Parameters + +- `options` + - `N` (default: `16384`) + - `r` (default: `16`) + - `p` (default: `1`) + - `dkLen` (default: `64`) + +## Methods + +- [`hash()`](/reference/main/LegacyScrypt/hash) +- [`verify()`](/reference/main/LegacyScrypt/verify) + +## Example + +```ts +import { LegacyScrypt } from "lucia"; + +const scrypt = new LegacyScrypt(); +const hash = await scrypt.hash(password); +const validPassword = await scrypt.verify(hash, password); +``` diff --git a/docs/pages/reference/main/LegacyScrypt/verify.md b/docs/pages/reference/main/LegacyScrypt/verify.md new file mode 100644 index 000000000..540868731 --- /dev/null +++ b/docs/pages/reference/main/LegacyScrypt/verify.md @@ -0,0 +1,24 @@ +--- +title: "LegacyScrypt.verify()" +--- + +# `LegacyScrypt.verify()` + +Method of [`LegacyScrypt`](/reference/main/LegacyScrypt). Verifies the password with the hash using scrypt. + +## Definition + +```ts +function verify(hash: string, password: string): Promise; +``` + +### Parameters + +- `hash` +- `password` + +## Example + +```ts +const validPassword = await scrypt.verify(hash, password); +``` diff --git a/docs/pages/reference/main/Lucia/createBlankSessionCookie.md b/docs/pages/reference/main/Lucia/createBlankSessionCookie.md new file mode 100644 index 000000000..d97eb491c --- /dev/null +++ b/docs/pages/reference/main/Lucia/createBlankSessionCookie.md @@ -0,0 +1,14 @@ +--- +title: "Lucia.createBlankSessionCookie()" +--- + +# `Lucia.createBlankSessionCookie()` + +Method of [`Lucia`](/reference/main/Lucia). Creates a new cookie with a blank value that expires immediately to delete the existing session cookie. + +## Definition + +```ts +//$ Cookie=/reference/cookie/Cookie +function createBlankSessionCookie(): $$Cookie; +``` diff --git a/docs/pages/reference/main/Lucia/createSession.md b/docs/pages/reference/main/Lucia/createSession.md new file mode 100644 index 000000000..28f6b20a4 --- /dev/null +++ b/docs/pages/reference/main/Lucia/createSession.md @@ -0,0 +1,20 @@ +--- +title: "Lucia.createSession()" +--- + +# `Lucia.createSession()` + +Method of [`Lucia`](/reference/main/Lucia). Creates a new session. + +## Definition + +```ts +//$ DatabaseSessionAttributes=/reference/main/DatabaseSessionAttributes +//$ Session=/reference/main/Session +function createSession(userId: string, attributes: $$DatabaseSessionAttributes): Promise<$$Session>; +``` + +### Parameters + +- `userId` +- `attributes`: Database session attributes diff --git a/docs/pages/reference/main/Lucia/createSessionCookie.md b/docs/pages/reference/main/Lucia/createSessionCookie.md new file mode 100644 index 000000000..085cbd38e --- /dev/null +++ b/docs/pages/reference/main/Lucia/createSessionCookie.md @@ -0,0 +1,14 @@ +--- +title: "Lucia.createSessionCookie()" +--- + +# `Lucia.createSessionCookie()` + +Method of [`Lucia`](/reference/main/Lucia). Creates a new session cookie. + +## Definition + +```ts +//$ Cookie=/reference/cookie/Cookie +function createSessionCookie(sessionId: string): $$Cookie; +``` diff --git a/docs/pages/reference/main/Lucia/deleteExpiredSessions.md b/docs/pages/reference/main/Lucia/deleteExpiredSessions.md new file mode 100644 index 000000000..5ecfc8529 --- /dev/null +++ b/docs/pages/reference/main/Lucia/deleteExpiredSessions.md @@ -0,0 +1,13 @@ +--- +title: "Lucia.deleteExpiredSessions()" +--- + +# `Lucia.deleteExpiredSessions()` + +Method of [`Lucia`](/reference/main/Lucia). Deletes all expired sessions. + +## Definition + +```ts +function deleteExpiredSessions(): Promise; +``` diff --git a/docs/pages/reference/main/Lucia/getUserSessions.md b/docs/pages/reference/main/Lucia/getUserSessions.md new file mode 100644 index 000000000..43d12b9e4 --- /dev/null +++ b/docs/pages/reference/main/Lucia/getUserSessions.md @@ -0,0 +1,18 @@ +--- +title: "Lucia.getUserSessions()" +--- + +# `Lucia.getUserSessions()` + +Method of [`Lucia`](/reference/main/Lucia). Gets all sessions of a user. + +## Definition + +```ts +//$ Session=/reference/main/Session +function getUserSessions(userId: string): Promise; +``` + +### Parameters + +- `userId` diff --git a/docs/pages/reference/main/Lucia/index.md b/docs/pages/reference/main/Lucia/index.md new file mode 100644 index 000000000..9084ef7fc --- /dev/null +++ b/docs/pages/reference/main/Lucia/index.md @@ -0,0 +1,68 @@ +--- +title: "Lucia" +--- + +# `Lucia` + +## Constructor + +```ts +//$ Adapter=/reference/main/Adapter +//$ TimeSpan=/reference/main/TimeSpan +//$ DatabaseSessionAttributes=/reference/main/DatabaseSessionAttributes +//$ DatabaseUserAttributes=/reference/main/DatabaseUserAttributes +function constructor< + _SessionAttributes extends {} = Record, + _UserAttributes extends {} = Record +>( + adapter: $$Adapter, + options?: { + sessionExpiresIn?: $$TimeSpan; + sessionCookie?: { + name?: string; + expires?: boolean; + attributes: { + sameSite?: "lax" | "strict"; + domain?: string; + path?: string; + secure?: boolean; + }; + }; + getSessionAttributes?: ( + databaseSessionAttributes: $$DatabaseSessionAttributes + ) => _SessionAttributes; + getUserAttributes?: (databaseUserAttributes: $$DatabaseUserAttributes) => _UserAttributes; + } +): this; +``` + +### Parameters + +- `adapter`: Database adapter +- `options`: + - `sessionExpiresIn`: How long a session lasts for inactive users + - `sessionCookie`: Session cookie options + - `name`: Cookie name (default: `auth_session`) + - `expires`: Set to `false` for cookies to persist indefinitely (default: `true`) + - `attributes`: Cookie attributes + - `sameSite` + - `domain` + - `path` + - `secure` + - `getSessionAttributes()`: Transforms database session attributes and the returned object is added to the [`Session`](/reference/main/Session) object + - `getUserAttributes()`: Transforms database user attributes and the returned object is added to the [`User`](/reference/main/User) object + +## Method + +- [`createBlankSessionCookie()`](/reference/main/Lucia/createBlankSessionCookie) +- [`createSession()`](/reference/main/Lucia/createSession) +- [`createSessionCookie()`](/reference/main/Lucia/createSessionCookie) +- [`deleteExpiredSessions()`](/reference/main/Lucia/deleteExpiredSessions) +- [`getUserSessions()`](/reference/main/Lucia/getUserSessions) +- [`handleRequest()`](/reference/main/Lucia/handleRequest) +- [`createSessionCookie()`](/reference/main/Lucia/createSessionCookie) +- [`invalidateSession()`](/reference/main/Lucia/invalidateSession) +- [`invalidateUserSessions()`](/reference/main/Lucia/invalidateUserSessions) +- [`readBearerToken()`](/reference/main/Lucia/readBearerToken) +- [`readSessionCookie()`](/reference/main/Lucia/readSessionCookie) +- [`validateSession()`](/reference/main/Lucia/validateSession) diff --git a/docs/pages/reference/main/Lucia/invalidateSession.md b/docs/pages/reference/main/Lucia/invalidateSession.md new file mode 100644 index 000000000..8b53942a4 --- /dev/null +++ b/docs/pages/reference/main/Lucia/invalidateSession.md @@ -0,0 +1,17 @@ +--- +title: "Lucia.invalidateSession()" +--- + +# `Lucia.invalidateSession()` + +Method of [`Lucia`](/reference/main/Lucia). Invalidates a session. + +## Definition + +```ts +function invalidateSession(sessionId: string): Promise; +``` + +### Parameters + +- `sessionId` diff --git a/docs/pages/reference/main/Lucia/invalidateUserSessions.md b/docs/pages/reference/main/Lucia/invalidateUserSessions.md new file mode 100644 index 000000000..1032a01b3 --- /dev/null +++ b/docs/pages/reference/main/Lucia/invalidateUserSessions.md @@ -0,0 +1,17 @@ +--- +title: "Lucia.invalidateUserSessions()" +--- + +# `Lucia.invalidateUserSessions()` + +Method of [`Lucia`](/reference/main/Lucia). Invalidates all sessions of a user. + +## Definition + +```ts +function invalidateUserSessions(userId: string): Promise; +``` + +### Parameters + +- `userId` diff --git a/docs/pages/reference/main/Lucia/readBearerToken.md b/docs/pages/reference/main/Lucia/readBearerToken.md new file mode 100644 index 000000000..733838bbf --- /dev/null +++ b/docs/pages/reference/main/Lucia/readBearerToken.md @@ -0,0 +1,17 @@ +--- +title: "Lucia.readBearerToken()" +--- + +# `Lucia.readBearerToken()` + +Method of [`Lucia`](/reference/main/Lucia). Reads the bearer token from the ` Authorization`` header. Returns `null` if the token doesn't exist. + +## Definition + +```ts +function readBearerToken(authorizationHeader: string): string | null; +``` + +### Parameters + +- `authorizationHeader`: HTTP `Authorization` header diff --git a/docs/pages/reference/main/Lucia/readSessionCookie.md b/docs/pages/reference/main/Lucia/readSessionCookie.md new file mode 100644 index 000000000..86d0819c9 --- /dev/null +++ b/docs/pages/reference/main/Lucia/readSessionCookie.md @@ -0,0 +1,17 @@ +--- +title: "Lucia.readSessionCookie()" +--- + +# `Lucia.readSessionCookie()` + +Method of [`Lucia`](/reference/main/Lucia). Reads the session cookie from the `Cookie` header. Returns `null` if the cookie doesn't exist. + +## Definition + +```ts +function readSessionCookie(cookieHeader: string): string | null; +``` + +### Parameters + +- `cookieHeader`: HTTP `Cookie` header diff --git a/docs/pages/reference/main/Lucia/validateSession.md b/docs/pages/reference/main/Lucia/validateSession.md new file mode 100644 index 000000000..245a0f307 --- /dev/null +++ b/docs/pages/reference/main/Lucia/validateSession.md @@ -0,0 +1,21 @@ +--- +title: "Lucia.validateSession()" +--- + +# `Lucia.validateSession()` + +Method of [`Lucia`](/reference/main/Lucia). Validates a session with the session ID. Extends the session expiration if in idle state. + +## Definition + +```ts +//$ User=/reference/main/User +//$ Session=/reference/main/Session +function validateSession( + sessionId: string +): Promise<{ user: $$User; session: $$Session } | { user: null; session: null }>; +``` + +### Parameters + +- `sessionId` diff --git a/docs/pages/reference/main/Scrypt/hash.md b/docs/pages/reference/main/Scrypt/hash.md new file mode 100644 index 000000000..5fb91796d --- /dev/null +++ b/docs/pages/reference/main/Scrypt/hash.md @@ -0,0 +1,23 @@ +--- +title: "Scrypt.hash()" +--- + +# `Scrypt.hash()` + +Method of [`Scrypt`](/reference/main/Scrypt). Hashes the provided password with scrypt. + +## Definition + +```ts +function hash(password: string): Promise; +``` + +### Parameters + +- `password` + +## Example + +```ts +const hash = await scrypt.hash(password); +``` diff --git a/docs/pages/reference/main/Scrypt/index.md b/docs/pages/reference/main/Scrypt/index.md new file mode 100644 index 000000000..a00120442 --- /dev/null +++ b/docs/pages/reference/main/Scrypt/index.md @@ -0,0 +1,40 @@ +--- +title: "Scrypt" +--- + +# `Scrypt` + +A pure JS implementation of Scrypt. Provides methods for hashing passwords and verifying hashes with [scrypt](https://datatracker.ietf.org/doc/html/rfc7914). By default, the configuration is set to [the recommended values](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html). + +The output hash is a combination of the scrypt hash and the 32-bytes salt, in the format of `:`. + +Since it's pure JS, it is anywhere from 2~3 times slower than implementations based on native code. See Oslo's [`Scrypt`](https://oslo.js.org/reference/password/Scrypt) for a faster API (Node.js-only). + +## Constructor + +```ts +function constructor(options?: { N?: number; r?: number; p?: number; dkLen?: number }): this; +``` + +### Parameters + +- `options` + - `N` (default: `16384`) + - `r` (default: `16`) + - `p` (default: `1`) + - `dkLen` (default: `64`) + +## Methods + +- [`hash()`](ref:password/Argon2id) +- [`verify()`](ref:password/Argon2id) + +## Example + +```ts +import { Scrypt } from "lucia"; + +const scrypt = new Scrypt(); +const hash = await scrypt.hash(password); +const validPassword = await scrypt.verify(hash, password); +``` diff --git a/docs/pages/reference/main/Scrypt/verify.md b/docs/pages/reference/main/Scrypt/verify.md new file mode 100644 index 000000000..05eb8d6d8 --- /dev/null +++ b/docs/pages/reference/main/Scrypt/verify.md @@ -0,0 +1,24 @@ +--- +title: "Scrypt.verify()" +--- + +# `Scrypt.verify()` + +Method of [`Scrypt`](/reference/main/Scrypt). Verifies the password with the hash using scrypt. + +## Definition + +```ts +function verify(hash: string, password: string): Promise; +``` + +### Parameters + +- `hash` +- `password` + +## Example + +```ts +const validPassword = await scrypt.verify(hash, password); +``` diff --git a/docs/pages/reference/main/Session.md b/docs/pages/reference/main/Session.md new file mode 100644 index 000000000..f16b659c5 --- /dev/null +++ b/docs/pages/reference/main/Session.md @@ -0,0 +1,26 @@ +--- +title: "Session" +--- + +# `Session` + +Represents a session. + +## Definition + +```ts +//$ SessionAttributes=/reference/main/SessionAttributes +interface Session extends SessionAttributes { + id: string; + expiresAt: Date; + fresh: boolean; + userId: string; +} +``` + +### Properties + +- `id` +- `expiresAt` +- `fresh`: `true` if session was newly created or its expiration was extended +- `userId` diff --git a/docs/pages/reference/main/TimeSpan.md b/docs/pages/reference/main/TimeSpan.md new file mode 100644 index 000000000..3d8d3ad5c --- /dev/null +++ b/docs/pages/reference/main/TimeSpan.md @@ -0,0 +1,7 @@ +--- +title: "TimeSpan" +--- + +# `TimeSpan` + +See [`TimeSpan`](https://oslo.js.org/reference/main/TimeSpan) from `oslo`. diff --git a/docs/pages/reference/main/User.md b/docs/pages/reference/main/User.md new file mode 100644 index 000000000..643e719c0 --- /dev/null +++ b/docs/pages/reference/main/User.md @@ -0,0 +1,20 @@ +--- +title: "User" +--- + +# `User` + +Represents a user. + +## Definition + +```ts +//$ UserAttributes=/reference/main/UserAttributes +interface User extends UserAttributes { + id: string; +} +``` + +### Properties + +- `id` diff --git a/docs/pages/reference/main/generateId.md b/docs/pages/reference/main/generateId.md new file mode 100644 index 000000000..2fed737fd --- /dev/null +++ b/docs/pages/reference/main/generateId.md @@ -0,0 +1,26 @@ +--- +title: "generateId()" +--- + +# `generateId()` + +Generates a cryptographically strong random string made of `a-z` (lowercase) and `0-9`. + +## Definition + +```ts +function generateId(length: number): string; +``` + +### Parameters + +- `length` + +## Example + +```ts +import { generateId } from "lucia"; + +// 10-characters long string +generateId(10); +``` diff --git a/docs/pages/reference/main/index.md b/docs/pages/reference/main/index.md new file mode 100644 index 000000000..adb462beb --- /dev/null +++ b/docs/pages/reference/main/index.md @@ -0,0 +1,27 @@ +--- +title: "lucia API reference" +--- + +# `lucia` API reference + +## Functions + +- [`generateId()`](/reference/main/generateId) + +## Classes + +- [`LegacyScrypt`](/reference/main/LegacyScrypt) +- [`Lucia`](/reference/main/Lucia) +- [`Cookie`](/reference/main/SessionCookie) +- [`Scrypt`](/reference/main/Scrypt) +- [`TimeSpan`](/reference/main/TimeSpan) + +## Interfaces + +- [`Adapter`](/reference/main/Adapter) +- [`DatabaseSession`](/reference/main/DatabaseSession) +- [`DatabaseSessionAttributes`](/reference/main/DatabaseSessionAttributes) +- [`DatabaseUser`](/reference/main/DatabaseUser) +- [`DatabaseUserAttributes`](/reference/main/DatabaseUserAttributes) +- [`Session`](/reference/main/Session) +- [`User`](/reference/main/User) diff --git a/docs/pages/reference/main/verifyRequestOrigin.md b/docs/pages/reference/main/verifyRequestOrigin.md new file mode 100644 index 000000000..51803964c --- /dev/null +++ b/docs/pages/reference/main/verifyRequestOrigin.md @@ -0,0 +1,7 @@ +--- +title: "verifyRequestOrigin()" +--- + +# `Cookie` + +See [`verifyRequestOrigin()`](https://oslo.js.org/reference/request/verifyRequestOrigin) from `oslo/request`. diff --git a/docs/pages/tutorials/github-oauth/astro.md b/docs/pages/tutorials/github-oauth/astro.md new file mode 100644 index 000000000..b55cf40b1 --- /dev/null +++ b/docs/pages/tutorials/github-oauth/astro.md @@ -0,0 +1,237 @@ +--- +title: "GitHub OAuth in Astro" +--- + +# Tutorial: GitHub OAuth in Astro + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/astro) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/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/examples/tree/v3/astro/github-oauth). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/astro/github-oauth +``` + +## Create an OAuth App + +[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. + +```bash +# .env +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +``` + +## Update database + +Add a `github_id` and `username` column to your user table. + +| column | type | attributes | +| ----------- | -------- | ---------- | +| `github_id` | `number` | unique | +| `username` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `githubId` and `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: import.meta.env.PROD + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + githubId: attributes.github_id, + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + github_id: number; + username: string; +} +``` + +## Setup Arctic + +We recommend using [Arctic](https://arctic.js.org) for implementing OAuth. It is a lightweight library that provides APIs for creating authorization URLs, validating callbacks, and refreshing access tokens. This is the easiest way to implement OAuth with Lucia and it supports most major providers. + +``` +npm install arctic +``` + +Initialize the GitHub provider with the client ID and secret. + +```ts +import { GitHub } from "arctic"; + +export const github = new GitHub( + import.meta.env.GITHUB_CLIENT_ID, + import.meta.env.GITHUB_CLIENT_SECRET +); +``` + +## Sign in page + +Create `pages/login/index.astro` and add a basic sign in button, which should be a link to `/login/github`. + +```html + + + +

Sign in

+ Sign in with GitHub + + +``` + +## Create authorization URL + +Create an API route in `pages/login/github/index.ts`. Generate a new state, create a new authorization URL with createAuthorizationURL(), store the state, and redirect the user to the authorization URL. The user will be prompted to sign in with GitHub. + +```ts +// pages/login/github/index.ts +import { generateState } from "arctic"; +import { github } from "@lib/auth"; + +import type { APIContext } from "astro"; + +export async function GET(context: APIContext): Promise { + const state = generateState(); + const url = await github.createAuthorizationURL(state); + + context.cookies.set("github_oauth_state", state, { + path: "/", + secure: import.meta.env.PROD, + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax" + }); + + return context.redirect(url.toString()); +} +``` + +## Validate callback + +Create an API route in `pages/login/github/callback.ts` to handle the callback. First, get the state from the cookie and the search params and compare them. Validate the authorization code in the search params with `validateAuthorizationCode()`. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the code or credentials are invalid. After validating the code, get the user's profile using the access token. Check if the user is already registered with the GitHub ID, and create a new user if they aren't. Finally, create a new session and set the session cookie. + +```ts +// pages/login/github/callback.ts +import { github, lucia } from "@lib/auth"; +import { OAuth2RequestError } from "arctic"; +import { generateId } from "lucia"; + +import type { APIContext } from "astro"; + +export async function GET(context: APIContext): Promise { + const code = context.url.searchParams.get("code"); + const state = context.url.searchParams.get("state"); + const storedState = context.cookies.get("github_oauth_state")?.value ?? null; + if (!code || !state || !storedState || state !== storedState) { + return new Response(null, { + status: 400 + }); + } + + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + }); + const githubUser: GitHubUser = await githubUserResponse.json(); + const existingUser = await db.table("user").where("github_id", "=", githubUser.id).get(); + + if (existingUser) { + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return context.redirect("/"); + } + + const userId = generateId(15); + await db.table("user").insert({ + id: userId, + github_id: githubUser.id, + username: githubUser.login + }); + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return context.redirect("/"); + } catch (e) { + // the specific error message depends on the provider + if (e instanceof OAuth2RequestError) { + // invalid code + return new Response(null, { + status: 400 + }); + } + return new Response(null, { + status: 500 + }); + } +} + +interface GitHubUser { + id: string; + login: string; +} +``` + +## Validate requests + +You can validate requests by checking `locals.user`. The field `user.username` is available since we defined the `getUserAttributes()` option. You can protect pages, such as `/`, by redirecting unauthenticated users to the login page. + +```ts +const user = Astro.locals.user; +if (!user) { + return Astro.redirect("/login"); +} + +const username = user.username; +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +import { lucia } from "@lib/auth"; +import type { APIContext } from "astro"; + +export async function POST(context: APIContext): Promise { + if (!context.locals.session) { + return new Response(null, { + status: 401 + }); + } + + await lucia.invalidateSession(context.locals.session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + + return Astro.redirect("/login"); +} +``` + +```html +
+ +
+``` diff --git a/docs/pages/tutorials/github-oauth/index.md b/docs/pages/tutorials/github-oauth/index.md new file mode 100644 index 000000000..e705c6b15 --- /dev/null +++ b/docs/pages/tutorials/github-oauth/index.md @@ -0,0 +1,13 @@ +--- +title: "Tutorial: GitHub OAuth" +--- + +# Tutorial: GitHub OAuth + +The tutorials go over how to implement a basic GitHub OAuth and cover the basics of Lucia along the way. As a prerequisite, you should be fairly comfortable with your framework and its APIs. Basic example projects are available in the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +- [Astro](/tutorials/github-oauth/astro) +- [Next.js App router](/tutorials/github-oauth/nextjs-app) +- [Next.js Pages router](/tutorials/github-oauth/nextjs-pages) +- [Nuxt](/tutorials/github-oauth/nuxt) +- [SvelteKit](/tutorials/github-oauth/sveltekit) diff --git a/docs/pages/tutorials/github-oauth/nextjs-app.md b/docs/pages/tutorials/github-oauth/nextjs-app.md new file mode 100644 index 000000000..e2bea7b6b --- /dev/null +++ b/docs/pages/tutorials/github-oauth/nextjs-app.md @@ -0,0 +1,293 @@ +--- +title: "GitHub OAuth in Next.js App router" +--- + +# Tutorial: GitHub OAuth in Next.js App router + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/nextjs-app) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/nextjs-app/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/examples/tree/v3/nextjs-app/github-oauth). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/nextjs-app/github-oauth +``` + +## Create an OAuth App + +[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. + +```bash +# .env +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +``` + +## Update database + +Add a `github_id` and `username` column to your user table. + +| column | type | attributes | +| ----------- | -------- | ---------- | +| `github_id` | `number` | unique | +| `username` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `githubId` and `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + expires: false, + attributes: { + secure: process.env.NODE_ENV === "production" + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + githubId: attributes.github_id, + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + github_id: number; + username: string; +} +``` + +## Setup Arctic + +We recommend using [Arctic](https://arctic.js.org) for implementing OAuth. It is a lightweight library that provides APIs for creating authorization URLs, validating callbacks, and refreshing access tokens. This is the easiest way to implement OAuth with Lucia and it supports most major providers. + +``` +npm install arctic +``` + +Initialize the GitHub provider with the client ID and secret. + +```ts +import { GitHub } from "arctic"; + +export const github = new GitHub(process.env.GITHUB_CLIENT_ID!, process.env.GITHUB_CLIENT_SECRET!); +``` + +## Sign in page + +Create `app/login/page.tsx` and add a basic sign in button, which should be a link to `/login/github`. + +```tsx +// app/login/page.tsx +export default async function Page() { + return ( + <> +

Sign in

+ Sign in with GitHub + + ); +} +``` + +## Create authorization URL + +Create an API route in `app/login/github/route.ts`. Generate a new state, create a new authorization URL with createAuthorizationURL(), store the state, and redirect the user to the authorization URL. The user will be prompted to sign in with GitHub. + +```ts +// app/login/github/route.ts +import { generateState } from "arctic"; +import { github } from "../../../lib/auth"; +import { cookies } from "next/headers"; + +export async function GET(): Promise { + const state = generateState(); + const url = await github.createAuthorizationURL(state); + + cookies().set("github_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax" + }); + + return Response.redirect(url); +} +``` + +## Validate callback + +Create an API route in `app/login/github/callback/route.ts` to handle the callback. First, get the state from the cookie and the search params and compare them. Validate the authorization code in the search params with `validateAuthorizationCode()`. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the code or credentials are invalid. After validating the code, get the user's profile using the access token. Check if the user is already registered with the GitHub ID, and create a new user if they aren't. Finally, create a new session and set the session cookie. + +```ts +// app/login/github/callback/route.ts +import { github, lucia } from "@/lib/auth"; +import { cookies } from "next/headers"; +import { OAuth2RequestError } from "arctic"; +import { generateId } from "lucia"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const storedState = cookies().get("github_oauth_state")?.value ?? null; + if (!code || !state || !storedState || state !== storedState) { + return new Response(null, { + status: 400 + }); + } + + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + }); + const githubUser: GitHubUser = await githubUserResponse.json(); + const existingUser = await db.table("user").where("github_id", "=", githubUser.id).get(); + + if (existingUser) { + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return new Response(null, { + status: 302, + headers: { + Location: "/" + } + }); + } + + const userId = generateId(15); + await db.table("user").insert({ + id: userId, + github_id: githubUser.id, + username: githubUser.login + }); + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return new Response(null, { + status: 302, + headers: { + Location: "/" + } + }); + } catch (e) { + // the specific error message depends on the provider + if (e instanceof OAuth2RequestError) { + // invalid code + return new Response(null, { + status: 400 + }); + } + return new Response(null, { + status: 500 + }); + } +} + +interface GitHubUser { + id: string; + login: string; +} +``` + +## Validate requests + +Create `validateRequest()`. This will check for the session cookie, validate it, and set a new cookie if necessary. Make sure to catch errors when setting cookies and wrap the function with `cache()` to prevent unnecessary database calls. To learn more, see the [Validating requests](/basics/validate-session-cookies/nextjs-app) page. + +CSRF protection should be implemented but Next.js handles it when using form actions (but not for API routes). + +```ts +import { cookies } from "next/headers"; +import { cache } from "react"; + +import type { Session, User } from "lucia"; + +export const lucia = new Lucia(); + +export const validateRequest = cache( + async (): Promise<{ user: User; session: Session } | { user: null; session: null }> => { + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) { + return { + user: null, + session: null + }; + } + + const result = await lucia.validateSession(sessionId); + // next.js throws when you attempt to set cookie when rendering page + try { + if (result.session && result.session.fresh) { + const sessionCookie = lucia.createSessionCookie(result.session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + if (!result.session) { + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + } catch {} + return result; + } +); +``` + +This function can then be used in server components and form actions to get the current session and user. + +```tsx +import { redirect } from "next/navigation"; +import { validateRequest } from "@/lib/auth"; + +export default async function Page() { + const { user } = await validateRequest(); + if (!user) { + return redirect("/login"); + } + return

Hi, {user.username}!

; +} +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```tsx +import { lucia, validateRequest } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; + +export default async function Page() { + return ( +
+ +
+ ); +} + +async function logout(): Promise { + "use server"; + const { session } = await validateRequest(); + if (!session) { + return { + error: "Unauthorized" + }; + } + + await lucia.invalidateSession(session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return redirect("/login"); +} +``` diff --git a/docs/pages/tutorials/github-oauth/nextjs-pages.md b/docs/pages/tutorials/github-oauth/nextjs-pages.md new file mode 100644 index 000000000..55533152a --- /dev/null +++ b/docs/pages/tutorials/github-oauth/nextjs-pages.md @@ -0,0 +1,324 @@ +--- +title: "GitHub OAuth in Next.js Pages router" +--- + +# Tutorial: GitHub OAuth in Next.js Pages router + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/nextjs-pages) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/nextjs-pages/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/examples/tree/v3/nextjs-pages/github-oauth). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/nextjs-pages/github-oauth +``` + +## Create an OAuth App + +[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/api/login/github/callback`. Copy and paste the client ID and secret to your `.env` file. + +```bash +# .env +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +``` + +## Update database + +Add a `github_id` and `username` column to your user table. + +| column | type | attributes | +| ----------- | -------- | ---------- | +| `github_id` | `number` | unique | +| `username` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `githubId` and `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: process.env.NODE_ENV === "production" + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + githubId: attributes.github_id, + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + github_id: number; + username: string; +} +``` + +## Setup Arctic + +We recommend using [Arctic](https://arctic.js.org) for implementing OAuth. It is a lightweight library that provides APIs for creating authorization URLs, validating callbacks, and refreshing access tokens. This is the easiest way to implement OAuth with Lucia and it supports most major providers. + +``` +npm install arctic +``` + +Initialize the GitHub provider with the client ID and secret. + +```ts +import { GitHub } from "arctic"; + +export const github = new GitHub(process.env.GITHUB_CLIENT_ID!, process.env.GITHUB_CLIENT_SECRET!); +``` + +## Sign in page + +Create `pages/login.tsx` and add a basic sign in button, which should be a link to `/login/github`. + +```tsx +// pages/login.tsx +export default function Page() { + return ( + <> +

Sign in

+ Sign in with GitHub + + ); +} +``` + +## Create authorization URL + +Create an API route in `pages/api/login/github/index.ts`. Generate a new state, create a new authorization URL with createAuthorizationURL(), store the state, and redirect the user to the authorization URL. The user will be prompted to sign in with GitHub. + +```ts +// pages/api/login/github/index.ts +import { github } from "@/lib/auth"; +import { generateState } from "arctic"; +import { serializeCookie } from "oslo/cookie"; + +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.status(404).end(); + return; + } + const state = generateState(); + const url = await github.createAuthorizationURL(state); + res + .appendHeader( + "Set-Cookie", + serializeCookie("github_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax" + }) + ) + .redirect(url.toString()); +} +``` + +## Validate callback + +Create an API route in `pages/api/login/github/callback.ts` to handle the callback. First, get the state from the cookie and the search params and compare them. Validate the authorization code in the search params with `validateAuthorizationCode()`. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the code or credentials are invalid. After validating the code, get the user's profile using the access token. Check if the user is already registered with the GitHub ID, and create a new user if they aren't. Finally, create a new session and set the session cookie. + +```ts +// pages/api/login/github/callback.ts +import { github, lucia } from "@/lib/auth"; +import { OAuth2RequestError } from "arctic"; +import { generateId } from "lucia"; + +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.status(404).end(); + return; + } + const code = req.query.code?.toString() ?? null; + const state = req.query.state?.toString() ?? null; + const storedState = req.cookies.github_oauth_state ?? null; + if (!code || !state || !storedState || state !== storedState) { + console.log(code, state, storedState); + res.status(400).end(); + return; + } + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + }); + const githubUser: GitHubUser = await githubUserResponse.json(); + const existingUser = await db.table("user").where("github_id", "=", githubUser.id).get(); + + if (existingUser) { + const session = await lucia.createSession(existingUser.id, {}); + return res + .appendHeader("Set-Cookie", lucia.createSessionCookie(session.id).serialize()) + .redirect("/"); + } + + const userId = generateId(15); + await db.table("user").insert({ + id: userId, + github_id: githubUser.id, + username: githubUser.login + }); + const session = await lucia.createSession(userId, {}); + return res + .appendHeader("Set-Cookie", lucia.createSessionCookie(session.id).serialize()) + .redirect("/"); + } catch (e) { + // the specific error message depends on the provider + if (e instanceof OAuth2RequestError) { + // invalid code + return new Response(null, { + status: 400 + }); + } + res.status(500).end(); + return; + } +} + +interface GitHubUser { + id: string; + login: string; +} +``` + +## Validate requests + +Create `validateRequest()`. This will check for the session cookie, validate it, and set a new cookie if necessary. To learn more, see the [Validating requests](/basics/validate-session-cookies/nextjs-pages) page. + +CSRF protection should be implemented and you should already have a middleware for it. + +```ts +import type { Session, User } from "lucia"; +import type { IncomingMessage, ServerResponse } from "http"; + +export const lucia = new Lucia(); + +export async function validateRequest( + req: IncomingMessage, + res: ServerResponse +): Promise<{ user: User; session: Session } | { user: null; session: null }> { + const sessionId = lucia.readSessionCookie(req.headers.cookie ?? ""); + if (!sessionId) { + return { + user: null, + session: null + }; + } + const result = await lucia.validateSession(sessionId); + if (result.session && result.session.fresh) { + res.appendHeader("Set-Cookie", lucia.createSessionCookie(result.session.id).serialize()); + } + if (!result.session) { + res.appendHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize()); + } + return result; +} +``` + +This function can then be used in both `getServerSideProps()` and API routes. + +```tsx +import { validateRequest } from "@/lib/auth"; + +import type { + GetServerSidePropsContext, + GetServerSidePropsResult, + InferGetServerSidePropsType +} from "next"; +import type { User } from "lucia"; + +export async function getServerSideProps(context: GetServerSidePropsContext): Promise< + GetServerSidePropsResult<{ + user: User; + }> +> { + const { user } = await validateRequest(context.req, context.res); + if (!user) { + return { + redirect: { + permanent: false, + destination: "/login" + } + }; + } + return { + props: { + user + } + }; +} + +export default function Page({ user }: InferGetServerSidePropsType) { + return

Hi, {user.username}!

; +} +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +// pages/api/logout.ts +import { lucia, validateRequest } from "@/lib/auth"; + +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(404).end(); + return; + } + const { session } = await validateRequest(req, res); + if (!session) { + res.status(401).end(); + return; + } + await lucia.invalidateSession(session.id); + res.setHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize()).status(200).end(); +} +``` + +```tsx +import { useRouter } from "next/router"; + +import type { FormEvent } from "react"; + +export default function Page({ user }: InferGetServerSidePropsType) { + const router = useRouter(); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + const formElement = e.target as HTMLFormElement; + await fetch(formElement.action, { + method: formElement.method + }); + router.push("/login"); + } + + return ( +
+ +
+ ); +} +``` diff --git a/docs/pages/tutorials/github-oauth/nuxt.md b/docs/pages/tutorials/github-oauth/nuxt.md new file mode 100644 index 000000000..04ee141f8 --- /dev/null +++ b/docs/pages/tutorials/github-oauth/nuxt.md @@ -0,0 +1,270 @@ +--- +title: "GitHub OAuth in Nuxt" +--- + +# Tutorial: GitHub OAuth in Nuxt + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/nuxt) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/nuxt/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/examples/tree/v3/nuxt/github-oauth). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/nuxt/github-oauth +``` + +## Create an OAuth App + +[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. + +```bash +# .env +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +``` + +## Update database + +Add a `github_id` and `username` column to your user table. + +| column | type | attributes | +| ----------- | -------- | ---------- | +| `github_id` | `number` | unique | +| `username` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `githubId` and `username` field to it, use the `getUserAttributes()` option. + +```ts +// server/utils/auth.ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !import.meta.dev + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + githubId: attributes.github_id, + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + github_id: number; + username: string; +} +``` + +## Setup Arctic + +We recommend using [Arctic](https://arctic.js.org) for implementing OAuth. It is a lightweight library that provides APIs for creating authorization URLs, validating callbacks, and refreshing access tokens. This is the easiest way to implement OAuth with Lucia and it supports most major providers. + +``` +npm install arctic +``` + +Initialize the GitHub provider with the client ID and secret. + +```ts +import { GitHub } from "arctic"; + +export const github = new GitHub(process.env.GITHUB_CLIENT_ID!, process.env.GITHUB_CLIENT_SECRET!); +``` + +## Sign in page + +Create `pages/login/index.vue` and add a basic sign in button, which should be a link to `/login/github`. + +```vue + + +``` + +## Create authorization URL + +Create an API route in `server/routes/login/github/index.get.ts`. Generate a new state, create a new authorization URL with createAuthorizationURL(), store the state, and redirect the user to the authorization URL. The user will be prompted to sign in with GitHub. + +```ts +// server/routes/login/github/index.get.ts +import { generateState } from "arctic"; + +export default defineEventHandler(async (event) => { + const state = generateState(); + const url = await github.createAuthorizationURL(state); + + setCookie(event, "github_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax" + }); + return sendRedirect(event, url.toString()); +}); +``` + +## Validate callback + +Create an API route in `server/routes/login/github/callback.get.ts` to handle the callback. First, get the state from the cookie and the search params and compare them. Validate the authorization code in the search params with `validateAuthorizationCode()`. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the code or credentials are invalid. After validating the code, get the user's profile using the access token. Check if the user is already registered with the GitHub ID, and create a new user if they aren't. Finally, create a new session and set the session cookie. + +```ts +// server/routes/login/github/callback.get.ts +import { OAuth2RequestError } from "arctic"; +import { generateId } from "lucia"; + +export default defineEventHandler(async (event) => { + const query = getQuery(event); + const code = query.code?.toString() ?? null; + const state = query.state?.toString() ?? null; + const storedState = getCookie(event, "github_oauth_state") ?? null; + if (!code || !state || !storedState || state !== storedState) { + throw createError({ + status: 400 + }); + } + + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + }); + const githubUser: GitHubUser = await githubUserResponse.json(); + const existingUser = await db.table("user").where("github_id", "=", githubUser.id).get(); + + if (existingUser) { + const session = await lucia.createSession(existingUser.id, {}); + appendHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + return sendRedirect(event, "/"); + } + + const userId = generateId(15); + await db.table("user").insert({ + id: userId, + github_id: githubUser.id, + username: githubUser.login + }); + const session = await lucia.createSession(userId, {}); + appendHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); + return sendRedirect(event, "/"); + } catch (e) { + // the specific error message depends on the provider + if (e instanceof OAuth2RequestError) { + // invalid code + throw createError({ + status: 400 + }); + } + throw createError({ + status: 500 + }); + } +}); + +interface GitHubUser { + id: string; + login: string; +} +``` + +## Validate requests + +You can validate requests by checking `event.context.user`. The field `user.username` is available since we defined the `getUserAttributes()` option. You can protect pages, such as `/`, by redirecting unauthenticated users to the login page. + +```ts +export default defineEventHandler((event) => { + if (event.context.user) { + const username = event.context.user.username; + } + // ... +}); +``` + +## Get user in the client + +Create an API route in `server/api/user.get.ts`. This will just return the current user. + +```ts +// server/api/user.get.ts +export default defineEventHandler((event) => { + return event.context.user; +}); +``` + +Create a composable `useUser()` in `composables/auth.ts`. + +```ts +// composables/auth.ts +import type { User } from "lucia"; + +export const useUser = () => { + const user = useState("user", () => null); + return user; +}; +``` + +Then, create a global middleware in `middleware/auth.global.ts` to populate it. + +```ts +// middleware/auth.global.ts +export default defineNuxtRouteMiddleware(async () => { + const user = useUser(); + user.value = await $fetch("/api/user"); +}); +``` + +You can now use `useUser()` client side to get the current user. + +```vue + +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +// server/api/logout.post.ts +export default eventHandler(async (event) => { + if (!event.context.session) { + throw createError({ + statusCode: 403 + }); + } + await lucia.invalidateSession(event.context.session.id); + appendHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize()); +}); +``` + +```vue + + + +``` diff --git a/docs/pages/tutorials/github-oauth/sveltekit.md b/docs/pages/tutorials/github-oauth/sveltekit.md new file mode 100644 index 000000000..6d2e64451 --- /dev/null +++ b/docs/pages/tutorials/github-oauth/sveltekit.md @@ -0,0 +1,260 @@ +--- +title: "GitHub OAuth in SvelteKit" +--- + +# Tutorial: GitHub OAuth in SvelteKit + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/sveltekit) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/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/examples/tree/v3/sveltekit/github-oauth). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/sveltekit/github-oauth +``` + +## Create an OAuth App + +[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. + +```bash +# .env +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +``` + +## Update database + +Add a `github_id` and `username` column to your user table. + +| column | type | attributes | +| ----------- | -------- | ---------- | +| `github_id` | `number` | unique | +| `username` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `githubId` and `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; +import { dev } from "$app/environment"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !dev + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + githubId: attributes.github_id, + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + github_id: number; + username: string; +} +``` + +## Setup Arctic + +We recommend using [Arctic](https://arctic.js.org) for implementing OAuth. It is a lightweight library that provides APIs for creating authorization URLs, validating callbacks, and refreshing access tokens. This is the easiest way to implement OAuth with Lucia and it supports most major providers. + +``` +npm install arctic +``` + +Initialize the GitHub provider with the client ID and secret. + +```ts +import { GitHub } from "arctic"; + +export const github = new GitHub( + import.meta.env.GITHUB_CLIENT_ID, + import.meta.env.GITHUB_CLIENT_SECRET +); +``` + +## Sign in page + +Create `routes/login/+page.svelte` and add a basic sign in button, which should be a link to `/login/github`. + +```svelte + +

Sign in

+Sign in with GitHub +``` + +## Create authorization URL + +Create an API route in `routes/login/github/+server.ts`. Generate a new state, create a new authorization URL with createAuthorizationURL(), store the state, and redirect the user to the authorization URL. The user will be prompted to sign in with GitHub. + +```ts +// routes/login/github/+server.ts +import { github } from "$lib/server/auth"; +import { generateState } from "arctic"; +import { redirect } from "@sveltejs/kit"; + +import type { RequestEvent } from "@sveltejs/kit"; + +export async function GET(event: RequestEvent): Promise { + const state = generateState(); + const url = await github.createAuthorizationURL(state); + + event.cookies.set("github_oauth_state", state, { + path: "/", + secure: import.meta.env.PROD, + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax" + }); + + redirect(302, url.toString()); +} +``` + +## Validate callback + +Create an API route in `routes/login/github/callback/+server.ts` to handle the callback. First, get the state from the cookie and the search params and compare them. Validate the authorization code in the search params with `validateAuthorizationCode()`. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the code or credentials are invalid. After validating the code, get the user's profile using the access token. Check if the user is already registered with the GitHub ID, and create a new user if they aren't. Finally, create a new session and set the session cookie. + +```ts +// routes/login/github/callback/+server.ts +import { github, lucia } from "$lib/server/auth"; +import { OAuth2RequestError } from "arctic"; +import { generateId } from "lucia"; + +import type { RequestEvent } from "@sveltejs/kit"; + +export async function GET(event: RequestEvent): Promise { + const code = event.url.searchParams.get("code"); + const state = event.url.searchParams.get("state"); + const storedState = event.cookies.get("github_oauth_state") ?? null; + if (!code || !state || !storedState || state !== storedState) { + return new Response(null, { + status: 400 + }); + } + + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUserResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}` + } + }); + const githubUser: GitHubUser = await githubUserResponse.json(); + const existingUser = await db.table("user").where("github_id", "=", githubUser.id).get(); + + if (existingUser) { + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + } else { + const userId = generateId(15); + await db.table("user").insert({ + id: userId, + github_id: githubUser.id, + username: githubUser.login + }); + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + } + return new Response(null, { + status: 302, + headers: { + Location: "/" + } + }); + } catch (e) { + // the specific error message depends on the provider + if (e instanceof OAuth2RequestError) { + // invalid code + return new Response(null, { + status: 400 + }); + } + return new Response(null, { + status: 500 + }); + } +} + +interface GitHubUser { + id: string; + login: string; +} +``` + +## Validate requests + +You can validate requests by checking `locals.user`. The field `user.username` is available since we defined the `getUserAttributes()` option. You can protect pages, such as `/`, by redirecting unauthenticated users to the login page. + +```ts +// +page.server.ts +import type { PageServerLoad, Actions } from "./$types"; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) redirect(302, "/login"); + return { + username: event.locals.user.username + }; +}; +``` + +## Sign out user + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +// routes/+page.server.ts +import { lucia } from "$lib/server/auth"; +import { fail, redirect } from "@sveltejs/kit"; + +import type { Actions, PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals }) => { + // ... +}; + +export const actions: Actions = { + default: async (event) => { + if (!event.locals.session) { + return fail(401); + } + await auth.invalidateSession(event.locals.session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + context.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + redirect(302, "/login"); + } +}; +``` + +```svelte + + + +
+ +
+``` diff --git a/docs/pages/tutorials/username-and-password/astro.md b/docs/pages/tutorials/username-and-password/astro.md new file mode 100644 index 000000000..6b79bbd51 --- /dev/null +++ b/docs/pages/tutorials/username-and-password/astro.md @@ -0,0 +1,263 @@ +--- +title: "Tutorial: Username and password auth in Astro" +--- + +# Tutorial: Username and password auth in Astro + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/astro) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/astro/username-and-password) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/v3/astro/username-and-password). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/astro/username-and-password +``` + +## Update database + +Add a `username` and `hashed_password` column to your user table. + +| column | type | attributes | +| ----------------- | -------- | ---------- | +| `username` | `string` | unique | +| `hashed_password` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: import.meta.env.PROD + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} +``` + +## Sign up user + +Create `pages/signup.astro` and set up a basic form. + +```html + + + +

Sign up

+
+ + + + + +
+ + +``` + +Create an API route in `pages/api/signup.ts`. First, do a very basic input validation. Hash the password, generate a new user ID, and create a new user. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +// pages/api/signup.ts +import { lucia } from "@lib/auth"; +import { generateId } from "lucia"; +import { Argon2id } from "oslo/password"; + +import type { APIContext } from "astro"; + +export async function POST(context: APIContext): Promise { + const formData = await context.request.formData(); + const username = formData.get("username"); + // username must be between 4 ~ 31 characters, and only consists of lowercase letters, 0-9, -, and _ + // keep in mind some database (e.g. mysql) are case insensitive + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return new Response("Invalid username", { + status: 400 + }); + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return new Response("Invalid password", { + status: 400 + }); + } + + const userId = generateId(15); + const hashedPassword = await new Argon2id().hash(password); + + // TODO: check if username is already used + await db.table("user").insert({ + id: userId, + username: username, + hashed_password: hashedPassword + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + + return context.redirect("/"); +} +``` + +We recommend using Argon2id, but Oslo also provides Scrypt and Bcrypt. These only work in Node.js. If you're planning to deploy your project to a non-Node.js runtime, use `Scrypt` provided by `lucia`. This is a pure JS implementation but 2~3 times slower. For Bun, use [`Bun.password`](https://bun.sh/docs/api/hashing#bun-password). + +```ts +import { Scrypt } from "lucia"; + +new Scrypt().hash(password); +``` + +**If you're using Bcrypt, [set the maximum password length to 64 _bytes_](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#input-limits-of-bcrypt).** + +```ts +const length = new TextEncoder().encode(password).length; +``` + +## Sign in user + +Create `pages/login.astro` and set up a basic form. + +```html + + + +

Sign in

+
+ + + + + +
+ + +``` + +Create an API route as `pages/api/signup.ts`. First, do a very basic input validation. Get the user with the username and verify the password. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +// pages/api/login.ts +import { lucia } from "@lib/auth"; +import { Argon2id } from "oslo/password"; + +import type { APIContext } from "astro"; + +export async function POST(context: APIContext): Promise { + const formData = await context.request.formData(); + const username = formData.get("username"); + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return new Response("Invalid username", { + status: 400 + }); + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return new Response("Invalid password", { + status: 400 + }); + } + + const existingUser = await db + .table("username") + .where("username", "=", username.toLowerCase()) + .get(); + if (!existingUser) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid usernames from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid usernames. + // However, valid usernames can be already be revealed with the signup page among other methods. + // It will also be much more resource intensive. + // Since protecting against this is none-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If usernames are public, you may outright tell the user that the username is invalid. + return new Response("Incorrect username or password", { + status: 400 + }); + } + + const validPassword = await new Argon2id().verify(existingUser.password, password); + if (!validPassword) { + return new Response("Incorrect username or password", { + status: 400 + }); + } + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + + return context.redirect("/"); +} +``` + +## Validate requests + +You can validate requests by checking `locals.user`. The field `user.username` is available since we defined the `getUserAttributes()` option. You can protect pages, such as `/`, by redirecting unauthenticated users to the login page. + +```ts +const user = Astro.locals.user; +if (!user) { + return Astro.redirect("/login"); +} + +const username = user.username; +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +import { lucia } from "@lib/auth"; +import type { APIContext } from "astro"; + +export async function POST(context: APIContext): Promise { + if (!context.locals.session) { + return new Response(null, { + status: 401 + }); + } + + await lucia.invalidateSession(context.locals.session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + + return Astro.redirect("/login"); +} +``` + +```html +
+ +
+``` diff --git a/docs/pages/tutorials/username-and-password/index.md b/docs/pages/tutorials/username-and-password/index.md new file mode 100644 index 000000000..3f4bcb126 --- /dev/null +++ b/docs/pages/tutorials/username-and-password/index.md @@ -0,0 +1,13 @@ +--- +title: "Tutorial: Username and password" +--- + +# Tutorial: Username and password auth + +The tutorials go over how to implement a basic username and password auth and cover the basics of Lucia along the way. As a prerequisite, you should be fairly comfortable with your framework and its APIs. For a more in-depth guide, see the [Email and password](/guides/email-and-password/) guides. Basic example projects are available in the [examples repository](https://github.com/lucia-auth/examples/tree/v3). + +- [Astro](/tutorials/username-and-password/astro) +- [Next.js App router](/tutorials/username-and-password/nextjs-app) +- [Next.js Pages router](/tutorials/username-and-password/nextjs-pages) +- [Nuxt](/tutorials/username-and-password/nuxt) +- [SvelteKit](/tutorials/username-and-password/sveltekit) diff --git a/docs/pages/tutorials/username-and-password/nextjs-app.md b/docs/pages/tutorials/username-and-password/nextjs-app.md new file mode 100644 index 000000000..b56acee57 --- /dev/null +++ b/docs/pages/tutorials/username-and-password/nextjs-app.md @@ -0,0 +1,331 @@ +--- +title: "Username and password auth in Next.js App Router" +--- + +# Username and password auth in Next.js App Router + +Before starting, make sure you've set up your database as described in the [Getting started](/getting-started/nextjs-app) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/nextjs-app/username-and-password) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/v3/nextjs-app/username-and-password). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/nextjs-app/username-and-password +``` + +## Update database + +Add a `username` and `hashed_password` column to your user table. + +| column | type | attributes | +| ----------------- | -------- | ---------- | +| `username` | `string` | unique | +| `hashed_password` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + expires: false, + attributes: { + secure: process.env.NODE_ENV === "production" + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} +``` + +## Sign up user + +Create `app/signup/page.tsx` and set up a basic form and action. + +```tsx +export default async function Page() { + return ( + <> +

Create an account

+
+ + +
+ + +
+ +
+ + ); +} + +async function signup(_: any, formData: FormData): Promise {} + +interface ActionResult { + error: string; +} +``` + +In the form action, first do a very basic input validation. Hash the password, generate a new user ID, and create a new user. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```tsx +import { db } from "@/lib/db"; +import { Argon2id } from "oslo/password"; +import { cookies } from "next/headers"; +import { lucia } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { generateId } from "lucia"; + +export default async function Page() {} + +async function signup(_: any, formData: FormData): Promise { + "use server"; + const username = formData.get("username"); + // username must be between 4 ~ 31 characters, and only consists of lowercase letters, 0-9, -, and _ + // keep in mind some database (e.g. mysql) are case insensitive + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return { + error: "Invalid username" + }; + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return { + error: "Invalid password" + }; + } + + const hashedPassword = await new Argon2id().hash(password); + const userId = generateId(15); + + // TODO: check if username is already used + await db.table("user").insert({ + id: userId, + username: username, + hashed_password: hashedPassword + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return redirect("/"); +} +``` + +We recommend using Argon2id, but Oslo also provides Scrypt and Bcrypt. These only work in Node.js. If you're planning to deploy your project to a non-Node.js runtime, use `Scrypt` provided by `lucia`. This is a pure JS implementation but 2~3 times slower. For Bun, use [`Bun.password`](https://bun.sh/docs/api/hashing#bun-password). + +```ts +import { Scrypt } from "lucia"; + +new Scrypt().hash(password); +``` + +**If you're using Bcrypt, [set the maximum password length to 64 _bytes_](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#input-limits-of-bcrypt).** + +```ts +const length = new TextEncoder().encode(password).length; +``` + +## Sign in user + +Create `app/login/page.tsx` and set up a basic form and action. + +```tsx +// app/login/page.tsx +export default async function Page() { + return ( + <> +

Sign in

+
+ + +
+ + +
+ +
+ + ); +} + +async function login(_: any, formData: FormData): Promise {} + +interface ActionResult { + error: string; +} +``` + +In the form action, first do a very basic input validation. Get the user with the username and verify the password. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```tsx +import { Argon2id } from "oslo/password"; +import { cookies } from "next/headers"; +import { lucia } from "@/lib/auth"; +import { redirect } from "next/navigation"; + +export default async function Page() {} + +async function login(_: any, formData: FormData): Promise { + "use server"; + const username = formData.get("username"); + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return { + error: "Invalid username" + }; + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return { + error: "Invalid password" + }; + } + + const existingUser = await db + .table("username") + .where("username", "=", username.toLowerCase()) + .get(); + if (!existingUser) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid usernames from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid usernames. + // However, valid usernames can be already be revealed with the signup page among other methods. + // It will also be much more resource intensive. + // Since protecting against this is none-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If usernames are public, you may outright tell the user that the username is invalid. + return { + error: "Incorrect username or password" + }; + } + + const validPassword = await new Argon2id().verify(existingUser.password, password); + if (!validPassword) { + return { + error: "Incorrect username or password" + }; + } + + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return redirect("/"); +} +``` + +## Validate requests + +Create `validateRequest()`. This will check for the session cookie, validate it, and set a new cookie if necessary. Make sure to catch errors when setting cookies and wrap the function with `cache()` to prevent unnecessary database calls. To learn more, see the [Validating requests](/basics/validate-session-cookies/nextjs-app) page. + +CSRF protection should be implemented but Next.js handles it when using form actions (but not for API routes). + +```ts +import { cookies } from "next/headers"; +import { cache } from "react"; + +import type { Session, User } from "lucia"; + +export const lucia = new Lucia(); + +export const validateRequest = cache( + async (): Promise<{ user: User; session: Session } | { user: null; session: null }> => { + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) { + return { + user: null, + session: null + }; + } + + const result = await lucia.validateSession(sessionId); + // next.js throws when you attempt to set cookie when rendering page + try { + if (result.session && result.session.fresh) { + const sessionCookie = lucia.createSessionCookie(result.session.id); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + if (!result.session) { + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + } + } catch {} + return result; + } +); +``` + +This function can then be used in server components and form actions to get the current session and user. + +```tsx +import { redirect } from "next/navigation"; +import { validateRequest } from "@/lib/auth"; + +export default async function Page() { + const { user } = await validateRequest(); + if (!user) { + return redirect("/login"); + } + return

Hi, {user.username}!

; +} +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```tsx +import { lucia, validateRequest } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; + +export default async function Page() { + return ( +
+ +
+ ); +} + +async function logout(): Promise { + "use server"; + const { session } = await validateRequest(); + if (!session) { + return { + error: "Unauthorized" + }; + } + + await lucia.invalidateSession(session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + return redirect("/login"); +} +``` diff --git a/docs/pages/tutorials/username-and-password/nextjs-pages.md b/docs/pages/tutorials/username-and-password/nextjs-pages.md new file mode 100644 index 000000000..ac8325525 --- /dev/null +++ b/docs/pages/tutorials/username-and-password/nextjs-pages.md @@ -0,0 +1,394 @@ +--- +title: "Tutorial: Username and password auth in Next.js Pages router" +--- + +# Tutorial: Username and password auth in Next.js Pages router + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/nextjs-pages) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/nextjs-pages/username-and-password) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/v3/nextjs-pages/username-and-password). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/nextjs-pages/username-and-password +``` + +## Update database + +Add a `username` and `hashed_password` column to your user table. + +| column | type | attributes | +| ----------------- | -------- | ---------- | +| `username` | `string` | unique | +| `hashed_password` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: process.env.NODE_ENV === "production" + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} +``` + +## Sign up user + +Create `pages/signup.tsx` and set up a basic form. + +```tsx +// pages/signup.tsx +import { useRouter } from "next/router"; +import type { FormEvent } from "react"; + +export default function Page() { + const router = useRouter(); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + const formElement = e.target as HTMLFormElement; + const response = await fetch(formElement.action, { + method: formElement.method, + body: JSON.stringify(Object.fromEntries(new FormData(formElement).entries())), + headers: { + "Content-Type": "application/json" + } + }); + if (response.ok) { + router.push("/"); + } + } + + return ( + <> +

Create an account

+
+ + +
+ + +
+ +
+ + ); +} +``` + +Create an API route in `pages/api/signup.ts`. First, do a very basic input validation. Hash the password, generate a new user ID, and create a new user. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +// pages/api/signup.ts +import { lucia } from "@/lib/auth"; +import { generateId } from "lucia"; +import { Argon2id } from "oslo/password"; + +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(404).end(); + return; + } + + const body: null | Partial<{ username: string; password: string }> = req.body; + const username = body?.username; + if (!username || username.length < 3 || username.length > 31 || !/^[a-z0-9_-]+$/.test(username)) { + res.status(400).json({ + error: "Invalid username" + }); + return; + } + const password = body?.password; + if (!password || password.length < 6 || password.length > 255) { + res.status(400).json({ + error: "Invalid password" + }); + return; + } + + const hashedPassword = await new Argon2id().hash(password); + const userId = generateId(15); + + // TODO: check if username is already used + await db.table("user").insert({ + id: userId, + username: username, + hashed_password: hashedPassword + }); + + const session = await lucia.createSession(userId, {}); + res + .appendHeader("Set-Cookie", lucia.createSessionCookie(session.id).serialize()) + .status(200) + .end(); +} +``` + +We recommend using Argon2id, but Oslo also provides Scrypt and Bcrypt. These only work in Node.js. If you're planning to deploy your project to a non-Node.js runtime, use `Scrypt` provided by `lucia`. This is a pure JS implementation but 2~3 times slower. For Bun, use [`Bun.password`](https://bun.sh/docs/api/hashing#bun-password). + +```ts +import { Scrypt } from "lucia"; + +new Scrypt().hash(password); +``` + +**If you're using Bcrypt, [set the maximum password length to 64 _bytes_](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#input-limits-of-bcrypt).** + +```ts +const length = new TextEncoder().encode(password).length; +``` + +## Sign in user + +Create `pages/login.tsx` and set up a basic form. + +```tsx +// pages/signup.tsx +import { useRouter } from "next/router"; +import type { FormEvent } from "react"; + +export default function Page() { + const router = useRouter(); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + const formElement = e.target as HTMLFormElement; + const response = await fetch(formElement.action, { + method: formElement.method, + body: JSON.stringify(Object.fromEntries(new FormData(formElement).entries())), + headers: { + "Content-Type": "application/json" + } + }); + if (response.ok) { + router.push("/"); + } + } + + return ( + <> +

Create an account

+
+ + +
+ + +
+ +
+ + ); +} +``` + +Create an API route as `pages/api/signup.ts`. First, do a very basic input validation. Get the user with the username and verify the password. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +// pages/api/login.ts +import { Argon2id } from "oslo/password"; +import { lucia } from "@/lib/auth"; + +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(404).end(); + return; + } + + const body: null | Partial<{ username: string; password: string }> = req.body; + const username = body?.username; + if (!username || username.length < 3 || username.length > 31 || !/^[a-z0-9_-]+$/.test(username)) { + res.status(400).json({ + error: "Invalid username" + }); + return; + } + const password = body?.password; + if (!password || password.length < 6 || password.length > 255) { + res.status(400).json({ + error: "Invalid password" + }); + return; + } + + const existingUser = await db + .table("username") + .where("username", "=", username.toLowerCase()) + .get(); + if (!existingUser) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid usernames from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid usernames. + // However, valid usernames can be already be revealed with the signup page among other methods. + // It will also be much more resource intensive. + // Since protecting against this is none-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If usernames are public, you may outright tell the user that the username is invalid. + res.status(400).json({ + error: "Incorrect username or password" + }); + return; + } + + const validPassword = await new Argon2id().verify(existingUser.password, password); + if (!validPassword) { + res.status(400).json({ + error: "Incorrect username or password" + }); + return; + } + + const session = await lucia.createSession(existingUser.id, {}); + res + .appendHeader("Set-Cookie", lucia.createSessionCookie(session.id).serialize()) + .status(200) + .end(); +} +``` + +## Validate requests + +Create `validateRequest()`. This will check for the session cookie, validate it, and set a new cookie if necessary. To learn more, see the [Validating requests](/basics/validate-session-cookies/nextjs-pages) page. + +CSRF protection should be implemented and you should already have a middleware for it. + +```ts +import type { Session, User } from "lucia"; +import type { IncomingMessage, ServerResponse } from "http"; + +export const lucia = new Lucia(); + +export async function validateRequest( + req: IncomingMessage, + res: ServerResponse +): Promise<{ user: User; session: Session } | { user: null; session: null }> { + const sessionId = lucia.readSessionCookie(req.headers.cookie ?? ""); + if (!sessionId) { + return { + user: null, + session: null + }; + } + const result = await lucia.validateSession(sessionId); + if (result.session && result.session.fresh) { + res.appendHeader("Set-Cookie", lucia.createSessionCookie(result.session.id).serialize()); + } + if (!result.session) { + res.appendHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize()); + } + return result; +} +``` + +This function can then be used in both `getServerSideProps()` and API routes. + +```tsx +import { validateRequest } from "@/lib/auth"; + +import type { + GetServerSidePropsContext, + GetServerSidePropsResult, + InferGetServerSidePropsType +} from "next"; +import type { User } from "lucia"; + +export async function getServerSideProps(context: GetServerSidePropsContext): Promise< + GetServerSidePropsResult<{ + user: User; + }> +> { + const { user } = await validateRequest(context.req, context.res); + if (!user) { + return { + redirect: { + permanent: false, + destination: "/login" + } + }; + } + return { + props: { + user + } + }; +} + +export default function Page({ user }: InferGetServerSidePropsType) { + return

Hi, {user.username}!

; +} +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +// pages/api/logout.ts +import { lucia, validateRequest } from "@/lib/auth"; + +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.status(404).end(); + return; + } + const { session } = await validateRequest(req, res); + if (!session) { + res.status(401).end(); + return; + } + await lucia.invalidateSession(session.id); + res.setHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize()).status(200).end(); +} +``` + +```tsx +import { useRouter } from "next/router"; + +import type { FormEvent } from "react"; + +export default function Page({ user }: InferGetServerSidePropsType) { + const router = useRouter(); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + const formElement = e.target as HTMLFormElement; + await fetch(formElement.action, { + method: formElement.method + }); + router.push("/login"); + } + + return ( +
+ +
+ ); +} +``` diff --git a/docs/pages/tutorials/username-and-password/nuxt.md b/docs/pages/tutorials/username-and-password/nuxt.md new file mode 100644 index 000000000..2d2e5bd83 --- /dev/null +++ b/docs/pages/tutorials/username-and-password/nuxt.md @@ -0,0 +1,326 @@ +--- +title: "Tutorial: Username and password auth in Nuxt" +--- + +# Tutorial: Username and password auth in Nuxt + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/nuxt) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/nuxt/username-and-password) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/v3/nuxt/username-and-password). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/nuxt/username-and-password +``` + +## Update database + +Add a `username` and `hashed_password` column to your user table. + +| column | type | attributes | +| ----------------- | -------- | ---------- | +| `username` | `string` | unique | +| `hashed_password` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `username` field to it, use the `getUserAttributes()` option. + +```ts +// server/utils/auth.ts +import { Lucia } from "lucia"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !import.meta.dev + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} +``` + +## Sign up user + +Create `pages/signup.nuxt` and set up a basic form. + +```vue + + + + +``` + +Create an API route in `server/api/signup.post.ts`. First, do a very basic input validation. Hash the password, generate a new user ID, and create a new user. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +// server/api/signup.post.ts +import { Argon2id } from "oslo/password"; +import { generateId } from "lucia"; +import { SqliteError } from "better-sqlite3"; + +export default eventHandler(async (event) => { + const formData = await readFormData(event); + const username = formData.get("username"); + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + throw createError({ + message: "Invalid username", + statusCode: 400 + }); + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + throw createError({ + message: "Invalid password", + statusCode: 400 + }); + } + + const hashedPassword = await new Argon2id().hash(password); + const userId = generateId(15); + + // TODO: check if username is already used + await db.table("user").insert({ + id: userId, + username: username, + hashed_password: hashedPassword + }); + + const session = await lucia.createSession(userId, {}); + appendHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); +}); +``` + +We recommend using Argon2id, but Oslo also provides Scrypt and Bcrypt. These only work in Node.js. If you're planning to deploy your project to a non-Node.js runtime, use `Scrypt` provided by `lucia`. This is a pure JS implementation but 2~3 times slower. For Bun, use [`Bun.password`](https://bun.sh/docs/api/hashing#bun-password). + +```ts +import { Scrypt } from "lucia"; + +new Scrypt().hash(password); +``` + +**If you're using Bcrypt, [set the maximum password length to 64 _bytes_](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#input-limits-of-bcrypt).** + +```ts +const length = new TextEncoder().encode(password).length; +``` + +## Sign in user + +Create `pages/login.vue` and set up a basic form. + +```vue + + + + +``` + +Create an API route as `server/api/login.post.ts`. First, do a very basic input validation. Get the user with the username and verify the password. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +// server/api/login.post.ts +import { Argon2id } from "oslo/password"; + +export default eventHandler(async (event) => { + const formData = await readFormData(event); + const username = formData.get("username"); + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + throw createError({ + message: "Invalid username", + statusCode: 400 + }); + } + const password = formData.get("password"); + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + throw createError({ + message: "Invalid password", + statusCode: 400 + }); + } + + const existingUser = await db + .table("username") + .where("username", "=", username.toLowerCase()) + .get(); + if (!existingUser) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid usernames from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid usernames. + // However, valid usernames can be already be revealed with the signup page among other methods. + // It will also be much more resource intensive. + // Since protecting against this is none-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If usernames are public, you may outright tell the user that the username is invalid. + throw createError({ + message: "Incorrect username or password", + statusCode: 400 + }); + } + + const validPassword = await new Argon2id().verify(existingUser.password, password); + if (!validPassword) { + throw createError({ + message: "Incorrect username or password", + statusCode: 400 + }); + } + + const session = await lucia.createSession(existingUser.id, {}); + appendHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize()); +}); +``` + +## Validate requests + +You can validate requests by checking `event.context.user`. The field `user.username` is available since we defined the `getUserAttributes()` option. You can protect pages, such as `/`, by redirecting unauthenticated users to the login page. + +```ts +export default defineEventHandler((event) => { + if (event.context.user) { + const username = event.context.user.username; + } + // ... +}); +``` + +## Get user in the client + +Create an API route in `server/api/user.get.ts`. This will just return the current user. + +```ts +// server/api/user.get.ts +export default defineEventHandler((event) => { + return event.context.user; +}); +``` + +Create a composable `useUser()` in `composables/auth.ts`. + +```ts +// composables/auth.ts +import type { User } from "lucia"; + +export const useUser = () => { + const user = useState("user", () => null); + return user; +}; +``` + +Then, create a global middleware in `middleware/auth.global.ts` to populate it. + +```ts +// middleware/auth.global.ts +export default defineNuxtRouteMiddleware(async () => { + const user = useUser(); + user.value = await $fetch("/api/user"); +}); +``` + +You can now use `useUser()` client side to get the current user. + +```vue + +``` + +## Sign out + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +// server/api/logout.post.ts +export default eventHandler(async (event) => { + if (!event.context.session) { + throw createError({ + statusCode: 403 + }); + } + await lucia.invalidateSession(event.context.session.id); + appendHeader(event, "Set-Cookie", lucia.createBlankSessionCookie().serialize()); +}); +``` + +```vue + + + +``` diff --git a/docs/pages/tutorials/username-and-password/sveltekit.md b/docs/pages/tutorials/username-and-password/sveltekit.md new file mode 100644 index 000000000..e42b2b218 --- /dev/null +++ b/docs/pages/tutorials/username-and-password/sveltekit.md @@ -0,0 +1,290 @@ +--- +title: "Tutorial: Username and password auth in SvelteKit" +--- + +# Tutorial: Username and password auth in SvelteKit + +Before starting, make sure you've set up your database and middleware as described in the [Getting started](/getting-started/astro) page. + +An [example project](https://github.com/lucia-auth/examples/tree/v3/sveltekit/username-and-password) based on this tutorial is also available. You can clone the example locally or [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/v3/sveltekit/username-and-password). + +``` +npx degit https://github.com/lucia-auth/examples/tree/v3/sveltekit/username-and-password +``` + +## Update database + +Add a `username` and `hashed_password` column to your user table. + +| column | type | attributes | +| ----------------- | -------- | ---------- | +| `username` | `string` | unique | +| `hashed_password` | `string` | | + +Create a `DatabaseUserAttributes` interface in the module declaration and add your database columns. By default, Lucia will not expose any database columns to the `User` type. To add a `username` field to it, use the `getUserAttributes()` option. + +```ts +import { Lucia } from "lucia"; +import { dev } from "$app/environment"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !dev + } + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + username: attributes.username + }; + } +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + username: string; +} +``` + +## Sign up user + +Create `routes/signup/+page.svelte` and set up a basic form. + +```svelte + + + +

Sign up

+
+ +
+ +
+ +
+``` + +Create a form action in `routes/signup/+page.server.ts`. First, do a very basic input validation. Hash the password, generate a new user ID, and create a new user. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +// routes/signup/+page.server.ts +import { lucia } from "$lib/server/auth"; +import { fail, redirect } from "@sveltejs/kit"; +import { Argon2id } from "oslo/password"; + +import type { Actions } from "./$types"; + +export const actions: Actions = { + default: async (event) => { + const formData = await event.request.formData(); + const username = formData.get("username"); + const password = formData.get("password"); + // username must be between 4 ~ 31 characters, and only consists of lowercase letters, 0-9, -, and _ + // keep in mind some database (e.g. mysql) are case insensitive + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return fail(400, { + message: "Invalid username" + }); + } + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return fail(400, { + message: "Invalid password" + }); + } + + const userId = generateId(15); + const hashedPassword = await new Argon2id().hash(password); + + // TODO: check if username is already used + await db.table("user").insert({ + id: userId, + username: username, + hashed_password: hashedPassword + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + + redirect(302, "/"); + } +}; +``` + +We recommend using Argon2id, but Oslo also provides Scrypt and Bcrypt. These only work in Node.js. If you're planning to deploy your project to a non-Node.js runtime, use `Scrypt` provided by `lucia`. This is a pure JS implementation but 2~3 times slower. For Bun, use [`Bun.password`](https://bun.sh/docs/api/hashing#bun-password). + +```ts +import { Scrypt } from "lucia"; + +new Scrypt().hash(password); +``` + +**If you're using Bcrypt, [set the maximum password length to 64 _bytes_](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#input-limits-of-bcrypt).** + +```ts +const length = new TextEncoder().encode(password).length; +``` + +## Sign in user + +Create `routes/login/+page.svelte` and set up a basic form. + +```svelte + + + +

Sign in

+
+ +
+ +
+ +
+``` + +Create an API route as `pages/api/signup.ts`. First, do a very basic input validation. Get the user with the username and verify the password. If successful, create a new session with `Lucia.createSession()` and set a new session cookie. + +```ts +import { lucia } from "$lib/server/auth"; +import { fail, redirect } from "@sveltejs/kit"; +import { Argon2id } from "oslo/password"; + +import type { Actions } from "./$types"; + +export const actions: Actions = { + default: async (event) => { + const formData = await event.request.formData(); + const username = formData.get("username"); + const password = formData.get("password"); + + if ( + typeof username !== "string" || + username.length < 3 || + username.length > 31 || + !/^[a-z0-9_-]+$/.test(username) + ) { + return fail(400, { + message: "Invalid username" + }); + } + if (typeof password !== "string" || password.length < 6 || password.length > 255) { + return fail(400, { + message: "Invalid password" + }); + } + + const existingUser = await db + .table("username") + .where("username", "=", username.toLowerCase()) + .get(); + if (!existingUser) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid usernames from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid usernames. + // However, valid usernames can be already be revealed with the signup page among other methods. + // It will also be much more resource intensive. + // Since protecting against this is none-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If usernames are public, you may outright tell the user that the username is invalid. + return fail(400, { + message: "Incorrect username or password" + }); + } + + const validPassword = await new Argon2id().verify(existingUser.hashed_password, password); + if (!validPassword) { + return fail(400, { + message: "Incorrect username or password" + }); + } + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + + redirect(302, "/"); + } +}; +``` + +## Validate requests + +You can validate requests by checking `locals.user`. The field `user.username` is available since we defined the `getUserAttributes()` option. You can protect pages, such as `/`, by redirecting unauthenticated users to the login page. + +```ts +// +page.server.ts +import type { PageServerLoad, Actions } from "./$types"; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) redirect(302, "/login"); + return { + username: event.locals.user.username + }; +}; +``` + +## Sign out user + +Sign out users by invalidating their session with `Lucia.invalidateSession()`. Make sure to remove their session cookie by setting a blank session cookie created with `Lucia.createBlankSessionCookie()`. + +```ts +// routes/+page.server.ts +import { lucia } from "$lib/server/auth"; +import { fail, redirect } from "@sveltejs/kit"; + +import type { Actions, PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals }) => { + // ... +}; + +export const actions: Actions = { + default: async (event) => { + if (!event.locals.session) { + return fail(401); + } + await auth.invalidateSession(event.locals.session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: ".", + ...sessionCookie.attributes + }); + redirect(302, "/login"); + } +}; +``` + +```svelte + + + +
+ +
+``` diff --git a/docs/pages/upgrade-v3/index.md b/docs/pages/upgrade-v3/index.md new file mode 100644 index 000000000..af0b36b91 --- /dev/null +++ b/docs/pages/upgrade-v3/index.md @@ -0,0 +1,193 @@ +--- +title: "Upgrade to Lucia v3" +--- + +# Upgrade to Lucia v3 + +Version 3.0 rethinks Lucia and the role it should play in your application. We have stripped out all the annoying bits, and everything else we kept has been refined even more. Everything is more flexible, and just all around easier to understand and work with. + +We estimate it will take about an hour or two to upgrade your project, though it depends on how big your application is. If you're having issues with the migration or have any questions, feel free to ask on our [Discord server](https://discord.com/invite/PwrK3kpVR3). + +## Major changes + +The biggest change to Lucia is that keys have been removed entirely. We believe it was too limiting and ultimately an unnecessary concept that made many projects more complex than they needed to be. Another big change is that Lucia no longer handles user creation, so `createUser()` among other APIs has been removed. + +For a simple password-based auth, the password can just be stored in the user table. + +```ts +const hashedPassword = await new Argon2id().hash(password); +const userId = generateId(15); + +await db.table("user").insert({ + id: userId, + email, + hashed_password: hashedPassword +}); +``` + +Another change is that APIs for request handling have been removed. We now just provide code snippets in the docs that you can copy-paste. + +Lucia is now built with [Oslo](https://oslo.js.org), a library that provides useful auth-related utilities. While not required, we recommend installing it alongside Lucia as all guides in the documentation use it some way or another. + +``` +npm install lucia@beta oslo +``` + +## Initialize Lucia + +Here's the base config. Lucia is now initialized using the `Lucia` class, which takes an adapter and an options object. **Make sure to configure the `sessionCookie` option**. + +```ts +import { Lucia, TimeSpan } from "lucia"; +import { astro } from "lucia/middleware"; + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: env === "PRODUCTION" // replaces `env` config + } + } +}); +``` + +Here's the fully updated configuration for reference. `middleware` and `csrfProtection` have been removed. + +```ts +import { Lucia, TimeSpan } from "lucia"; +import { astro } from "lucia/middleware"; + +export const lucia = new Lucia(adapter, { + getSessionAttributes: (attributes) => { + return { + ipCountry: attributes.ip_country + }; + }, + getUserAttributes: (attributes) => { + return { + username: attributes.username + }; + }, + sessionExpiresIn: new TimeSpan(30, "d"), // no more active/idle + sessionCookie: { + name: "session", + expires: false, // session cookies have very long lifespan (2 years) + attributes: { + secure: true, + sameSite: "strict", + domain: "example.com" + } + } +}); +``` + +### Type declaration + +Lucia v3 uses the newer module syntax instead of `.d.ts` files for declaring types for improved ergonomics and monorepo support. The `Lucia` type declaration is required. + +```ts +export const lucia = new Lucia(); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseSessionAttributes: DatabaseSessionAttributes; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseSessionAttributes { + country: string; +} +interface DatabaseUserAttributes { + username: string; +} +``` + +### Polyfill + +`lucia/polyfill/node` has been removed. Manually polyfill the Web Crypto API by importing the `crypto` module. + +```ts +import { webcrypto } from "node:crypto"; + +globalThis.crypto = webcrypto as Crypto; +``` + +## Update your database + +Refer to each database migration guide: + +- [Mongoose](/upgrade-v3/mongoose) +- [MySQL](/upgrade-v3/mysql) +- [PostgreSQL](/upgrade-v3/postgresql) +- [Prisma](/upgrade-v3/prisma) +- [SQLite](/upgrade-v3/sqlite) + +The following packages are deprecated: + +- `@lucia-auth/adapter-mongoose` (see Mongoose migration guide) +- `@lucia-auth/adapter-session-redis` +- `@lucia-auth/adapter-session-unstorage` + +If you're using a session adapter, we recommend building a custom adapter as the API has been greatly simplified. + +## Sessions + +### Session validation + +Middleware, `Auth.handleRequest()`, and `AuthRequest` have been removed. **This means Lucia no longer provides strict CSRF protection**. For replacing `AuthRequest.validate()`, see the [Validating session cookies](/guides/validate-session-cookies) guide or a framework-specific version of it as these need to be re-implemented from scratch (though it's just copy-pasting code from the guides): + +- [Astro](/guides/validate-session-cookies/astro) +- [Elysia](/guides/validate-session-cookies/elysia) +- [Express](/guides/validate-session-cookies/express) +- [Hono](/guides/validate-session-cookies/hono) +- [Next.js App router](/guides/validate-session-cookies/nextjs-app) +- [Next.js Pages router](/guides/validate-session-cookies/nextjs-pages) +- [Nuxt](/guides/validate-session-cookies/nuxt) +- [SvelteKit](/guides/validate-session-cookies/sveltekit) + +`Session.sessionId` has been renamed to `Session.id` + +```ts +const sessionId = session.id; +``` + +`validateSession()` no longer throws an error when the session is invalid, and returns an object of `User` and `Session` instead. + +```ts +// v3 +const { session, user } = await auth.validateSession(sessionId); +if (!session) { + // invalid session +} +``` + +### Session cookies + +`createSessionCookie()` now takes a session ID instead of a session object, and `createBlankSessionCookie()` should be used for creating blank session cookies. + +```ts +const sessionCookie = auth.createSessionCookie(session.id); +const blankSessionCookie = auth.createBlankSessionCookie(); +``` + +## Update authentication + +Refer to these guides: + +- [Upgrade OAuth setup to v3](/upgrade-v3/oauth) +- [Upgrade Password-based auth to v3](/upgrade-v3/password) + +## Next.js + +If you installed Oslo, mark its dependencies as external to prevent it from getting bundled. This is only required when using the `oslo/password` module. + +```ts +// next.config.ts +const nextConfig = { + webpack: (config) => { + config.externals.push("@node-rs/argon2", "@node-rs/bcrypt"); + return config; + } +}; +``` diff --git a/docs/pages/upgrade-v3/mongoose.md b/docs/pages/upgrade-v3/mongoose.md new file mode 100644 index 000000000..893565310 --- /dev/null +++ b/docs/pages/upgrade-v3/mongoose.md @@ -0,0 +1,173 @@ +--- +title: "Upgrade your Mongoose project to v3" +--- + +# Upgrade your Mongoose project to v3 + +Read this guide carefully as some parts depend on your current structure (**especially the collection names**), and feel free to ask questions on our Discord server if you have any questions. + +## Update the adapter + +The Mongoose adapter has been replaced with the MongoDB adapter. + +``` +npm install @lucia-auth/adapter-mongodb@beta +``` + +Initialize the adapter: + +```ts +import { MongoDBAdapter } from "@lucia-auth/adapter-mongodb"; +import mongoose from "mongoose"; + +const adapter = new MongodbAdapter( + mongoose.connection.collection("sessions"), + mongoose.connection.collection("users") +); +``` + +## Update the session collection + +Replace the `idle_expires` field with `expires_at` and update the Mongoose schema accordingly. + +```ts +db.sessions.updateMany({}, [ + { + $set: { + expires_at: { $toDate: "$idle_expires" } + } + }, + { + $unset: ["idle_expires", "active_expires"] + } +]); +``` + +```ts +import mongoose from "mongoose"; + +const Session = mongoose.model( + "Session", + new mongoose.Schema( + { + _id: { + type: String, + required: true + }, + user_id: { + type: String, + required: true + }, + expires_at: { + type: Date, + required: true + } + } as const, + { _id: false } + ) +); +``` + +## Replace the key collection + +Keys have been removed. You can keep using them but you may want to update your schema to better align with MongoDB. + +### OAuth accounts + +This database command adds a `github_id` field to users with a GitHub account based on the key collection. + +```ts +db.users.aggregate([ + { + $lookup: { + from: "keys", + localField: "_id", + foreignField: "user_id", + as: "github_accounts", + pipeline: [ + { + $match: { + _id: { + $regex: /^github:/ + } + } + } + ] + } + }, + { + $match: { + $expr: { + $gt: [{ $size: "$github_accounts" }, 0] + } + } + }, + { + $set: { + github_id: { + $replaceOne: { + input: { $arrayElemAt: ["$github_accounts._id", 0] }, + find: "github:", + replacement: "" + } + } + } + }, + { + $unset: ["github_accounts"] + }, + { + $merge: { + into: "users", + whenMatched: "merge" + } + } +]); +``` + +### Password accounts + +This database command moves the `hashed_password` field from the keys collection to the users collection. + +```ts +db.users.aggregate([ + { + $lookup: { + from: "keys", + localField: "_id", + foreignField: "user_id", + as: "password_accounts", + pipeline: [ + { + $match: { + hashed_password: { + $ne: null + } + } + } + ] + } + }, + { + $match: { + $expr: { + $gt: [{ $size: "$password_accounts" }, 0] + } + } + }, + { + $set: { + hashed_password: { $arrayElemAt: ["$password_accounts.hashed_password", 0] } + } + }, + { + $unset: ["password_accounts"] + }, + { + $merge: { + into: "users", + whenMatched: "merge" + } + } +]); +``` diff --git a/docs/pages/upgrade-v3/mysql.md b/docs/pages/upgrade-v3/mysql.md new file mode 100644 index 000000000..5273b6fd4 --- /dev/null +++ b/docs/pages/upgrade-v3/mysql.md @@ -0,0 +1,98 @@ +--- +title: "Upgrade your MySQL database to v3" +--- + +# Upgrade your MySQL database to v3 + +**Migration must be handled manually or else you will lose all your data**. **Do NOT use automated tools as is.** Read this guide carefully as some parts depend on your current structure (**especially the table names**), and feel free to ask questions on our Discord server if you have any questions. + +## Update the adapter + +Install the latest version of the MySQL adapter package. + +``` +npm install @lucia-auth/adapter-mysql@beta +``` + +Initialize the adapter: + +```ts +import { Mysql2Adapter, PlanetScaleAdapter } from "@lucia-auth/adapter-mysql"; + +new Mysql2Adapter(pool, { + // table names + user: "user", + session: "user_session" +}); + +new PlanetScaleAdapter(connection, { + // table names + user: "user", + session: "user_session" +}); +``` + +## Update session table + +The main change to the session table is that the `idle_expires` and `active_expires` columns are replaced with a single `expires_at` column. Unlike the previous columns, it's a `DATETIME` column. + +**Check your table names before running the code.** + +```sql +ALTER TABLE user_session ADD expires_at DATETIME; + +UPDATE user_session SET expires_at = FROM_UNIXTIME(idle_expires / 1000); + +ALTER TABLE user_session DROP active_expires, DROP idle_expires, MODIFY expires_at DATETIME NOT NULL; +``` + +You may also just delete the session table and replace it with the [new schema](/database/mysql#schema). + +## Replace key table + +You can keep using the key table, but we recommend using dedicated tables for each authentication method. + +### OAuth + +The SQL below creates a dedicated table `oauth_account` for storing all user OAuth accounts. This assumes all keys where `hashed_password` column is null are for OAuth accounts. You may also separate them by the OAuth provider. You should adjust the `VARCHAR` length accordingly. + +```sql +CREATE TABLE oauth_account ( + provider_id VARCHAR(255) NOT NULL, + provider_user_id VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL REFERENCES user(id), + PRIMARY KEY (provider_id, provider_user_id) +); + +INSERT INTO oauth_account (provider_id, provider_user_id, user_id) +SELECT SUBSTRING(id, 1, POSITION(':' IN id)-1), SUBSTRING(id, POSITION(':' IN id)+1), user_id FROM user_key +WHERE hashed_password IS NULL; +``` + +### Email/password + +The SQL below creates a dedicated table `password` for storing user passwords. This assumes the provider ID for emails was `email` and that you're already storing the users' emails in the user table. + +```sql +CREATE TABLE password ( + id INT PRIMARY KEY AUTO_INCREMENT, + hashed_password VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL REFERENCES user(id) +); + +INSERT INTO password (hashed_password, user_id) +SELECT hashed_password, user_id FROM user_key +WHERE SUBSTRING(id, 1, POSITION(':' IN id)-1) = 'email'; +``` + +Alternatively, you can store the user's credentials in the user table if you only work with email/password. + +```sql +ALTER TABLE user ADD hashed_password VARCHAR(255); + +UPDATE user INNER JOIN user_key ON user_key.user_id = user.id +SET user.hashed_password = user_key.hashed_password +WHERE user_key.hashed_password IS NOT NULL; + +ALTER TABLE user MODIFY hashed_password VARCHAR(255) NOT NULL; +``` diff --git a/docs/pages/upgrade-v3/oauth.md b/docs/pages/upgrade-v3/oauth.md new file mode 100644 index 000000000..cd00face5 --- /dev/null +++ b/docs/pages/upgrade-v3/oauth.md @@ -0,0 +1,152 @@ +--- +title: "Upgrade OAuth setup to v3" +--- + +# Upgrade OAuth setup to v3 + +## Update database + +You can continue using the keys table but we recommend creating a dedicated table for storing OAuth accounts, as shown in the database migration guides. + +## Replace OAuth integration + +The OAuth integration has been replaced with [Arctic](https://github.com/pilcrowonpaper/arctic), which provides everything the integration did without Lucia-specific APIs. It supports all the OAuth providers that the integration supported. + +``` +npm install arctic +``` + +You can initialize the providers without passing the Lucia instance and it does not accept scopes. + +```ts +import { GitHub } from "arctic"; + +export const githubAuth = new GitHub(GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET); +export const googleAuth = new Google( + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + "http://localhost:3000/login/github/callback" +); +``` + +## Create authorization URL + +`createAuthorizationURL()` replaces `getAuthorizationUrl()`. State and code verifier must generated on your side. + +```ts +import { generateState, generateCodeVerifier } from "arctic"; + +// generate state +const state = generateState(); + +// pass state (and code verifier for PKCE) +// returns the authorization url only +const authorizationURL = await githubAuth.createAuthorizationURL(state, { + scopes: ["email"] // pass scopes here instead +}); + +setCookie("github_oauth_state", state, { + secure: true, // set to false in localhost + path: "/", + httpOnly: true, + maxAge: 60 * 10 // 10 min +}); + +// redirect to authorization url +``` + +## Validate callback + +The `state` check stays the same. + +`validateAuthorizationCode()` replaces `validateCallback()`. Instead of returning tokens, users, and database methods, it just returns tokens. Use the access token to get the user, then check if the user is already registered and create a new user if they aren't. + +You now have to create users and manage OAuth accounts by yourself. + +```ts +import { generateId } from "lucia"; + +// check for state +// ... + +// only returns tokens +const tokens = await githubAuth.validateAuthorizationCode(code); + +// use the access token to get the user +const githubUser = await githubAuth.getUser(tokens.accessToken); + +const existingAccount = await db + .table("oauth_account") + .where("provider_id", "=", "github") + .where("provider_user_id", "=", githubUser.id) + .get(); + +if (existingAccount) { + // simplified `createSession()` - second param for session attributes + const session = await lucia.createSession(existingUser.id, {}); + + // `createSessionCookie()` now takes a session ID instead of the entire session object + const sessionCookie = lucia.createSessionCookie(session.id); + + // set session cookie as usual (using `Response` as example) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } + }); +} + +// v2 IDs have a length of 15 +const userId = generateId(15); + +await db.beginTransaction(); +// create user manually +await db.table("user").insert({ + id: userId, + username: github.login +}); +// store oauth account +await db.table("oauth_account").insert({ + provider_id: "github", + provider_user_id: githubUser.id, + user_id: userId +}); +await db.commit(); + +// simplified `createSession()` - second param for session attributes +const session = await lucia.createSession(userId, {}); +// `createSessionCookie()` now takes a session ID instead of the entire session object +const sessionCookie = lucia.createSessionCookie(session.id); +// set session cookie as usual (using `Response` as example) +return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } +}); +``` + +### Error handling + +Error handling has improved with v3. `validateAuthorizationCode()` throws an `OAuth2RequestError`, which includes proper error messages and descriptions. + +```ts +try { + const tokens = await githubAuth.validateAuthorizationCode(code); + // ... +} catch (e) { + console.log(e); + if (e instanceof OAuth2RequestError) { + // bad verification code, invalid credentials, etc + return new Response(null, { + status: 400 + }); + } + return new Response(null, { + status: 500 + }); +} +``` diff --git a/docs/pages/upgrade-v3/password.md b/docs/pages/upgrade-v3/password.md new file mode 100644 index 000000000..1899d4f17 --- /dev/null +++ b/docs/pages/upgrade-v3/password.md @@ -0,0 +1,77 @@ +--- +title: "Upgrade password-based auth to v3" +--- + +# Upgrade password-based auth to v3 + +## Update database + +You can continue using the keys table but we recommend either creating a dedicated table for storing passwords or storing passwords in the user table, as shown in the database migration guides. + +## Create users + +Lucia provides `LegacyScrypt` for hashing and comparing passwords using the algorithm used in v1 and v2. For future projects, we recommend using `Argon2id` or `Scrypt` provided by Oslo. + +```ts +import { generateId, LegacyScrypt } from "lucia"; + +// v2 IDs have a length of 15 +const userId = generateId(15); + +await db.beginTransaction(); +// create user manually +await db.table("user").insert({ + id: userId, + username +}); +// store oauth account +await db.table("password").insert({ + hashed_password: await new LegacyScrypt().hash(password), + user_id: userId +}); +await db.commit(); + +// simplified `createSession()` - second param for session attributes +const session = await lucia.createSession(userId, {}); +// `createSessionCookie()` now takes a session ID instead of the entire session object +const sessionCookie = lucia.createSessionCookie(session.id); +// set session cookie as usual (using `Response` as example) +return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize() + } +}); +``` + +## Authenticate users + +Use `verify()` to validate passwords. + +```ts +import { LegacyScrypt } from "lucia"; + +// using consecutive queries to simplify example but you can use joins +const user = await db.table("user").where("username", "=", username).get(); +if (!user) { + return new Response("Invalid username or password", { + status: 400 + }); +} +const credentials = await db.table("password").where("user_id", "=", user.id).get(); +if (!user) { + return new Response("Invalid username or password", { + status: 400 + }); +} + +const validPassword = await new LegacyScrypt().verify(credentials.hashed_password, password); +if (!validPassword) { + return new Response("Invalid username or password", { + status: 400 + }); +} + +// create sessions... +``` diff --git a/docs/pages/upgrade-v3/postgresql.md b/docs/pages/upgrade-v3/postgresql.md new file mode 100644 index 000000000..6ff8c87a7 --- /dev/null +++ b/docs/pages/upgrade-v3/postgresql.md @@ -0,0 +1,115 @@ +--- +title: "Upgrade your PostgreSQL database to v3" +--- + +# Upgrade your PostgreSQL database to v3 + +**Migration must be handled manually or else you will lose all your data**. **Do NOT use automated tools as is.** Read this guide carefully as some parts depend on your current structure (**especially the table names**), and feel free to ask questions on our Discord server if you have any questions. + +## Update the adapter + +Install the latest version of the PostgreSQL adapter package. + +``` +npm install @lucia-auth/adapter-postgresql@beta +``` + +Initialize the adapter: + +```ts +import { NodePostgresAdapter, PostgresJsAdapter } from "@lucia-auth/adapter-postgresql"; + +// previously named `pg` adapter +new NodePostgresAdapter(pool, { + // table names + user: "auth_user", + session: "user_session" +}); + +// previously named `postgres` adapter +new PostgresJsAdapter(sql, { + // table names + user: "auth_user", + session: "user_session" +}); +``` + +## Update session table + +The main change to the session table is that the `idle_expires` and `active_expires` columns are replaced with a single `expires_at` column. Unlike the previous columns, it's a `DATETIME` column. + +**Check your table names before running the code.** + +```sql +START TRANSACTION; + +ALTER TABLE user_session ADD COLUMN expires_at TIMESTAMPTZ; + +UPDATE user_session SET expires_at = to_timestamp(idle_expires / 1000); + +ALTER TABLE user_session +DROP COLUMN active_expires, +DROP COLUMN idle_expires, +ALTER COLUMN expires_at SET NOT NULL; +``` + +Do a final check and commit the transaction. + +```sql +COMMIT; +``` + +You may also just delete the session table and replace it with the [new schema](/database/postgresql#schema). + +## Replace key table + +You can keep using the key table, but we recommend using dedicated tables for each authentication method. + +### OAuth + +The SQL below creates a dedicated table `oauth_account` for storing all user OAuth accounts. This assumes all keys where `hashed_password` column is null are for OAuth accounts. You may also separate them by the OAuth provider. + +```sql +CREATE TABLE oauth_account ( + provider_id TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES auth_user(id), + PRIMARY KEY (provider_id, provider_user_id) +); + +INSERT INTO oauth_account (provider_id, provider_user_id, user_id) +SELECT SUBSTRING(id, 1, POSITION(':' IN id)-1), SUBSTRING(id, POSITION(':' IN id)+1), user_id FROM user_key +WHERE hashed_password IS NULL; +``` + +### Email/password + +The SQL below creates a dedicated table `password` for storing user passwords. This assumes the provider ID for emails was `email` and that you're already storing the users' emails in the user table. + +```sql +CREATE TABLE password ( + id SERIAL PRIMARY KEY, + hashed_password TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES auth_user(id) +); + +INSERT INTO password (hashed_password, user_id) +SELECT hashed_password, user_id FROM user_key +WHERE SUBSTRING(id, 1, POSITION(':' IN id)-1) = 'email'; +``` + +Alternatively, you can store the user's credentials in the user table if you only work with email/password. + +```sql +START TRANSACTION; + +ALTER TABLE auth_user ADD COLUMN hashed_password TEXT; + +UPDATE auth_user SET hashed_password = user_key.hashed_password FROM user_key +WHERE user_key.user_id = auth_user.id +AND user_key.hashed_password IS NOT NULL; + +ALTER TABLE auth_user ALTER COLUMN hashed_password SET NOT NULL; + +COMMIT; +``` diff --git a/docs/pages/upgrade-v3/prisma/index.md b/docs/pages/upgrade-v3/prisma/index.md new file mode 100644 index 000000000..f669f6551 --- /dev/null +++ b/docs/pages/upgrade-v3/prisma/index.md @@ -0,0 +1,30 @@ +--- +title: "Upgrade your Prisma project to v3" +--- + +# Upgrade your Prisma project to v3 + +## Update the adapter + +Install the latest version of the Prisma adapter. + +``` +npm install @lucia-auth/adapter-prisma@beta +``` + +Initialize the adapter: + +```ts +import { PrismaClient } from "@prisma/client"; +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; + +const client = new PrismaClient(); + +new PrismaAdapter(client.session, client.user); +``` + +## Update schema and database + +- [MySQL](/upgrade-v3/prisma/mysql) +- [PostgreSQL](/upgrade-v3/prisma/postgresql) +- [SQLite](/upgrade-v3/prisma/sqlite) diff --git a/docs/pages/upgrade-v3/prisma/mysql.md b/docs/pages/upgrade-v3/prisma/mysql.md new file mode 100644 index 000000000..eec21d1cf --- /dev/null +++ b/docs/pages/upgrade-v3/prisma/mysql.md @@ -0,0 +1,125 @@ +--- +title: "Upgrade Prisma and your MySQL database to v3" +--- + +# Upgrade Prisma and your MySQL database to v3 + +The v3 Prisma adapter now requires all fields to be `camelCase`. + +**Migration must be handled manually or else you will lose all your data**. **Do NOT use Prisma's migration tools as is**. Read this guide carefully as some parts depend on your current structure (**especially the table names**), and feel free to ask questions on our Discord server if you have any questions. + +## Update session table + +The main change to the session table is that the `idle_expires` and `active_expires` fields are replaced with a single `expiresAt` field. Unlike the previous columns, it's a `DateTime` type. Update the `Session` model. Make sure to add any custom attributes you previously had. + +```prisma +model Session { + id String @id + userId String + expiresAt DateTime + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +If you're fine with clearing your session table, you can now migrate your database and you're done updating it. + +However, if you'd like to keep your session data, first run `prisma migrate` **with the `--create-only` flag.** + +``` +npx prisma migrate dev --name updated_session --create-only +``` + +Find the migration file inside `prisma/migrations/X_updated_session` and replace it with the SQL below. Make sure to alter it if you have custom session attributes. + +**This script assumes your session and user models are named `Session` and `User`.** + +```sql +ALTER TABLE `Session` ADD `expiresAt` DATETIME(3), DROP FOREIGN KEY `Session_user_id_fkey`; + +UPDATE `Session` SET `expiresAt` = FROM_UNIXTIME(`idle_expires` / 1000); + +ALTER TABLE `Session` +DROP `active_expires`, +DROP `idle_expires`, +RENAME COLUMN `user_id` TO `userId`, +MODIFY `expiresAt` DATETIME(3) NOT NULL, +ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +``` + +Finally, run the migration: + +``` +npx prisma migrate dev --name updated_session +``` + +## Replace key table + +You can keep using the key table, but we recommend using dedicated tables for each authentication method. + +### OAuth + +This creates a dedicated model for user OAuth accounts. + +```prisma +model User { + id String @id + sessions Session[] + oauthAccounts OauthAccount[] +} + +model OauthAccount { + providerId String + providerUserId String + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) + + @@id([providerId, providerUserId]) +} +``` + +Update your database: + +``` +npx prisma migrate dev --name added_oauth_account_table +``` + +Finally, copy the data from the key table. This assumes all keys where `hashed_password` column is null are for OAuth accounts. + +```sql +INSERT INTO `OauthAccount` (`providerId`, `providerUserId`, `userId`) +SELECT SUBSTRING(`id`, 1, POSITION(':' IN `id`)-1), SUBSTRING(`id`, POSITION(':' IN `id`)+1), `user_id` FROM `Key` +WHERE `hashed_password` IS NULL; +``` + +### Email/password + +This creates a dedicated model for user passwords. + +```prisma +model User { + id String @id + sessions Session[] + passwords OauthAccount[] +} + +model Password { + id Int @id @default(autoincrement()) + hashedPassword String + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +Update your database: + +``` +npx prisma migrate dev --name added_password_table +``` + +Finally, copy the data from the key table. This assumes the provider ID for emails was `email` and that you're already storing the users' emails in the user table. + +```sql +INSERT INTO Password (`hashedPassword`, `userId`) +SELECT `hashed_password`, `user_id` FROM `Key` +WHERE SUBSTRING(`id`, 1, POSITION(':' IN `id`)-1) = 'email'; +``` diff --git a/docs/pages/upgrade-v3/prisma/postgresql.md b/docs/pages/upgrade-v3/prisma/postgresql.md new file mode 100644 index 000000000..1adc2ec39 --- /dev/null +++ b/docs/pages/upgrade-v3/prisma/postgresql.md @@ -0,0 +1,128 @@ +--- +title: "Upgrade Prisma and your PostgreSQL database to v3" +--- + +# Upgrade Prisma and your PostgreSQL database to v3 + +The v3 Prisma adapter now requires all fields to be `camelCase`. + +**Migration must be handled manually or else you will lose all your data**. **Do NOT use Prisma's migration tools as is**. Read this guide carefully as some parts depend on your current structure (**especially the table names**), and feel free to ask questions on our Discord server if you have any questions. + +## Update session table + +The main change to the session table is that the `idle_expires` and `active_expires` fields are replaced with a single `expiresAt` field. Unlike the previous columns, it's a `DateTime` type. Update the `Session` model. Make sure to add any custom attributes you previously had. + +```prisma +model Session { + id String @id + userId String + expiresAt DateTime + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +If you're fine with clearing your session table, you can now migrate your database and you're done updating it. + +However, if you'd like to keep your session data, first run `prisma migrate` **with the `--create-only` flag.** + +``` +npx prisma migrate dev --name updated_session --create-only +``` + +Find the migration file inside `prisma/migrations/X_updated_session` and replace it with the SQL below. Make sure to alter it if you have custom session attributes. + +**This script assumes your session and user models are named `Session` and `User`.** + +```sql +ALTER TABLE "Session" +DROP CONSTRAINT "Session_user_id_fkey", +ADD COLUMN "expiresAt" TIMESTAMP(3); + +UPDATE "Session" SET "expiresAt" = to_timestamp("idle_expires" / 1000); + +ALTER TABLE "Session" RENAME COLUMN "user_id" TO "userId"; + +ALTER TABLE "Session" +DROP COLUMN "active_expires", +DROP COLUMN "idle_expires", +ALTER COLUMN "expiresAt" SET NOT NULL, +ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +``` + +Finally, run the migration: + +``` +npx prisma migrate dev --name updated_session +``` + +## Replace key table + +You can keep using the key table, but we recommend using dedicated tables for each authentication method. + +### OAuth + +This creates a dedicated model for user OAuth accounts. + +```prisma +model User { + id String @id + sessions Session[] + oauthAccounts OauthAccount[] +} + +model OauthAccount { + providerId String + providerUserId String + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) + + @@id([providerId, providerUserId]) +} +``` + +Update your database: + +``` +npx prisma migrate dev --name added_oauth_account_table +``` + +Finally, copy the data from the key table. This assumes all keys where `hashed_password` column is null are for OAuth accounts. + +```sql +INSERT INTO "OauthAccount" ("providerId", "providerUserId", "userId") +SELECT SUBSTRING("id", 1, POSITION(':' IN "id")-1), SUBSTRING("id", POSITION(':' IN id)+1), "user_id" FROM "Key" +WHERE "hashed_password" IS NULL; +``` + +### Email/password + +This creates a dedicated model for user passwords. + +```prisma +model User { + id String @id + sessions Session[] + passwords OauthAccount[] +} + +model Password { + id Int @id @default(autoincrement()) + hashedPassword String + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +Update your database: + +``` +npx prisma migrate dev --name added_password_table +``` + +Finally, copy the data from the key table. This assumes the provider ID for emails was `email` and that you're already storing the users' emails in the user table. + +```sql +INSERT INTO "Password" ("hashedPassword", "userId") +SELECT "hashed_password", "user_id" FROM "Key" +WHERE SUBSTRING("id", 1, POSITION(':' IN "id")-1) = 'email'; +``` diff --git a/docs/pages/upgrade-v3/prisma/sqlite.md b/docs/pages/upgrade-v3/prisma/sqlite.md new file mode 100644 index 000000000..0f82db946 --- /dev/null +++ b/docs/pages/upgrade-v3/prisma/sqlite.md @@ -0,0 +1,127 @@ +--- +title: "Upgrade Prisma and your SQLite database to v3" +--- + +# Upgrade Prisma and your SQLite database to v3 + +The v3 Prisma adapter now requires all fields to be `camelCase`. + +**Migration must be handled manually or else you will lose all your data**. **Do NOT use Prisma's migration tools as is**. Read this guide carefully as some parts depend on your current structure (**especially the table names**), and feel free to ask questions on our Discord server if you have any questions. + +## Update session table + +The main change to the session table is that the `idle_expires` and `active_expires` fields are replaced with a single `expiresAt` field. Unlike the previous columns, it's a `DateTime` type. Update the `Session` model. Make sure to add any custom attributes you previously had. + +```prisma +model Session { + id String @id + userId String + expiresAt DateTime + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +If you're fine with clearing your session table, you can now migrate your database and you're done updating it. + +However, if you'd like to keep your session data, first run `prisma migrate` **with the `--create-only` flag.** + +``` +npx prisma migrate dev --name updated_session --create-only +``` + +Find the migration file inside `prisma/migrations/X_updated_session` and replace it with the SQL below. Make sure to alter it if you have custom session attributes. + +**This script assumes your session and user models are named `Session` and `User`.** + +```sql +CREATE TABLE "new_Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL REFERENCES "User"("id"), + "expiresAt" DATETIME NOT NULL +); + +INSERT INTO "new_Session" ("id", "userId", "expiresAt") +SELECT "id", "user_id", "idle_expires" FROM "Session"; + +DROP TABLE "Session"; + +ALTER TABLE "new_Session" RENAME TO "Session"; +``` + +Finally, run the migration: + +``` +npx prisma migrate dev --name updated_session +``` + +## Replace key table + +You can keep using the key table, but we recommend using dedicated tables for each authentication method. + +### OAuth + +This creates a dedicated model for user OAuth accounts. + +```prisma +model User { + id String @id + sessions Session[] + oauthAccounts OauthAccount[] +} + +model OauthAccount { + providerId String + providerUserId String + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) + + @@id([providerId, providerUserId]) +} +``` + +Update your database: + +``` +npx prisma migrate dev --name added_oauth_account_table +``` + +Finally, copy the data from the key table. This assumes all keys where `hashed_password` column is null are for OAuth accounts. + +```sql +INSERT INTO "OauthAccount" ("providerId", "providerUserId", "userId") +SELECT substr("id", 1, instr("id", ':')-1), substr("id", instr("id", ':')+1), "user_id" FROM "Key" +WHERE "hashed_password" IS NULL; +``` + +### Email/password + +This creates a dedicated model for user passwords. + +```prisma +model User { + id String @id + sessions Session[] + passwords OauthAccount[] +} + +model Password { + id Int @id @default(autoincrement()) + hashedPassword String + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} +``` + +Update your database: + +``` +npx prisma migrate dev --name added_password_table +``` + +Finally, copy the data from the key table. This assumes the provider ID for emails was `email` and that you're already storing the users' emails in the user table. + +```sql +INSERT INTO "Password" ("hashedPassword", "userId") +SELECT "hashed_password", "user_id" FROM "Key" +WHERE substr("id", 1, instr("id", ':')-1) = 'email'; +``` diff --git a/docs/pages/upgrade-v3/sqlite.md b/docs/pages/upgrade-v3/sqlite.md new file mode 100644 index 000000000..af5546ba8 --- /dev/null +++ b/docs/pages/upgrade-v3/sqlite.md @@ -0,0 +1,145 @@ +--- +title: "Upgrade your SQLite database to v3" +--- + +# Upgrade your SQLite database to v3 + +**Migration must be handled manually or else you will lose all your data**. **Do NOT use automated tools as is.** Read this guide carefully as some parts depend on your current structure (**especially the table names**), and feel free to ask questions on our Discord server if you have any questions. + +## Update the adapter + +Install the latest version of the SQLite adapter package. + +``` +npm install @lucia-auth/adapter-sqlite@beta +``` + +Initialize the adapter: + +```ts +import { + BetterSqlite3Adapter, + CloudflareD1Adapter, + LibSQLAdapter +} from "@lucia-auth/adapter-sqlite"; + +new BetterSqlite3Adapter(db, { + // table names + user: "user", + session: "session" +}); + +new CloudflareD1Adapter(d1, { + // table names + user: "user", + session: "session" +}); + +new LibSQLAdapter(db, { + // table names + user: "user", + session: "session" +}); +``` + +## Update session table + +The main change to the session table is that the `idle_expires` and `active_expires` columns are replaced with a single `expires_at` column. Unlike the previous columns, this takes a UNIX time in _seconds_. + +Make sure to use transactions and add any additional columns in your existing session table when creating the new table and copying the data. + +**Check your table names before running the code.** + +```sql +BEGIN TRANSACTION; + +CREATE TABLE new_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id), + expires_at INTEGER NOT NULL +); + +INSERT INTO new_session (id, user_id, expires_at) +SELECT id, user_id, idle_expires / 1000 FROM session; + +DROP TABLE session; + +ALTER TABLE new_session RENAME TO session; +``` + +Check your new `session` table looks right. If not run `ROLLBACK` to rollback the transaction. If you're ready, run `COMMIT` to commit the transaction: + +```sql +COMMIT; +``` + +You may also just delete the session table and replace it with the [new schema](/database/sqlite#schema). + +## Replace key table + +You can keep using the key table, but we recommend using dedicated tables for each authentication method. + +### OAuth + +The SQL below creates a dedicated table `oauth_account` for storing all user OAuth accounts. This assumes all keys where `hashed_password` column is null are for OAuth accounts. You may also separate them by the OAuth provider. + +```sql +CREATE TABLE oauth_account ( + provider_id TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES user(id), + PRIMARY KEY (provider_id, provider_user_id) +); + +INSERT INTO oauth_account (provider_id, provider_user_id, user_id) +SELECT substr(id, 1, instr(id, ':')-1), substr(id, instr(id, ':')+1), user_id FROM key +WHERE hashed_password IS NULL; +``` + +### Email/password + +The SQL below creates a dedicated table `password` for storing user passwords. This assumes the provider ID for emails was `email` and that you're already storing the users' emails in the user table. + +```sql +CREATE TABLE password ( + hashed_password TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES user(id) +); + +INSERT INTO password (hashed_password, user_id) +SELECT hashed_password, user_id FROM key +WHERE substr(id, 1, instr(id, ':')-1) = 'email'; +``` + +Alternatively, you can store the user's credentials in the user table if you only work with email/password. Unfortunately, since SQLite's `ALTER` statement only supports a limited number of operations, you'd have to recreate tables that reference the user table. + +```sql +BEGIN TRANSACTION; + +CREATE TABLE new_user ( + id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + hashed_password TEXT NOT NULL +); + +INSERT INTO new_user (id, email, hashed_password) +SELECT user.id, email, hashed_password FROM user INNER JOIN key ON key.user_id = user.id +WHERE hashed_password IS NOT NULL; + +CREATE TABLE new_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id), + expires_at INTEGER NOT NULL +); + +INSERT INTO new_session (id, user_id, expires_at) +SELECT id, user_id, expires_at FROM session; + +DROP TABLE session; +DROP TABLE user; + +ALTER TABLE new_user RENAME TO user; +ALTER TABLE new_session RENAME TO session; + +COMMIT; +``` diff --git a/documentation/.gitignore b/documentation/.gitignore deleted file mode 100644 index 6240da8b1..000000000 --- a/documentation/.gitignore +++ /dev/null @@ -1,21 +0,0 @@ -# build output -dist/ -# generated types -.astro/ - -# dependencies -node_modules/ - -# logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - - -# environment variables -.env -.env.production - -# macOS-specific files -.DS_Store diff --git a/documentation/README.md b/documentation/README.md deleted file mode 100644 index 2e93af697..000000000 --- a/documentation/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Lucia v2 Documentation - -Documentation for Lucia v2 **WORK IN PROGRESS** - -## Development - -### Install dependencies - -``` -pnpm i -``` - -### Run server - -``` -pnpm dev -``` diff --git a/documentation/astro.config.mjs b/documentation/astro.config.mjs deleted file mode 100644 index 1574e775f..000000000 --- a/documentation/astro.config.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from "astro/config"; -import markdown from "./integrations/markdown"; -import og from "./integrations/og"; -import search from "./integrations/search"; -import tailwind from "@astrojs/tailwind"; - -import { rehypeHeadingIds } from "@astrojs/markdown-remark"; - -// https://astro.build/config -export default defineConfig({ - integrations: [tailwind(), markdown(), og(), search()], - markdown: { - rehypePlugins: [rehypeHeadingIds], - shikiConfig: { - theme: "min-light" - } - }, - redirects: { - "/github": "https://github.com/lucia-auth/lucia", - "/discord": "https://discord.gg/PwrK3kpVR3" - } -}); diff --git a/documentation/content/blog/lucia-1.md b/documentation/content/blog/lucia-1.md deleted file mode 100644 index 8a5596e4c..000000000 --- a/documentation/content/blog/lucia-1.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: "Lucia 1.0" -description: "Announcing Lucia 1.0!" ---- - -We are thrilled to announce Lucia v1.0! Watch the [announcement video]("https://www.youtube.com/watch?v=j8lyUqKdmJQ")! - -Lucia is a server-side authentication library for TypeScript that aims to be unintrusive, straightforward, and flexible. - -At its core, it's a library for managing users and sessions, providing the building blocks for setting up auth just how you want. Database adapters allow Lucia to be used with any modern ORMs/databases and integration packages make it easy to implement things like OAuth. - -Here's what working with Lucia looks like: - -```ts -const user = await auth.createUser({ - // how to identify user for authentication? - primaryKey: { - providerId: "email", // using email - providerUserId: "user@example.com", // email to use - password: "123456" - }, - // custom attributes - attributes: { - email: "user@example.com" - } -}); -const session = await auth.createSession(user.userId); -const sessionCookie = auth.createSessionCookie(session); -``` - -It started off as a small JWT-based library for SvelteKit made over summer break. Over the last few months, we switched to sessions, made it framework agnostic, added keys and OAuth support, and cleaned up the APIs. Even with all the breaking changes, it's still a huge passion project that aims to provide a solution between something fully custom and ready-made. - -Our core approach remained the same as well. Simple is better than easy. Things should be obvious and easy to understand. APIs should be applicable to a wide range of scenarios, even when a bit verbose. Being easy is nice at the start, but unfortunately leads to endless configuration and callbacks. We also believe documentation and learning resources are crucial to a library's success and have spent countless hours on it. - -As of writing, the project has over 750 stars and nearly 3,000 weekly downloads. Special thanks to: - -- [SkepticMystic](https://github.com/SkepticMystic) -- [Tazor](https://github.com/TazorDE) -- [CokaKoala](https://github.com/AdrianGonz97) -- [Faey](https://github.com/FaeyUmbrea) -- [Huntabyte](https://github.com/huntabyte) -- [dawidmachon](https://github.com/dawidmachon) - -and, of course, a big thanks to everyone who has contributed! [Valentin Rogg](https://github.com/v-rogg), [Blastose](https://github.com/Blastose), [Ingo Krumbein](https://github.com/Jings), [Felipe dos Santos](https://github.com/ffss92), [Dana Woodman](https://github.com/danawoodman), [Alexander Way](https://github.com/alex-way), [captaindirgo](https://github.com/captaindirgo), [Christopher Pfohl](https://github.com/Crisfole), [Jean-Cédric Huet](https://github.com/BiscuiTech), [Johan Karlsson](https://github.com/JouanDeag), [Oscar Beaumont](https://github.com/oscartbeaumont), [Parables Boltnoel](https://github.com/Parables), [CA Gustavo](https://github.com/gustavocadev), [Zach](https://github.com/zach-hopkins), [Boian Ivanov](https://github.com/boian-ivanov), [Fabian Merino](https://github.com/fabianmerino), [Jasper Kelder](https://github.com/JasperKelder), [Jeremy Schoonover](https://github.com/skoontastic), [Jordan Calhoun](https://github.com/jordancalhoun), [Kelby Faessler](https://github.com/kelbyfaessler), [Lih Haur Voon](https://github.com/leovoon), [Marvin](https://github.com/m4rvr), [Mathis Côté](https://github.com/BenocxX), [Oskar](https://github.com/oskar-gmerek), [Roga](https://github.com/rogadev),[Thomas Slater](https://github.com/taslater), [VoiceOfSoftware](https://github.com/VoiceOfSoftware), [hffeka](https://github.com/hffeka), [moka-ayumu](https://github.com/moka-ayumu), [weepy](https://github.com/weepy) - -**Ready to update? Read the [migration guide!](https://lucia-auth.com/migrate-to-version-1)** diff --git a/documentation/content/blog/lucia-2.md b/documentation/content/blog/lucia-2.md deleted file mode 100644 index 0005abb89..000000000 --- a/documentation/content/blog/lucia-2.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: "Lucia 2.0" -description: "Announcing Lucia 2.0" ---- - -We're super excited to announce Lucia 2.0! - -This update brings new big features to the library as well as major improvements to our APIs. A big focus for 2.0 was bringing stability, and that included future proofing it. This should mean 2.0 marks the end of our "rapid development" stage. - -There is unfortunately a slew of breaking changes, but it shouldn't take more than 20 minutes for most users. At worst, it should only take an hour or so. Please read the [migration guide](/migrate/v2) for details. And, if you encounter any issues, feel free to ask them on our Discord server! - -You might've noticed that we updated our docs (again)! Some key features are still missing (namely dark mode and search) but it should be just better all around. We also added the [Guidebook](/guidebook). This is a collection of tutorials and guides on using Lucia, and it should cover the lack of resources compared to other solutions. It's still a work-in-progress and expect more content soon! - -Thank you to everyone who has provided us with valuable feedback and helped out with the development! - -## New package name - -The core library `lucia-auth` has been renamed to `lucia`! All other package names (such as `@lucia-auth/oauth`) remain the same. - -## Revamped sessions - -With Lucia 2.0, we are removing the concept of session renewals entirely. To be exact, we'll be extending the expiration of the session instead of replacing it. - -### Better APIs for session validation - -The `User` object is now available inside the `Session` object. This means we are removing `AuthRequest.validateSessionUser()` which should be less confusing. - -```ts -const session = await authRequest.validate(); -if (session) { - const user = session.user; -} -``` - -## Custom session attributes - -Custom attributes can now be defined for sessions in addition to users! - -```ts -const session = await auth.createSession({ - userId, - attributes: { - country - } -}); -``` - -## Bearer token support - -You can now send session ids inside the `Authorization` header as bearer tokens and validate them with `AuthRequest.validateBearerToken()`. Even better, you don't have to handle session renewals since new sessions won't be created when you validate them! This means you can now use Lucia with SPAs and native applications without workarounds. - -## Remove primary and single use keys - -This was one of the biggest regret with v1, especially single use keys. It was clunky and did not align with our goals and approach. We have decided to remove it instead of caring the burden of maintaining it long term. - -### Token integration deprecated - -This might be the biggest change for v2. Please see the [Email authentication with verification links](/guidebook/email-verification-links) for a guide on replacing verification tokens. - -## Custom database table names - -All official adapters allow to use any table names! [We truly live in an age of wonders.](https://www.youtube.com/live/GYkq9Rgoj8E?feature=share&t=2331) - -## New adapters and OAuth providers - -We added a bunch of new adapters: - -- libSQL -- `postgres` -- Unstorage -- Upstash Redis - -and OAuth providers: - -- Spotify -- Lichess -- osu! -- (Work in progress: Apple) diff --git a/documentation/content/guidebook/drizzle-orm.md b/documentation/content/guidebook/drizzle-orm.md deleted file mode 100644 index e03ccdec9..000000000 --- a/documentation/content/guidebook/drizzle-orm.md +++ /dev/null @@ -1,371 +0,0 @@ ---- -title: "Using Drizzle ORM" -description: "Learn how to use Drizzle ORM with Lucia" ---- - -[Drizzle ORM](https://github.com/drizzle-team/drizzle-orm) is a TypeScript ORM for SQL databases designed with maximum type safety in mind. While Lucia doesn't provide an adapter for Drizzle itself, it does provide adapters for most database drivers supported by Drizzle. - -## MySQL - -Make sure to change the table names accordingly. While you can name your Drizzle fields anything you want, the underlying column names must match what's defined in the docs (e.g `user_id`). - -```ts -// schema.js -import { mysqlTable, bigint, varchar } from "drizzle-orm/mysql-core"; - -export const user = mysqlTable("auth_user", { - id: varchar("id", { - length: 15 // change this when using custom user ids - }).primaryKey() - // other user attributes -}); - -export const key = mysqlTable("user_key", { - id: varchar("id", { - length: 255 - }).primaryKey(), - userId: varchar("user_id", { - length: 15 - }) - .notNull() - .references(() => user.id), - hashedPassword: varchar("hashed_password", { - length: 255 - }) -}); - -export const session = mysqlTable("user_session", { - id: varchar("id", { - length: 128 - }).primaryKey(), - userId: varchar("user_id", { - length: 15 - }) - .notNull() - .references(() => user.id), - activeExpires: bigint("active_expires", { - mode: "number" - }).notNull(), - idleExpires: bigint("idle_expires", { - mode: "number" - }).notNull() -}); -``` - -### `mysql2` - -Install `mysql2` and follow the [adapter documentation](/database-adapters/mysql2) to setup your database. - -``` -npm install mysql2 -``` - -Create a new `Pool` from `mysql/promise` and use it to initialize both Drizzle and Lucia. - -```ts -// db.js -import { drizzle } from "drizzle-orm/mysql2"; -import { createPool } from "mysql2/promise"; - -export const pool = mysql.createPool({ - // ... -}); - -export const db = drizzle(pool); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { mysql2 } from "@lucia-auth/adapter-mysql"; - -import { pool } from "./db.js"; - -export const auth = lucia({ - adapter: mysql2(pool, tableNames) -}); -``` - -### `@planetscale/database` - -Remove all `references()` from the schema since Planetscale does not support foreign keys from `key` and `session`. For example: - -```ts -export const key = mysqlTable("user_key", { - // ... - userId: varchar("user_id", { - length: 15 - }).notNull() - // .references(() => user.id) - - // ... -}); -``` - -Install `@planetscale/database` and follow the [adapter documentation](/database-adapters/planetscale-serverless) to setup your database. - -``` -npm install @planetscale/database -``` - -Create a new connection and use it to initialize both Drizzle and Lucia. - -```ts -// db.js -import { drizzle } from "drizzle-orm/planetscale-serverless"; -import { connect } from "@planetscale/database"; - -export const connection = connect({ - // ... -}); - -export const db = drizzle(connection); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { planetscale } from "@lucia-auth/adapter-mysql"; - -import { connection } from "./db.js"; - -export const auth = lucia({ - adapter: planetscale(connection, tableNames) -}); -``` - -## PostgreSQL - -We recommend using `pg` with TCP connections for Supabase and Neon. - -Make sure to change the table names accordingly. While you can name your Drizzle fields anything you want, the underlying column names must match what's defined in the docs (e.g `user_id`). - -```ts -// schema.js -import { pgTable, bigint, varchar } from "drizzle-orm/pg-core"; - -export const user = pgTable("auth_user", { - id: varchar("id", { - length: 15 // change this when using custom user ids - }).primaryKey() - // other user attributes -}); - -export const session = pgTable("user_session", { - id: varchar("id", { - length: 128 - }).primaryKey(), - userId: varchar("user_id", { - length: 15 - }) - .notNull() - .references(() => user.id), - activeExpires: bigint("active_expires", { - mode: "number" - }).notNull(), - idleExpires: bigint("idle_expires", { - mode: "number" - }).notNull() -}); - -export const key = pgTable("user_key", { - id: varchar("id", { - length: 255 - }).primaryKey(), - userId: varchar("user_id", { - length: 15 - }) - .notNull() - .references(() => user.id), - hashedPassword: varchar("hashed_password", { - length: 255 - }) -}); -``` - -### `pg` - -Install `pg` and follow the [adapter documentation](/database-adapters/pg) to setup your database. - -``` -npm install pg -``` - -Create a new `Pool` and use it to initialize both Drizzle and Lucia. - -```ts -// db.js -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; - -export const pool = new Pool({ - // ... -}); - -export const db = drizzle(pool); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { pg } from "@lucia-auth/adapter-postgresql"; - -import { pool } from "./db.js"; - -export const auth = lucia({ - adapter: pg(pool, tableNames) -}); -``` - -## `postgres` - -Install `postgres` and follow the [adapter documentation](/database-adapters/postgres) to setup your database. - -``` -npm install postgres -``` - -Create a new connection and use it to initialize both Drizzle and Lucia. - -```ts -// db.js -import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -export const queryClient = postgres(/* ... */); - -export const db: PostgresJsDatabase = drizzle(queryClient); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { postgres as postgresAdapter } from "@lucia-auth/adapter-postgresql"; - -import { queryClient } from "./db.js"; - -export const auth = lucia({ - adapter: postgresAdapter(queryClient, tableNames) -}); -``` - -## SQLite - -Make sure to change the table names accordingly. While you can name your Drizzle fields anything you want, the underlying column names must match what's defined in the docs (e.g `user_id`). - -```ts -// schema.js -import { sqliteTable, text, blob } from "drizzle-orm/sqlite-core"; - -export const user = sqliteTable("user", { - id: text("id").primaryKey() - // other user attributes -}); - -export const session = sqliteTable("user_session", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => user.id), - activeExpires: blob("active_expires", { - mode: "bigint" - }).notNull(), - idleExpires: blob("idle_expires", { - mode: "bigint" - }).notNull() -}); - -export const key = sqliteTable("user_key", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => user.id), - hashedPassword: text("hashed_password") -}); -``` - -### `better-sqlite3` - -Install `better-sqlite3` and follow the [adapter documentation](/database-adapters/better-sqlite3) to setup your database. - -``` -npm install better-sqlite3 -``` - -Create a new `Database` and use it to initialize both Drizzle and Lucia. - -```ts -// db.js -import { drizzle, BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import sqlite from "better-sqlite3"; - -export const sqliteDatabase = sqlite(/* ... */); - -export const db: BetterSQLite3Database = drizzle(sqliteDatabase); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; - -import { sqliteDatabase } from "./db.js"; - -export const auth = lucia({ - adapter: betterSqlite3(sqliteDatabase, tableNames) -}); -``` - -### Cloudflare D1 - -Follow the [adapter documentation](/database-adapters/cloudflare-d1) to setup your database. - -```ts -import { drizzle } from "drizzle-orm/d1"; - -const initializeDrizzle = (db: D1Database) => { - return drizzle(db); -}; - -const initializeLucia = (db: D1Database) => { - const auth = lucia({ - adapter: d1(db, tableNames) - // ... - }); - return auth; -}; -``` - -### libSQL (Turso) - -Install `@libsql/client` and follow the [adapter documentation](/database-adapters/libsql) to setup your database. - -``` -npm install @libsql/client -``` - -Create a new client and use it to initialize both Drizzle and Lucia. - -```ts -// db.js -import { drizzle } from "drizzle-orm/libsql"; -import { createClient } from "@libsql/client"; - -export const libsqlClient = createClient({ - // ... -}); - -export const db = drizzle(libsqlClient); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { libsql } from "@lucia-auth/adapter-sqlite"; - -import { libsqlClient } from "./db.js"; - -export const auth = lucia({ - adapter: libsql(libsqlClient, tableNames) -}); -``` diff --git a/documentation/content/guidebook/email-verification-codes.md b/documentation/content/guidebook/email-verification-codes.md deleted file mode 100644 index a400e54e7..000000000 --- a/documentation/content/guidebook/email-verification-codes.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: "Email verification codes" -description: "Learn how to verify emails with one-time passwords" ---- - -An alternative way to verify emails is to use one-time passwords. These are generally more user friendly than verification links and magic links as the login process can be continued on the same device/session. However, **because one-time passwords are susceptible to brute force attack, proper protection must be implemented.** - -## Database - -### `verification_code` - -| name | type | unique | references | description | -| ------- | -------- | :----: | ---------- | ------------------------------------- | -| id | any | ✓ | | PRIMARY KEY | -| user_id | `string` | ✓ | `user(id)` | User id | -| code | `string` | | | Verification code | -| expires | `bigint` | | | `int4` and `timestamp` type works too | - -## Generate and send verification code - -The verification code should only be valid for a short span of time (3~5 minutes). The recommended length is 8 and hashing it is optional in this case. - -```ts -import { generateRandomString } from "lucia/utils"; - -const session = await authRequest.validate(); -if (!session) { - // Unauthorized - throw new Error(); -} -// check if email is already verified -if (session.user.emailVerified) { - return redirect("/"); -} - -const code = generateRandomString(8, "0123456789"); - -await db.transaction((trx) => { - // delete existing code - await trx - .table("verification_code") - .where("user_id", "=", session.user.userId) - .delete(); - // create new code - await trx.table("verification_code").insert({ - code, - user_id: session.user.userId, - expires: Date.now() + 1000 * 60 * 5 // 5 minutes - }); -}); - -await sendVerificationCode(session.user.email, code); -``` - -## Validate verification codes - -Make sure to prevent brute force attacks by limiting the number of attempts. One simple approach is to double the timeout on each failed attempt (2, 4, 8, 16 seconds...). This example tracks attempts in-memory but can of course be handled by a regular database. Remember to check the expiration when validating the code, and invalidate all user sessions before updating user attributes (email verified status). - -```ts -const verificationTimeout = new Map< - string, - { - timeoutUntil: number; - timeoutSeconds: number; - } ->(); -``` - -```ts -import { isWithinExpiration } from "lucia/utils"; - -const session = await authRequest.validate(); - -// prevent brute force by throttling requests -const storedTimeout = verificationTimeout.get(session.user.userId) ?? null; -if (!storedTimeout) { - // first attempt - setup throttling - verificationTimeout.set(session.user.userId, { - timeoutUntil: Date.now(), - timeoutSeconds: 1 - }); -} else { - // subsequent attempts - if (!isWithinExpiration(data.timeoutUntil)) { - throw new Error("Too many requests"); - } - const timeoutSeconds = storedTimeout.timeoutSeconds * 2; - verificationTimeout.set(session.user.userId, { - timeoutUntil: Date.now() + timeoutSeconds * 1000, - timeoutSeconds - }); -} - -const storedVerificationCode = await db.transaction((trx) => { - const result = await trx - .table("verification_code") - .where("user_id", "=", session.user.userId) - .get(); - if (!result || result.code !== code) { - throw new Error("Invalid verification code"); - } - // invalidate code - await trx.table("verification_code").where("id", "=", result.id).delete(); - return result; -}); - -if (!isWithinExpiration(storedVerificationCode.expires)) { - // optionally send a new code instead of an error - throw new Error("Expired verification code"); -} - -storedTimeout.delete(session.user.userId); - -let user = await auth.getUser(storedVerificationCode.user_id); - -await auth.invalidateAllUserSessions(user.userId); // important! - -user = await auth.updateUserAttributes(user.userId, { - email_verified: true // verify email -}); - -// create session etc -``` diff --git a/documentation/content/guidebook/email-verification-links/$express.md b/documentation/content/guidebook/email-verification-links/$express.md deleted file mode 100644 index ecc149586..000000000 --- a/documentation/content/guidebook/email-verification-links/$express.md +++ /dev/null @@ -1,365 +0,0 @@ ---- -title: "Email authentication with verification links in Express" -description: "Extend Lucia by implementing email and password authentication with email verification links" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started)._ - -If you're new to Lucia, we recommend starting with [Sign in with username and password](/guidebook/sign-in-with-username-and-password/express) starter guide as this guide will gloss over basic concepts and APIs. Make sure to implement password resets as well, which is covered in a separate guide (see [Password reset links](/guidebook/password-reset-link/express) guide). - -### Clone project - -You can get started immediately by cloning the [Express example](https://github.com/lucia-auth/examples/tree/main/express/email-and-password) from the repository. - -``` -npx degit lucia-auth/examples/express/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/express/email-and-password). - -## Database - -### Update `user` table - -Add an `email` (`string`, unique) and `email_verified` (`boolean`) column to the user table. Keep in mind that some database do not support boolean types (notably SQLite and MySQL), in which case it should be stored as an integer (1 or 0). Lucia _does not_ support default database values. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - email: string; - email_verified: boolean; - }; - type DatabaseSessionAttributes = {}; -} -``` - -### Email verification token - -Create a new `email_verification_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ------------------------------------------ | -| `id` | `string` | ✓ | | Token to send inside the verification link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Configure Lucia - -We'll expose the user's email and verification status to the `User` object returned by Lucia's APIs. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { express } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: "DEV", // "PROD" for production - middleware: express(), - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified // `Boolean(data.email_verified)` if stored as an integer - }; - } -}); - -export type Auth = typeof auth; -``` - -## Email verification tokens - -The token will be sent as part of the verification link. - -``` -http://localhost:/email-verification/ -``` - -When a user clicks the link, we validate of the token stored in the url and set `email_verified` user attributes to `true`. - -### Create new tokens - -`generateEmailVerificationToken()` will first check if a verification token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommended minimum is 40). - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - const storedUserTokens = await db - .table("email_verification_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db.table("email_verification_token").insert({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }); - - return token; -}; -``` - -### Validate tokens - -`validateEmailVerificationToken()` will get the token and delete all tokens belonging to the user (which includes the used token). We recommend handling this in a transaction or a batched query. It then checks the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - // ... -}; - -export const validateEmailVerificationToken = async (token: string) => { - const storedToken = await db.transaction(async (trx) => { - const storedToken = await trx - .table("email_verification_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("email_verification_token") - .where("user_id", "=", storedToken.user_id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Sign up user - -### Create users - -When creating a user, use `"email"` as the provider id and the user's email as the provider user id. Make sure to set `email_verified` user property to `false`. We'll send a verification link when we create a new user, but we'll come back to that later. Redirect the user to the confirmation page (`/email-verification`). - -Since emails are not case sensitive, we can make it lowercase before storing. - -```ts -import { auth } from "./lucia.js"; -import { isValidEmail, sendEmailVerificationLink } from "./email.js"; -import { generateEmailVerificationToken } from "./token.js"; - -app.post("/signup", async (req, res) => { - const { email, password } = req.body as { - email: unknown; - password: unknown; - }; - // basic check - if (!isValidEmail(email)) { - return res.status(400).send("Invalid email"); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return res.status(400).send("Invalid password"); - } - try { - const user = await auth.createUser({ - key: { - providerId: "email", // auth method - providerUserId: email.toLowerCase(), // unique id when using "email" auth method - password // hashed by Lucia - }, - attributes: { - email: email.toLowerCase(), - email_verified: Number(false) - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(req, res); - authRequest.setSession(session); - const token = await generateEmailVerificationToken(user.userId); - await sendEmailVerificationLink(token); - return res.status(302).setHeader("Location", "/email-verification").end(); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return res.status(400).send("Account already exists"); - } - return res.status(500).send("An unknown error occurred"); - } -}); -``` - -```ts -// email.ts -export const sendEmailVerificationLink = async (email, token: string) => { - const url = `http://localhost:3000/email-verification/${token}`; - await sendEmail(email, { - // ... - }); -}; -``` - -#### Validating emails - -Validating emails is notoriously hard as the RFC defining them is rather complicated. Here, we're checking: - -- There's one `@` -- There's at least a single character before `@` -- There's at least a single character after `@` -- No longer than 255 characters - -You can check if a `.` exists, but keep in mind `https://com.` is a valid url/domain. - -```ts -const isValidEmail = (maybeEmail: unknown): maybeEmail is string => { - if (typeof maybeEmail !== "string") return false; - if (maybeEmail.length > 255) return false; - const emailRegexp = /^.+@.+$/; // [one or more character]@[one or more character] - return emailRegexp.test(maybeEmail); -}; -``` - -## Sign in user - -### Authenticate users - -Authenticate the user with `"email"` as the provider id and their email as the provider user id. Make sure to make the email lowercase before calling `useKey()`. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -app.post("/login", async (req, res) => { - const { email, password } = req.body as { - email: unknown; - password: unknown; - }; - // basic check - if (typeof email !== "string" || email.length < 1 || email.length > 255) { - return res.status(400).send("Invalid email"); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return res.status(400).send("Invalid password"); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("email", email.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(req, res); - authRequest.setSession(session); - // redirect to profile page - return res.status(302).setHeader("Location", "/").end(); - } catch (e) { - // check for unique constraint error in user table - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return res.status(400).send("Incorrect email or password"); - } - return res.status(500).send("An unknown error occurred"); - } -}); -``` - -## Resend verification link - -Redirect unauthenticated users and those who already have a verified email. Create a new verification token and send the link to the user's inbox. - -```ts -import { auth } from "@/auth/lucia"; -import { generateEmailVerificationToken } from "./token.js"; -import { sendEmailVerificationLink } from "./email.js"; - -app.post("/email-verification", async (req, res) => { - const authRequest = auth.handleRequest(req, res); - const session = await authRequest.validate(); - if (!session) return res.status(401).end(); - if (session.user.emailVerified) { - // email already verified - return res.status(422).end(); - } - try { - const token = await generateEmailVerificationToken(session.user.userId); - await sendEmailVerificationLink(token); - return res.end(); - } catch { - return res.status(500).send("An unknown error occurred"); - } -}); -``` - -## Verify email - -Create route `/email-verification/`, where `` is a dynamic route params. This route will validate the token stored in url and verify the user's email. - -Make sure to invalidate all sessions of the user. - -```ts -import { auth } from "./lucia.js"; -import { validateEmailVerificationToken } from "./token.js"; - -app.get("/email-verification/:token", async (req, res) => { - const { token } = req.params; - try { - const userId = await validateEmailVerificationToken(token); - const user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(req, res); - authRequest.setSession(session); - return res.status(302).setHeader("Location", "/").end(); - } catch { - return res.status(400).send("Invalid email verification link"); - } -}); -``` diff --git a/documentation/content/guidebook/email-verification-links/$nextjs-app.md b/documentation/content/guidebook/email-verification-links/$nextjs-app.md deleted file mode 100644 index 58339008e..000000000 --- a/documentation/content/guidebook/email-verification-links/$nextjs-app.md +++ /dev/null @@ -1,691 +0,0 @@ ---- -title: "Email authentication with verification links in Next.js App Router" -description: "Extend Lucia by implementing email and password authentication with email verification links" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started)._ - -If you're new to Lucia, we recommend starting with [Sign in with username and password](/guidebook/sign-in-with-username-and-password/nextjs-app) starter guide as this guide will gloss over basic concepts and APIs. Make sure to implement password resets as well, which is covered in a separate guide (see [Password reset links](/guidebook/password-reset-link/nextjs-app) guide). - -This example project will have a few pages: - -- `/signup` -- `/login` -- `/`: Profile page (protected) -- `/email-verification`: Confirmation + button to resend verification link - -It will also have a route to handle verification links. - -### Clone project - -You can get started immediately by cloning the [Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-app/email-and-password) from the repository. - -``` -npx degit lucia-auth/examples/nextjs-app/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-app/email-and-password). - -## Database - -### Update `user` table - -Add an `email` (`string`, unique) and `email_verified` (`boolean`) column to the user table. Keep in mind that some database do not support boolean types (notably SQLite and MySQL), in which case it should be stored as an integer (1 or 0). Lucia _does not_ support default database values. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("@/auth/lucia").Auth; - type DatabaseUserAttributes = { - email: string; - email_verified: number; - }; - type DatabaseSessionAttributes = {}; -} -``` - -### Email verification tokens - -Create a new `email_verification_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ------------------------------------------ | -| `id` | `string` | ✓ | | Token to send inside the verification link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Configure Lucia - -We'll expose the user's email and verification status to the `User` object returned by Lucia's APIs. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - sessionCookie: { - expires: false - }, - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified // `Boolean(data.email_verified)` if stored as an integer - }; - } -}); - -export type Auth = typeof auth; -``` - -## Email verification tokens - -The token will be sent as part of the verification link. - -``` -http://localhost:3000/email-verification/ -``` - -When a user clicks the link, we validate the token stored in the url and set `email_verified` user attributes to `true`. - -### Create new tokens - -`generateEmailVerificationToken()` will first check if a verification token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommended minimum is 40). - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - const storedUserTokens = await db - .table("email_verification_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db.table("email_verification_token").insert({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }); - - return token; -}; -``` - -### Validate tokens - -`validateEmailVerificationToken()` will get the token and delete all tokens belonging to the user (which includes the used token). We recommend handling this in a transaction or a batched query. It then checks the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - // ... -}; - -export const validateEmailVerificationToken = async (token: string) => { - const storedToken = await db.transaction(async (trx) => { - const storedToken = await trx - .table("email_verification_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("email_verification_token") - .where("user_id", "=", storedToken.user_id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Form component - -Since the form will require client side JS, we will extract it into its own client component. We need to manually handle redirect responses as the default behavior is to make another request to the redirect location. We're going to use `refresh()` to reload the page (and redirect the user in the server) since we want to re-render the entire page, including `layout.tsx`. - -```tsx -// components/form.tsx -"use client"; -import { useRouter } from "next/navigation"; - -const Form = (props: { children: React.ReactNode; action: string }) => { - const router = useRouter(); - return ( - <> -
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch(props.action, { - method: "POST", - body: formData, - redirect: "manual" - }); - if (response.status === 0) { - // redirected - // when using `redirect: "manual"`, response status 0 is returned - return router.refresh(); - } - }} - > - {props.children} -
- - ); -}; - -export default Form; -``` - -## Sign up page - -Create `app/signup/page.tsx`. It will have a form with inputs for email and password. Redirect authenticated users to the profile page if their email is verified, or to the confirmation page if not. - -```tsx -// app/signup/page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -import Form from "@/components/form"; -import Link from "next/link"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (session) { - if (!session.user.emailVerified) redirect("/email-verification"); - redirect("/"); - } - return ( - <> -

Sign up

-
- - -
- - -
- -
- Sign in - - ); -}; - -export default Page; -``` - -### Create users - -Create `app/api/signup/route.ts` and handle POST requests. - -When creating a user, use `"email"` as the provider id and the user's email as the provider user id. Make sure to set `email_verified` user property to `false`. After creating a user, send the email verification link to the user's inbox. Redirect the user to the confirmation page (`/email-verification`). - -```ts -// app/api/signup/route.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { NextResponse } from "next/server"; -import { generateEmailVerificationToken } from "@/auth/token"; -import { sendEmailVerificationLink } from "@/auth/email"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - // basic check - if (!isValidEmail(email)) { - return NextResponse.json( - { - error: "Invalid email" - }, - { - status: 400 - } - ); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return NextResponse.json( - { - error: "Invalid password" - }, - { - status: 400 - } - ); - } - try { - const user = await auth.createUser({ - key: { - providerId: "email", // auth method - providerUserId: email.toLowerCase(), // unique id when using "email" auth method - password // hashed by Lucia - }, - attributes: { - email: email.toLowerCase(), - email_verified: false // `Number(true)` if stored as an integer - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(request.method, context); - authRequest.setSession(session); - - const token = await generateEmailVerificationToken(user.userId); - await sendEmailVerificationLink(token); - - return new Response(null, { - status: 302, - headers: { - Location: "/email-verification" - } - }); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return NextResponse.json( - { - error: "Account already exists" - }, - { - status: 400 - } - ); - } - return NextResponse.json( - { - error: "An unknown error occurred" - }, - { - status: 500 - } - ); - } -}; -``` - -```ts -// auth/email.ts -export const sendEmailVerificationLink = async (email, token: string) => { - const url = `http://localhost:3000/email-verification/${token}`; - await sendEmail(email, { - // ... - }); -}; -``` - -#### Validating emails - -Validating emails is notoriously hard as the RFC defining them is rather complicated. Here, we're checking: - -- There's one `@` -- There's at least a single character before `@` -- There's at least a single character after `@` -- No longer than 255 characters - -You can check if a `.` exists, but keep in mind `https://com.` is a valid url/domain. - -```ts -const isValidEmail = (maybeEmail: unknown): maybeEmail is string => { - if (typeof maybeEmail !== "string") return false; - if (maybeEmail.length > 255) return false; - const emailRegexp = /^.+@.+$/; // [one or more character]@[one or more character] - return emailRegexp.test(maybeEmail); -}; -``` - -## Sign in page - -Create `app/login/page.tsx`. It will have a form with inputs for email and password. Implement redirects as we did in the sign up page. - -```tsx -// app/login/page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -import Form from "@/components/form"; -import Link from "next/link"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (session) { - if (!session.user.emailVerified) redirect("/email-verification"); - redirect("/"); - } - return ( - <> -

Sign in

-
- - -
- - -
- -
- Reset password - Create an account - - ); -}; - -export default Page; -``` - -### Authenticate users - -Create `app/api/login/route.ts` and handle POST requests. - -Authenticate the user with `"email"` as the provider id and their email as the provider user id. Make sure to make the email lowercase before calling `useKey()`. - -```ts -// app/api/login/route.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { NextResponse } from "next/server"; -import { LuciaError } from "lucia"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - // basic check - if (typeof email !== "string" || email.length < 1 || email.length > 255) { - return NextResponse.json( - { - error: "Invalid email" - }, - { - status: 400 - } - ); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return NextResponse.json( - { - error: "Invalid password" - }, - { - status: 400 - } - ); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("email", email.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(request.method, context); - authRequest.setSession(session); - return new Response(null, { - status: 302, - headers: { - Location: "/" // redirect to profile page - } - }); - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return NextResponse.json( - { - error: "Incorrect email or password" - }, - { - status: 400 - } - ); - } - return NextResponse.json( - { - error: "An unknown error occurred" - }, - { - status: 500 - } - ); - } -}; -``` - -## Confirmation page - -Create `app/email-verification/page.tsx`. Users who just signed up and those without a verified email will be redirected to this page. It will include a form to resend the verification link. - -This page should only be accessible to users whose email is not verified. - -```tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -import Form from "@/components/form"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (!session) redirect("/login"); - if (session.user.emailVerified) redirect("/"); - return ( - <> -

Email verification

-

Your email verification link was sent to your inbox (i.e. console).

-

Resend verification link

-
- -
- - ); -}; - -export default Page; -``` - -### Resend verification link - -Create `app/api/email-verification/route.ts` and handle POST requests. Create a new verification token and send the link to the user's inbox. - -```ts -// app/api/email-verification/route.ts -import { auth } from "@/auth/lucia"; -import { generateEmailVerificationToken } from "@/auth/token"; -import { sendEmailVerificationLink } from "@/auth/email"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const authRequest = auth.handleRequest(request); - const session = await authRequest.validate(); - if (!session) { - return new Response(null, { - status: 401 - }); - } - if (session.user.emailVerified) { - return new Response( - JSON.stringify({ - error: "Email already verified" - }), - { - status: 422 - } - ); - } - try { - const token = await generateEmailVerificationToken(session.user.userId); - await sendEmailVerificationLink(token); - return new Response(); - } catch { - return new Response( - JSON.stringify({ - error: "An unknown error occurred" - }), - { - status: 500 - } - ); - } -}; -``` - -## Verify email - -Create `app/email-verification/[token]/route.ts` and handle GET requests. This route will validate the token stored in url and verify the user's email. The token can be accessed from the url with `params`. - -Make sure to invalidate all sessions of the user. - -```ts -// app/email-verification/[token]/route.ts -import { auth } from "@/auth/lucia"; -import { validateEmailVerificationToken } from "@/auth/token"; - -import type { NextRequest } from "next/server"; - -export const GET = async ( - _: NextRequest, - { - params - }: { - params: { - token: string; - }; - } -) => { - const { token } = params; - try { - const userId = await validateEmailVerificationToken(token); - const user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateUserAttributes(user.userId, { - email_verified: true // `Number(true)` if stored as an integer - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() - } - }); - } catch { - return new Response("Invalid email verification link", { - status: 400 - }); - } -}; -``` - -## Protect pages - -Protect all other pages and API routes by redirecting unauthenticated users and those without a verified email. - -```tsx -// page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (!session) redirect("/login"); - if (!session.user.emailVerified) redirect("/email-verification"); - return ( - // ... - ); -}; - -export default Page; -``` - -```ts -// route.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const authRequest = auth.handleRequest(request.method, context); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - return new Response(null, { - status: 401 - }) - if (!session.user.emailVerified) { - return new Response(null, { - status: 403 - }) - } - // ... -}; -``` diff --git a/documentation/content/guidebook/email-verification-links/$nextjs-pages.md b/documentation/content/guidebook/email-verification-links/$nextjs-pages.md deleted file mode 100644 index 8035cf4ae..000000000 --- a/documentation/content/guidebook/email-verification-links/$nextjs-pages.md +++ /dev/null @@ -1,751 +0,0 @@ ---- -title: "Email authentication with verification links in Next.js Pages Router" -description: "Extend Lucia by implementing email and password authentication with email verification links" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started)._ - -If you're new to Lucia, we recommend starting with [Sign in with username and password](/guidebook/sign-in-with-username-and-password/nextjs-pages) starter guide as this guide will gloss over basic concepts and APIs. Make sure to implement password resets as well, which is covered in a separate guide (see [Password reset links](/guidebook/password-reset-link/nextjs-pages) guide). - -This example project will have a few pages: - -- `/signup` -- `/login` -- `/`: Profile page (protected) -- `/email-verification`: Confirmation + button to resend verification link - -It will also have a route to handle verification links. - -### Clone project - -You can get started immediately by cloning the [Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-pages/email-and-password) from the repository. - -``` -npx degit lucia-auth/examples/nextjs-pages/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-pages/email-and-password). - -## Database - -### Update `user` table - -Add a `email` (`string`, unique) and `email_verified` (`boolean`) column to the user table. Keep in mind that some database do not support boolean types (notably SQLite and MySQL), in which case it should be stored as an integer (1 or 0). Lucia _does not_ support default database values. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("@/auth/lucia").Auth; - type DatabaseUserAttributes = { - email: string; - email_verified: number; - }; - type DatabaseSessionAttributes = {}; -} -``` - -### Email verification tokens - -Create a new `email_verification_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ------------------------------------------ | -| `id` | `string` | ✓ | | Token to send inside the verification link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Configure Lucia - -We'll expose the user's email and verification status to the `User` object returned by Lucia's APIs. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified // `Boolean(data.email_verified)` if stored as an integer - }; - } -}); - -export type Auth = typeof auth; -``` - -## Email verification tokens - -The token will be sent as part of the verification link. - -``` -http://localhost:3000/api/email-verification/ -``` - -When a user clicks the link, we validate of the token stored in the url and set `email_verified` user attributes to `true`. - -### Create new tokens - -`generateEmailVerificationToken()` will first check if a verification token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - const storedUserTokens = await db - .table("email_verification_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db.table("email_verification_token").insert({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }); - - return token; -}; -``` - -### Validate tokens - -`validateEmailVerificationToken()` will get the token and delete all tokens belonging to the user (which includes the used token). We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - // ... -}; - -export const validateEmailVerificationToken = async (token: string) => { - const storedToken = await db.transaction(async (trx) => { - const storedToken = await trx - .table("email_verification_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("email_verification_token") - .where("user_id", "=", storedToken.user_id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Sign up page - -Create `pages/signup.tsx`. It will have a form with inputs for email and password. We need to manually handle redirect responses as the default behavior is to make another request to the redirect location. - -Redirect authenticated users to the profile page if their email is verified, or to the confirmation page if not. - -```tsx -// pages/signup.tsx -import { useRouter } from "next/router"; -import { auth } from "@/auth/lucia"; - -import Link from "next/link"; - -import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (session) { - if (!session.user.emailVerified) { - return { - redirect: { - destination: "/email-verification", - permanent: false - } - }; - } - return { - redirect: { - destination: "/", - permanent: false - } - }; - } - return { - props: {} - }; -}; - -const Page = () => { - const router = useRouter(); - return ( - <> -

Sign up

-
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch("/api/signup", { - method: "POST", - body: JSON.stringify({ - email: formData.get("email"), - password: formData.get("password") - }), - headers: { - "Content-Type": "application/json" - }, - redirect: "manual" - }); - if (response.status === 0) { - // redirected - // when using `redirect: "manual"`, response status 0 is returned - return router.push("/"); - } - }} - > - - -
- - -
- -
- Sign in - - ); -}; - -export default Page; -``` - -### Create users - -Create `pages/api/signup.ts` and handle POST requests. - -When creating a user, use `"email"` as the provider id and the user's email as the provider user id. Make sure to set `email_verified` user property to `false`. After creating a user, send the email verification link to the user's inbox. Redirect the user to the confirmation page (`/email-verification`). - -```ts -// pages/api/signup.ts -import { isValidEmail, sendEmailVerificationLink } from "@/auth/email"; -import { auth } from "@/auth/lucia"; -import { generateEmailVerificationToken } from "@/auth/token"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") return res.status(405); - const { email, password } = req.body as { - email: unknown; - password: unknown; - }; - // basic check - if (!isValidEmail(email)) { - return res.status(400).json({ - error: "Invalid email" - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return res.status(400).json({ - error: "Invalid password" - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "email", // auth method - providerUserId: email.toLowerCase(), // unique id when using "email" auth method - password // hashed by Lucia - }, - attributes: { - email: email.toLowerCase(), - email_verified: Number(false) - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest({ - req, - res - }); - authRequest.setSession(session); - - const token = await generateEmailVerificationToken(user.userId); - await sendEmailVerificationLink(token); - - return res.redirect(302, "/email-verification"); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return res.status(400).json({ - error: "Account already exists" - }); - } - - return res.status(500).json({ - error: "An unknown error occurred" - }); - } -}; - -export default handler; -``` - -```ts -// auth/email.ts -export const sendEmailVerificationLink = async (email, token: string) => { - const url = `http://localhost:3000/api/email-verification/${token}`; - await sendEmail(email, { - // ... - }); -}; -``` - -#### Validating emails - -Validating emails are notoriously hard as the RFC defining them is rather complicated. Here, we're checking: - -- There's one `@` -- There's at least a single character before `@` -- There's at least a single character after `@` -- No longer than 255 characters - -You can check if a `.` exists, but keep in mind `https://com.` is a valid url/domain. - -```ts -const isValidEmail = (maybeEmail: unknown): maybeEmail is string => { - if (typeof maybeEmail !== "string") return false; - if (maybeEmail.length > 255) return false; - const emailRegexp = /^.+@.+$/; // [one or more character]@[one or more character] - return emailRegexp.test(maybeEmail); -}; -``` - -## Sign in page - -Create `pages/login.tsx`. It will have a form with inputs for email and password. Implement redirects as we did in the sign up page. - -```tsx -// pages/login.tsx -import { useRouter } from "next/router"; -import { auth } from "@/auth/lucia"; - -import Link from "next/link"; - -import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (session) { - if (!session.user.emailVerified) { - return { - redirect: { - destination: "/email-verification", - permanent: false - } - }; - } - return { - redirect: { - destination: "/", - permanent: false - } - }; - } - return { - props: {} - }; -}; - -const Page = () => { - const router = useRouter(); - return ( - <> -

Sign in

-
{ - e.preventDefault(); - setErrorMessage(null); - const formData = new FormData(e.currentTarget); - const response = await fetch("/api/login", { - method: "POST", - body: JSON.stringify({ - email: formData.get("email"), - password: formData.get("password") - }), - headers: { - "Content-Type": "application/json" - }, - redirect: "manual" - }); - - if (response.status === 0) { - // redirected - // when using `redirect: "manual"`, response status 0 is returned - return router.push("/"); - } - }} - > - - -
- - -
- -
- Create an account - - ); -}; - -export default Page; -``` - -### Authenticate users - -Create `pages/api/login.ts` and handle POST requests. - -Authenticate the user with `"email"` as the provider id and their email as the provider user id. Make sure to make the email lowercase before calling `useKey()`. - -```ts -// pages/api/login.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { NextResponse } from "next/server"; -import { LuciaError } from "lucia"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - // basic check - if (typeof email !== "string" || email.length < 1 || email.length > 255) { - return NextResponse.json( - { - error: "Invalid email" - }, - { - status: 400 - } - ); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return NextResponse.json( - { - error: "Invalid password" - }, - { - status: 400 - } - ); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("email", email.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest({ - request, - cookies - }); - authRequest.setSession(session); - return new Response(null, { - status: 302, - headers: { - Location: "/" // redirect to profile page - } - }); - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return NextResponse.json( - { - error: "Incorrect email or password" - }, - { - status: 400 - } - ); - } - return NextResponse.json( - { - error: "An unknown error occurred" - }, - { - status: 500 - } - ); - } -}; -``` - -## Confirmation page - -Create `pages/email-verification.tsx`. Users who just signed up and those without a verified email will be redirected to this page. It will include a form to resend the verification link. - -This page should only accessible to users whose email is not verified. - -```tsx -// pages/email-verification.tsx -import { auth } from "@/auth/lucia"; - -import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (!session) { - return { - redirect: { - destination: "/login", - permanent: false - } - }; - } - if (session.user.emailVerified) { - return { - redirect: { - destination: "/", - permanent: false - } - }; - } - return { - props: {} - }; -}; - -const Page = () => { - return ( - <> -

Email verification

-

Your email verification link was sent to your inbox (i.e. console).

-

Resend verification link

-
{ - e.preventDefault(); - await fetch("/api/email-verification", { - method: "POST" - }); - }} - > - -
- - ); -}; - -export default Page; -``` - -### Resend verification link - -Create `pages/api/email-verification/index.ts` and handle POST requests. Create a new verification token and send the link to the user's inbox. - -```ts -// pages/api/email-verification/index.ts -import { auth } from "@/auth/lucia"; -import { generateEmailVerificationToken } from "@/auth/verification-token"; -import { sendEmailVerificationLink } from "@/auth/email"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") return res.status(405).end(); - const authRequest = auth.handleRequest({ - req, - res - }); - const session = await authRequest.validate(); - if (!session) return res.status(401).end(); - if (session.user.emailVerified) { - return res.status(422).json({ - error: "Email already verified" - }); - } - try { - const token = await generateEmailVerificationToken(session.user.userId); - await sendEmailVerificationLink(token); - return res.end(); - } catch { - return res.status(500).json({ - error: "An unknown error occurred" - }); - } -}; - -export default handler; -``` - -## Verify email - -Create `pages/api/email-verification/[token].ts` and handle GET requests. This route will validate the token stored in url and verify the user's email. The token can be accessed from the url with `req.query`. - -Make sure to invalidate all sessions of the user. - -```ts -// pages/api/email-verification/[token].ts -import { auth } from "@/auth/lucia"; -import { validateEmailVerificationToken } from "@/auth/verification-token"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "GET") return res.status(405).end(); - const { token } = req.query as { - token: string; - }; - try { - const userId = await validateEmailVerificationToken(token); - const user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest({ - req, - res - }); - authRequest.setSession(session); - return res.status(302).setHeader("Location", "/").end(); - } catch { - return res.status(400).send("Invalid email verification link"); - } -}; - -export default handler; -``` - -## Protect pages - -Protect all other pages and API routes by redirecting unauthenticated users and those without a verified email. - -```tsx -import { auth } from "@/auth/lucia"; - -import type { - GetServerSidePropsContext, - GetServerSidePropsResult, - InferGetServerSidePropsType -} from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise< - GetServerSidePropsResult<{ - userId: string; - email: string; - }> -> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (!session) { - return { - redirect: { - destination: "/login", - permanent: false - } - }; - } - if (!session.user.emailVerified) { - return { - redirect: { - destination: "/email-verification", - permanent: false - } - }; - } - // ... -}; -``` - -```ts -import { auth } from "@/auth/lucia"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - const authRequest = auth.handleRequest({ - req, - res - }); - const session = await authRequest.validate(); - if (!session) return res.status(401).end(); - if (!session.user.emailVerified) { - return res.status(403).end(); - } - // ... -}; - -export default handler; -``` diff --git a/documentation/content/guidebook/email-verification-links/$nuxt.md b/documentation/content/guidebook/email-verification-links/$nuxt.md deleted file mode 100644 index ba164a1c9..000000000 --- a/documentation/content/guidebook/email-verification-links/$nuxt.md +++ /dev/null @@ -1,643 +0,0 @@ ---- -title: "Email authentication with verification links in Nuxt" -description: "Extend Lucia by implementing email and password authentication with email verification links" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started)._ - -If you're new to Lucia, we recommend starting with [Sign in with username and password](/guidebook/sign-in-with-username-and-password/nuxt) starter guide as this guide will gloss over basic concepts and APIs. Make sure to implement password resets as well, which is covered in a separate guide (see [Password reset links](/guidebook/password-reset-link/nuxt) guide). - -This example project will have a few pages: - -- `/signup` -- `/login` -- `/`: Profile page (protected) -- `/email-verification`: Confirmation + button to resend verification link - -It will also have a route to handle verification links. - -### Clone project - -You can get started immediately by cloning the [Nuxt example](https://github.com/lucia-auth/examples/tree/main/nuxt/email-and-password) from the repository. - -``` -npx degit lucia-auth/examples/nuxt/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nuxt/email-and-password). - -## Database - -### Update `user` table - -Add a `email` (`string`, unique) and `email_verified` (`boolean`) column to the user table. Keep in mind that some database do not support boolean types (notably SQLite and MySQL), in which case it should be stored as an integer (1 or 0). Lucia _does not_ support default database values. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// server/app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./utils/lucia").Auth; - type DatabaseUserAttributes = { - email: string; - email_verified: number; - }; - type DatabaseSessionAttributes = {}; -} -``` - -### Email verification tokens - -Create a new `email_verification_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ------------------------------------------ | -| `id` | `string` | ✓ | | Token to send inside the verification link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Configure Lucia - -We'll expose the user's email and verification status to the `User` object returned by Lucia's APIs. - -```ts -// server/utils/lucia.ts -import { lucia } from "lucia"; -import { h3 } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.dev ? "DEV" : "PROD", - middleware: h3(), - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified // `Boolean(data.email_verified)` if stored as an integer - }; - } -}); - -export type Auth = typeof auth; -``` - -## Email verification tokens - -The token will be sent as part of the verification link. - -``` -http://localhost:3000/api/email-verification/ -``` - -When a user clicks the link, we validate of the token stored in the url and set `email_verified` user attributes to `true`. - -### Create new tokens - -`generateEmailVerificationToken()` will first check if a verification token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// server/utils/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - const storedUserTokens = await db - .table("email_verification_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db.table("email_verification_token").insert({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }); - - return token; -}; -``` - -### Validate tokens - -`validateEmailVerificationToken()` will get the token and delete all tokens belonging to the user (which includes the used token). We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// server/utils/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - // ... -}; - -export const validateEmailVerificationToken = async (token: string) => { - const storedToken = await db.transaction(async (trx) => { - const storedToken = await trx - .table("email_verification_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("email_verification_token") - .where("user_id", "=", storedToken.user_id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Managing auth state - -### Get authenticated user - -Create `server/api/user.get.ts`. This endpoint will return the current user. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```ts -// server/api/user.get.ts -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - const session = await authRequest.validate(); - return { - user: session?.user ?? null; - } -}); -``` - -### Composables - -Create `useUser()` and `useAuthenticatedUser()` composables. `useUser()` will return the current user. `useAuthenticatedUser()` can only be used inside protected routes, which allows the ref value type to be always defined (never `null`). - -```ts -// composables/auth.ts -import type { User } from "lucia"; - -export const useUser = () => { - const user = useState("user", () => null); - return user; -}; - -export const useAuthenticatedUser = () => { - const user = useUser(); - return computed(() => { - const userValue = unref(user); - if (!userValue) { - throw createError( - "useAuthenticatedUser() can only be used in protected pages" - ); - } - return userValue; - }); -}; -``` - -### Define middleware - -Define a global `auth` middleware that gets the current user and populates the user state. This will run on every navigation. - -```ts -// middleware/auth.global.ts -export default defineNuxtRouteMiddleware(async () => { - const user = useUser(); - const { data, error } = await useFetch("/api/user"); - if (error.value) throw createError("Failed to fetch data"); - user.value = data.value?.user ?? null; -}); -``` - -Next, define a regular `protected` middleware that redirects unauthenticated users to the login page. - -```ts -// middleware/protected.ts -export default defineNuxtRouteMiddleware(async () => { - const user = useUser(); - if (!user.value) return navigateTo("/login"); -}); -``` - -## Sign up page - -Create `pages/signup.vue`. It will have a form with inputs for email and password. We need to manually handle redirect responses as the default behavior is to make another request to the redirect location. - -Redirect authenticated users to the profile page if their email is verified, or to the confirmation page if not. - -```vue - - - - -``` - -### Create users - -Create `server/api/signup.post.ts`. - -When creating a user, use `"email"` as the provider id and the user's email as the provider user id. Make sure to set `email_verified` user property to `false`. After creating a user, send the email verification link to the user's inbox. Redirect the user to the confirmation page (`/email-verification`). - -```ts -// server/api/signup.post.ts -import { SqliteError } from "better-sqlite3"; - -export default defineEventHandler(async (event) => { - const { email, password } = await readBody<{ - email: unknown; - password: unknown; - }>(event); - // basic check - if (!isValidEmail(email)) { - throw createError({ - message: "Invalid email", - statusCode: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - throw createError({ - message: "Invalid password", - statusCode: 400 - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "email", // auth method - providerUserId: email.toLowerCase(), // unique id when using "email" auth method - password // hashed by Lucia - }, - attributes: { - email: email.toLowerCase(), - email_verified: Number(false) - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(event); - authRequest.setSession(session); - const token = await generateEmailVerificationToken(user.userId); - await sendEmailVerificationLink(token); - return sendRedirect(event, "/email-verification"); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - throw createError({ - message: "Account already exists", - statusCode: 400 - }); - } - throw createError({ - message: "An unknown error occurred", - statusCode: 500 - }); - } -}); -``` - -```ts -// server/utils/email.ts -export const sendEmailVerificationLink = async (email, token: string) => { - const url = `http://localhost:3000/api/email-verification/${token}`; - await sendEmail(email, { - // ... - }); -}; -``` - -#### Validating emails - -Validating emails are notoriously hard as the RFC defining them is rather complicated. Here, we're checking: - -- There's one `@` -- There's at least a single character before `@` -- There's at least a single character after `@` -- No longer than 255 characters - -You can check if a `.` exists, but keep in mind `https://com.` is a valid url/domain. - -```ts -const isValidEmail = (maybeEmail: unknown): maybeEmail is string => { - if (typeof maybeEmail !== "string") return false; - if (maybeEmail.length > 255) return false; - const emailRegexp = /^.+@.+$/; // [one or more character]@[one or more character] - return emailRegexp.test(maybeEmail); -}; -``` - -## Sign in page - -Create `pages/login.vue`. It will have a form with inputs for email and password. Implement redirects as we did in the sign up page. - -```vue - - - - -``` - -### Authenticate users - -Create `server/api/login.post.ts` and handle POST requests. - -Authenticate the user with `"email"` as the provider id and their email as the provider user id. Make sure to make the email lowercase before calling `useKey()`. - -```ts -// server/api/login.post.ts -import { LuciaError } from "lucia"; - -export default defineEventHandler(async (event) => { - const { email, password } = await readBody<{ - email: unknown; - password: unknown; - }>(event); - // basic check - if (typeof email !== "string" || email.length < 1 || email.length > 255) { - throw createError({ - message: "Invalid email", - statusCode: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - throw createError({ - message: "Invalid password", - statusCode: 400 - }); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("email", email.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(event); - authRequest.setSession(session); - return sendRedirect(event, "/"); // redirect to profile page - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - throw createError({ - message: "Incorrect email or password", - statusCode: 400 - }); - } - throw createError({ - message: "An unknown error occurred", - statusCode: 500 - }); - } -}); -``` - -## Confirmation page - -Create `pages/email-verification.vue`. Users who just signed up and those without a verified email will be redirected to this page. It will include a form to resend the verification link. - -This page should only accessible to users whose email is not verified. - -```vue - - - - -``` - -### Resend verification link - -Create `server/api/email-verification/index.post.ts` and handle POST requests. Create a new verification token and send the link to the user's inbox. - -```ts -// server/api/email-verification/index.post.ts -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - const session = await authRequest.validate(); - if (!session) { - throw createError({ - status: 401 - }); - } - if (session.user.emailVerified) { - throw createError({ - status: 422, - message: "Email already verified" - }); - } - try { - const token = await generateEmailVerificationToken(session.user.userId); - await sendEmailVerificationLink(token); - return {}; - } catch { - throw createError({ - message: "An unknown error occurred", - statusCode: 500 - }); - } -}); -``` - -## Verify email - -Create `server/api/email-verification/[token].get.ts This route will validate the token stored in url and verify the user's email. The token can be accessed from the url with `event.context.params`. - -Make sure to invalidate all sessions of the user. - -```ts -// server/api/email-verification/[token].get.ts -export default defineEventHandler(async (event) => { - const { token } = event.context.params ?? { - token: "" - }; - try { - const userId = await validateEmailVerificationToken(token); - const user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(event); - authRequest.setSession(session); - return sendRedirect(event, "/"); - } catch { - throw createError({ - status: 400, - message: "Invalid email verification link" - }); - } -}); -``` - -## Protect pages - -Protect all other pages and API routes by redirecting unauthenticated users and those without a verified email. - -```vue - - - -``` - -```ts -// api routes -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - const session = await authRequest.validate(); - if (!session) { - throw createError({ - message: "Unauthorized", - status: 401 - }); - } - if (session.user.emailVerified) { - throw createError({ - status: 422, - message: "Email already verified" - }); - } - // ... -}); -``` diff --git a/documentation/content/guidebook/email-verification-links/$sveltekit.md b/documentation/content/guidebook/email-verification-links/$sveltekit.md deleted file mode 100644 index 749dfb528..000000000 --- a/documentation/content/guidebook/email-verification-links/$sveltekit.md +++ /dev/null @@ -1,577 +0,0 @@ ---- -title: "Email authentication with verification links in SvelteKit" -description: "Extend Lucia by implementing email and password authentication with email verification links" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/sveltekit)._ - -If you're new to Lucia, we recommend starting with [Sign in with username and password](/guidebook/sign-in-with-username-and-password/sveltekit) starter guide as this guide will gloss over basic concepts and APIs. Make sure to implement password resets as well, which is covered in a separate guide (see [Password reset links](/guidebook/password-reset-link/sveltekit) guide). - -This example project will have a few pages: - -- `/signup` -- `/login` -- `/`: Profile page (protected) -- `/email-verification`: Confirmation + button to resend verification link - -It will also have a route to handle verification links. - -### Clone project - -You can get started immediately by cloning the [SvelteKit example](https://github.com/lucia-auth/examples/tree/main/sveltekit/email-and-password) from the repository. - -``` -npx degit lucia-auth/examples/sveltekit/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/sveltekit/email-and-password). - -## Database - -### Update `user` table - -Add a `email` (`string`, unique) and `email_verified` (`boolean`) column to the user table. Keep in mind that some database do not support boolean types (notably SQLite and MySQL), in which case it should be stored as an integer (1 or 0). Lucia _does not_ support default database values. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -/// -declare global { - namespace Lucia { - type Auth = import("$lib/server/lucia").Auth; - type DatabaseUserAttributes = { - email: string; - email_verified: boolean; - }; - type DatabaseSessionAttributes = Record; - } -} -``` - -### Email verification token - -Create a new `email_verification_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ------------------------------------------ | -| `id` | `string` | ✓ | | Token to send inside the verification link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Configure Lucia - -We'll expose the user's email and verification status to the `User` object returned by Lucia's APIs. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { sveltekit } from "lucia/middleware"; -import { dev } from "$app/environment"; - -export const auth = lucia({ - adapter: ADAPTER, - env: dev ? "DEV" : "PROD", - middleware: sveltekit(), - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified // `Boolean(data.email_verified)` if stored as an integer - }; - } -}); - -export type Auth = typeof auth; -``` - -## Email verification tokens - -The token will be sent as part of the verification link. - -``` -http://localhost:5173/email-verification/ -``` - -When a user clicks the link, we validate of the token stored in the url and set `email_verified` user attributes to `true`. - -### Create new tokens - -`generateEmailVerificationToken()` will first check if a verification token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// lib/server/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - const storedUserTokens = await db - .table("email_verification_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db.table("email_verification_token").insert({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }); - - return token; -}; -``` - -### Validate tokens - -`validateEmailVerificationToken()` will get the token and delete all tokens belonging to the user (which includes the used token). We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// lib/server/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - // ... -}; - -export const validateEmailVerificationToken = async (token: string) => { - const storedToken = await db.transaction(async (trx) => { - const storedToken = await trx - .table("email_verification_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("email_verification_token") - .where("user_id", "=", storedToken.user_id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Sign up page - -Create `routes/signup/+page.svelte`. It will have a form with inputs for email and password. - -```svelte - - - -

Sign up

-
- -
- -
- -
-Sign in -``` - -### Create users - -Create `routes/signup/+page.server.ts` and define a new form action. - -When creating a user, use `"email"` as the provider id and the user's email as the provider user id. Make sure to set `email_verified` user property to `false`. We'll send a verification link when we create a new user, but we'll come back to that later. Redirect the user to the confirmation page (`/email-verification`). - -```ts -// routes/signup/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; -import { generateEmailVerificationToken } from "$lib/server/token"; -import { sendEmailVerificationLink } from "$lib/server/email"; - -import type { Actions } from "./$types"; - -export const actions: Actions = { - default: async ({ request, locals }) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - // basic check - if (!isValidEmail(email)) { - return fail(400, { - message: "Invalid email" - }); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return fail(400, { - message: "Invalid password" - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "email", // auth method - providerUserId: email.toLowerCase(), // unique id when using "email" auth method - password // hashed by Lucia - }, - attributes: { - email: email.toLowerCase(), - email_verified: false // `Number(false)` if stored as an integer - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - locals.auth.setSession(session); // set session cookie - - const token = await generateEmailVerificationToken(user.userId); - await sendEmailVerificationLink(token); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return new Response("Account already exists", { - status: 400 - }); - } - return fail(500, { - message: "An unknown error occurred" - }); - } - // make sure you don't throw inside a try/catch block! - throw redirect(302, "/email-verification"); - } -}; -``` - -```ts -// lib/server/email.ts -export const sendEmailVerificationLink = async (email, token: string) => { - const url = `http://localhost:5173/email-verification/${token}`; - await sendEmail(email, { - // ... - }); -}; -``` - -#### Validating emails - -Validating emails are notoriously hard as the RFC defining them is rather complicated. Here, we're checking: - -- There's one `@` -- There's at least a single character before `@` -- There's at least a single character after `@` -- No longer than 255 characters - -You can check if a `.` exists, but keep in mind `https://com.` is a valid url/domain. - -```ts -const isValidEmail = (maybeEmail: unknown): maybeEmail is string => { - if (typeof maybeEmail !== "string") return false; - if (maybeEmail.length > 255) return false; - const emailRegexp = /^.+@.+$/; // [one or more character]@[one or more character] - return emailRegexp.test(maybeEmail); -}; -``` - -### Redirect authenticated users - -Create `routes/signup/+page.server.ts` and define a load function. Redirect authenticated users to the profile page if their email is verified, or to the confirmation page if not. - -```ts -// routes/signup/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { PageServerLoad, Actions } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (session) { - if (!session.user.emailVerified) throw redirect(302, "/email-verification"); - throw redirect(302, "/"); - } - return {}; -}; - -export const actions: Actions = { - // ... -}; -``` - -## Sign in page - -Create `routes/login/+page.svelte`. It will have a form with inputs for email and password. - -```svelte - - - -

Sign in

-
- -
- -
- -
-Create an account -``` - -### Authenticate users - -Create `routes/login/+page.server.ts` and define a new form action. - -Authenticate the user with `"email"` as the provider id and their email as the provider user id. Make sure to make the email lowercase before calling `useKey()`. - -```ts -// routes/login/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { LuciaError } from "lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { Actions } from "./$types"; - -export const actions: Actions = { - default: async ({ request, locals }) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - // basic check - if (typeof email !== "string" || email.length < 1 || email.length > 255) { - return fail(400, { - message: "Invalid email" - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return fail(400, { - message: "Invalid password" - }); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("email", email.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - locals.auth.setSession(session); // set session cookie - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return fail(400, { - message: "Incorrect email or password" - }); - } - return fail(500, { - message: "An unknown error occurred" - }); - } - // redirect to profile page - // make sure you don't throw inside a try/catch block! - throw redirect(302, "/"); - } -}; -``` - -### Redirect authenticated users - -Create `routes/login/+page.server.ts`. Define a load function and implement redirects as we did in the sign up page. - -```ts -// routes/login/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { PageServerLoad, Actions } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (session) { - if (!session.user.emailVerified) throw redirect(302, "/email-verification"); - throw redirect(302, "/"); - } - return {}; -}; - -export const actions: Actions = { - // ... -}; -``` - -## Confirmation page - -Create `routes/email-verification/+page.svelte`. Users who just signed up and those without a verified email will be redirected to this page. It will include a form to resend the verification link. - -```svelte - - - -

Email verification

-

Your email verification link was sent to your inbox (i.e. console).

-

Resend verification link

-
- -
-``` - -This page should only accessible to users whose email is not verified. Create `routes/email-verification/+page.server.ts` and define a load function to handle redirects. - -```ts -// routes/email-verification/+page.server.ts -import { redirect } from "@sveltejs/kit"; - -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) throw redirect(302, "/login"); - if (session.user.emailVerified) { - throw redirect(302, "/"); - } - return {}; -}; -``` - -### Resend verification link - -Define a new form action in `routes/email-verification/+page.server.ts`. Redirect unauthenticated users and those who have already have a verified email. Create a new verification token and send the link to the user's inbox. - -```ts -// routes/email-verification/+page.server.ts -import { redirect, fail } from "@sveltejs/kit"; -import { generateEmailVerificationToken } from "$lib/server/token"; -import { sendEmailVerificationLink } from "$lib/server/email"; - -import type { PageServerLoad, Actions } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - // ... -}; - -export const actions: Actions = { - default: async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) throw redirect(302, "/login"); - if (session.user.emailVerified) { - throw redirect(302, "/"); - } - try { - const token = await generateEmailVerificationToken(session.user.userId); - await sendEmailVerificationLink(token); - return { - success: true - }; - } catch { - return fail(500, { - message: "An unknown error occurred" - }); - } - } -}; -``` - -## Verify email - -Create `routes/email-verification/[token]/+server.ts`. This route will validate the token stored in url and verify the user's email. The token can be accessed from the url with `params` - -Make sure to invalidate all sessions of the user. - -```ts -// routes/email-verification/[token]/+server.ts -import { auth } from "$lib/server/lucia"; -import { validateEmailVerificationToken } from "$lib/server/token"; - -import type { RequestHandler } from "./$types"; - -export const GET: RequestHandler = async ({ params, locals }) => { - const { token } = params; - try { - const userId = await validateEmailVerificationToken(token); - const user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateUserAttributes(user.userId, { - email_verified: true // `Number(true)` if stored as an integer - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - locals.auth.setSession(session); - return new Response(null, { - status: 302, - headers: { - Location: "/" - } - }); - } catch { - return new Response("Invalid email verification link", { - status: 400 - }); - } -}; -``` - -## Protect pages - -Protect normal pages (and form actions) by defining a load function in `+page.server.ts`, and redirecting unauthenticated users and those without a verified email. - -```ts -// +page.server.ts -import { redirect } from "@sveltejs/kit"; - -import type { PageServerLoad, Actions } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) throw redirect(302, "/login"); - if (!session.user.emailVerified) { - throw redirect(302, "/email-verification"); - } - // ... -}; - -export const actions: Actions = { - default: async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) throw redirect(302, "/login"); - if (!session.user.emailVerified) { - throw redirect(302, "/email-verification"); - } - // ... - } -}; -``` diff --git a/documentation/content/guidebook/email-verification-links/index.md b/documentation/content/guidebook/email-verification-links/index.md deleted file mode 100644 index 24f209437..000000000 --- a/documentation/content/guidebook/email-verification-links/index.md +++ /dev/null @@ -1,396 +0,0 @@ ---- -title: "Email authentication with verification links" -description: "Extend Lucia by implementing email and password authentication with email verification links" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started)._ - -If you're new to Lucia, we recommend starting with [Sign in with username and password](/guidebook/sign-in-with-username-and-password) starter guide as this guide will gloss over basic concepts and APIs. Make sure to implement password resets as well, which is covered in a separate guide (see [Password reset links](/guidebook/password-reset-link) guide). - -## Database - -### Update `user` table - -Add a `email` (`string`, unique) and `email_verified` (`boolean`) column to the user table. Keep in mind that some database do not support boolean types (notably SQLite and MySQL), in which case it should be stored as an integer (1 or 0). Lucia _does not_ support default database values. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - email: string; - email_verified: boolean; - }; - type DatabaseSessionAttributes = {}; -} -``` - -### Email verification token - -Create a new `email_verification_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ------------------------------------------ | -| `id` | `string` | ✓ | | Token to send inside the verification link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Configure Lucia - -Since we're dealing with the standard `Request` and `Response`, we'll use the [`web()`](/reference/lucia/modules/middleware#web) middleware. We'll expose the user's email and verification status to the `User` object returned by Lucia's APIs. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: "DEV", // "PROD" for production - middleware: web(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified // `Boolean(data.email_verified)` if stored as an integer - }; - } -}); - -export type Auth = typeof auth; -``` - -## Email verification tokens - -The token will be sent as part of the verification link. - -``` -http://localhost:/email-verification/ -``` - -When a user clicks the link, we validate of the token stored in the url and set `email_verified` user attributes to `true`. - -### Create new tokens - -`generateEmailVerificationToken()` will first check if a verification token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - const storedUserTokens = await db - .table("email_verification_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db.table("email_verification_token").insert({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }); - - return token; -}; -``` - -### Validate tokens - -`validateEmailVerificationToken()` will get the token and delete all tokens belonging to the user (which includes the used token). We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generateEmailVerificationToken = async (userId: string) => { - // ... -}; - -export const validateEmailVerificationToken = async (token: string) => { - const storedToken = await db.transaction(async (trx) => { - const storedToken = await trx - .table("email_verification_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("email_verification_token") - .where("user_id", "=", storedToken.user_id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Sign up user - -### Create users - -When creating a user, use `"email"` as the provider id and the user's email as the provider user id. Make sure to set `email_verified` user property to `false`. We'll send a verification link when we create a new user, but we'll come back to that later. Redirect the user to the confirmation page (`/email-verification`). - -```ts -import { auth } from "./lucia.js"; -import { generateEmailVerificationToken } from "./token.js"; -import { sendEmailVerificationLink } from "./email.js"; - -post("/signup", async (request: Request) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - // basic check - if (!isValidEmail(email)) { - return new Response("Invalid email", { - status: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return new Response("Invalid password", { - status: 400 - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "email", // auth method - providerUserId: email.toLowerCase(), // unique id when using "email" auth method - password // hashed by Lucia - }, - attributes: { - email: email.toLowerCase(), - email_verified: false // `Boolean(false)` if stored as an integer - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - - const token = await generateEmailVerificationToken(user.userId); - await sendEmailVerificationLink(token); - - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - headers: { - Location: "/", // profile page - "Set-Cookie": sessionCookie.serialize() // store session cookie - }, - status: 302 - }); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return new Response("Account already exists", { - status: 400 - }); - } - - return new Response("An unknown error occurred", { - status: 500 - }); - } -}); -``` - -```ts -// email.ts -export const sendEmailVerificationLink = async (email, token: string) => { - const url = `http://localhost:3000/email-verification/${token}`; - await sendEmail(email, { - // ... - }); -}; -``` - -#### Validating emails - -Validating emails are notoriously hard as the RFC defining them is rather complicated. Here, we're checking: - -- There's one `@` -- There's at least a single character before `@` -- There's at least a single character after `@` -- No longer than 255 characters - -You can check if a `.` exists, but keep in mind `https://com.` is a valid url/domain. - -```ts -const isValidEmail = (maybeEmail: unknown): maybeEmail is string => { - if (typeof maybeEmail !== "string") return false; - if (maybeEmail.length > 255) return false; - const emailRegexp = /^.+@.+$/; // [one or more character]@[one or more character] - return emailRegexp.test(maybeEmail); -}; -``` - -## Sign in user - -### Authenticate users - -Authenticate the user with `"email"` as the provider id and their email as the provider user id. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -post("/login", async (request: Request) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - // basic check - if (typeof email !== "string" || email.length < 1 || email.length > 255) { - return new Response("Invalid email", { - status: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return new Response("Invalid password", { - status: 400 - }); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("email", email.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - headers: { - Location: "/", // profile page - "Set-Cookie": sessionCookie.serialize() // store session cookie - }, - status: 302 - }); - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return new Response("Incorrect email of password", { - status: 400 - }); - } - return new Response("An unknown error occurred", { - status: 500 - }); - } -}); -``` - -## Resend verification link - -Redirect unauthenticated users and those who have already have a verified email. Create a new verification token and send the link to the user's inbox. - -```ts -import { auth } from "@/auth/lucia"; -import { generateEmailVerificationToken } from "./token.js"; -import { sendEmailVerificationLink } from "./email.js"; - -post("/email-verification", async (request: Request) => { - const authRequest = auth.handleRequest(request); - const session = await authRequest.validate(); - if (!session) { - return new Response(null, { - status: 401 - }); - } - if (session.user.emailVerified) { - // email already verified - return new Response(null, { - status: 422 - }); - } - try { - const token = await generateEmailVerificationToken(session.user.userId); - await sendEmailVerificationLink(session.user.email, token); - return new Response(); - } catch { - return new Response("An unknown error occurred", { - status: 500 - }); - } -}); -``` - -## Verify email - -Create route `/email-verification/`, where `` is a dynamic route params. This route will validate the token stored in url and verify the user's email. - -Make sure to invalidate all sessions of the user. - -```ts -import { auth } from "./lucia.js"; -import { validateEmailVerificationToken } from "./token.js"; - -get("/email-verification/[token]", async (request: Request) => { - const token = getTokenParams(request.url); - try { - const userId = await validateEmailVerificationToken(token); - const user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateUserAttributes(user.userId, { - email_verified: true // `Number(true)` if stored as an integer - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - status: 302, - headers: { - Location: "/", // profile page - "Set-Cookie": sessionCookie.serialize() - } - }); - } catch { - return new Response("Invalid email verification link", { - status: 400 - }); - } -}); -``` diff --git a/documentation/content/guidebook/github-oauth-native/electron.md b/documentation/content/guidebook/github-oauth-native/electron.md deleted file mode 100644 index 21c2c3074..000000000 --- a/documentation/content/guidebook/github-oauth-native/electron.md +++ /dev/null @@ -1,311 +0,0 @@ ---- -title: "Github OAuth in Electron" -description: "Learn how to implement Github OAuth in Electron desktop applications" ---- - -> These guides are not beginner friendly and do not cover the basics of Lucia. We recommend reading the [Github OAuth](/guidebook/github-oauth) guide for regular websites first. - -We'll be using bearer tokens instead of cookies to validate users. For the most part, authenticating the user is identical to regular web applications. The user is redirected to Github, then back to your server with a `code`, which is then exchanged for an access token, and a new user/session is created. - -To send the session token (ie. session id) from the server back to our application, we'll be using deep-links which allow us to open applications using a url. - -### Clone project - -You can get started immediately by cloning the [example](https://github.com/lucia-auth/examples/tree/main/electron/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/electron/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/electron/github-oauth). - -## Server - -Make sure you've installed `lucia` and `@lucia-auth/oauth`, create 4 API routes: - -- GET `/user`: Returns the current user -- GET `/login/github`: Redirects the user to the Github authorization url -- GET `/login/github/callback`: Handles callback from Github and redirects the user to the localhost server with the session id -- POST `/logout`: Handles logouts - -This example uses [Hono](https://hono.dev) but you should be able to easily convert it to whatever framework you use. - -There are few key differences between the code for regular web applications. First, we'll be using bearer tokens instead of cookies. As such, [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) is used instead of `AuthRequest.validate()`. We'll send the user back to the application with a deep-link, where the session token is stored as a search params. The guide uses `electron-app` protocol as an example, but you can configure it in your Electron application. - -```ts -import { lucia } from "lucia"; -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export type Auth = typeof auth; - -export const githubAuth = github(auth, { - clientId, - clientSecret -}); -``` - -```ts -import { auth, githubAuth } from "./auth"; -import { OAuthRequestError } from "@lucia-auth/oauth"; - -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { getCookie, setCookie } from "hono/cookie"; - -const app = new Hono(); - -app.get("/user", async (c) => { - const authRequest = auth.handleRequest(c); - const session = await authRequest.validateBearerToken(); - if (!session) { - return c.newResponse(null, 401); - } - return c.json(session.user); -}); - -app.get("/login/github", async (c) => { - const [authorizationUrl, state] = await githubAuth.getAuthorizationUrl(); - setCookie(c, "github_oauth_state", state, { - path: "/", - maxAge: 60 * 10, // 10 min - httpOnly: true, - secure: process.env.NODE_ENV === "production" - }); - return c.redirect(authorizationUrl.toString()); -}); - -app.get("/login/github/callback", async (c) => { - const url = new URL(c.req.url); - const code = url.searchParams.get("code"); - if (!code) return c.newResponse(null, 400); - const state = url.searchParams.get("state"); - const storedState = getCookie(c, "github_oauth_state"); - if (!state || !storedState || state !== storedState) { - return c.newResponse(null, 400); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - let user = await getExistingUser(); - if (!user) { - user = await createUser({ - attributes: { - username: githubUser.login - } - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - return c.redirect( - `electron-app://login?session_token=${session.sessionId}` - ); - } catch (e) { - console.log(e); - if (e instanceof OAuthRequestError) { - // invalid code - return c.newResponse(null, 400); - } - return c.newResponse(null, 500); - } -}); - -app.post("/logout", async (c) => { - const authRequest = auth.handleRequest(c); - const session = await authRequest.validateBearerToken(); - if (!session) return c.newResponse(null, 401); - await auth.invalidateSession(session.sessionId); - return c.newResponse(null, 200); -}); - -serve(app); -``` - -## Electron app - -This example uses [Electron Forge](https://www.electronforge.io), which currently is the recommended way to package Electron apps. - -### Setup deep linking - -In `forge.config.ts`, update `packagerConfig.protocols` and `mimeType` for `MakerDeb`. This guide uses `electron-app` as an example. - -```ts -// forge.config.ts -import type { ForgeConfig } from "@electron-forge/shared-types"; - -// ... -import { MakerDeb } from "@electron-forge/maker-deb"; - -const config: ForgeConfig = { - packagerConfig: { - protocols: [ - { - name: "Electron app", - schemes: ["electron-app"] - } - ] - }, - makers: [ - // ... - new MakerDeb({ - options: { - mimeType: ["x-scheme-handler/electron-app"] - } - }) - ] - // ... -}; - -export default config; -``` - -In `src/main.ts`, set the default protocol client with `App.setAsDefaultProtocolClient()`. - -```ts -// src/main.ts -import { app } from "electron"; -import path from "path"; - -if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient("electron-app", process.execPath, [ - path.resolve(process.argv[1]) - ]); - } -} else { - app.setAsDefaultProtocolClient("electron-app"); -} -``` - -### Setup IPC listeners - -These will be invoked from `src/preload.ts`. - -```ts -// src/main.ts -import { app, BrowserWindow, shell, net } from "electron"; - -ipcMain.handle("auth:signInWithGithub", () => { - shell.openExternal("http://localhost:3000/login/github"); -}); - -ipcMain.handle("auth:getUser", async (e, sessionToken: string) => { - const response = await net.fetch("http://localhost:3000/user", { - headers: { - Authorization: `Bearer ${sessionToken}` - } - }); - if (!response.ok) { - return null; - } - return await response.json(); -}); - -ipcMain.handle("auth:signOut", async (e, sessionToken: string) => { - await net.fetch("http://localhost:3000/logout", { - method: "POST", - headers: { - Authorization: `Bearer ${sessionToken}` - } - }); -}); -``` - -### Setup login callback - -Listen for the deep-link callback, parse the url, and send the token to the renderer with the `auth-state-update` event (`preload.ts`). - -```ts -// src/main.ts -import { app, BrowserWindow, ipcMain, shell, net } from "electron"; - -// new BrowserWindow() instance -let mainWindow: BrowserWindow; - -// for windows, linux -app.on("second-instance", (_, commandLine) => { - // Someone tried to run a second instance, we should focus our window. - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } - const url = commandLine.at(-1); - handleDeepLinkCallback(url); -}); - -// macos -app.on("open-url", (_, url) => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - handleDeepLinkCallback(url); -}); - -const handleDeepLinkCallback = (url: string) => { - if (!url.startsWith("electron-app://login?")) return; - const params = new URLSearchParams(url.replace("electron-app://login?", "")); - const sessionToken = params.get("session_token"); - if (!sessionToken) return; - mainWindow.webContents.send("auth-state-update", sessionToken); -}; - -const createWindow = () => { - mainWindow = new BrowserWindow({ - width: 800, - height: 600, - webPreferences: { - preload: path.join(__dirname, "preload.js") - } - }); - // ... -}; -``` - -### Frontend - -Listen for the `auth-state-update` event sent by `main.ts`, and get the user and store token as needed. While we're there are ways to store tokens in with obfuscation, security is comparable to using `localStorage` API in a browser. - -```ts -// src/preload.ts -import { ipcRenderer } from "electron"; - -ipcRenderer.on("auth-state-update", async (e, sessionToken: string | null) => { - if (sessionToken) { - const user = await getUser(sessionToken); - if (user) { - localStorage.setItem("session_token", sessionToken); - // signed in - } else { - localStorage.removeItem("session_token"); - } - } else { - localStorage.removeItem("session_token"); - } -}); - -const signInWithGithub = async () => { - await ipcRenderer.invoke("auth:signInWithGithub"); -}; - -const getUser = async (sessionToken: string): Promise => { - return await ipcRenderer.invoke("auth:getUser", sessionToken); -}; - -const signOut = async () => { - const sessionToken = localStorage.getItem("session_token"); - if (!sessionToken) return; - await ipcRenderer.invoke("auth:signOut", sessionToken); - renderUserProfile(null); - localStorage.removeItem("session_token"); -}; - -type User = { - userId: string; - username: string; -}; -``` diff --git a/documentation/content/guidebook/github-oauth-native/expo.md b/documentation/content/guidebook/github-oauth-native/expo.md deleted file mode 100644 index 0e6c6a277..000000000 --- a/documentation/content/guidebook/github-oauth-native/expo.md +++ /dev/null @@ -1,189 +0,0 @@ ---- -title: "Github OAuth in Expo" -description: "Learn how to implement Github OAuth in Expo mobile applications" ---- - -> These guides are not beginner friendly and do not cover the basics of Lucia. We recommend reading the [Github OAuth](/guidebook/github-oauth) guide for regular websites first. - -We'll be using bearer tokens instead of cookies to validate users. For the most part, authenticating the user is identical to regular web applications. The user is redirected to Github, then back to your server with a `code`, which is then exchanged for an access token, and a new user/session is created. - -To send the session token (ie. session id) from the server back to our application, we'll be using deep-links which allow us to open applications using a url. - -### Clone project - -You can get started immediately by cloning the [example](https://github.com/lucia-auth/examples/tree/main/expo/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/expo/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/expo/github-oauth). - -## Server - -Make sure you've installed `lucia` and `@lucia-auth/oauth`, create 4 API routes: - -- GET `/user`: Returns the current user -- GET `/login/github`: Redirects the user to the Github authorization url -- GET `/login/github/callback`: Handles callback from Github and redirects the user to the localhost server with the session id -- POST `/logout`: Handles logouts - -This example uses [Hono](https://hono.dev) but you should be able to easily convert it to whatever framework you use. - -There are few key differences between the code for regular web applications. First, we'll be using bearer tokens instead of cookies. As such, [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) is used instead of `AuthRequest.validate()`. We'll send the user back to the application with a deep-link, where the session token is stored as a search params. The guide uses port 8081 (the default port) for the redirect, but it may differ for your application. - -```ts -import { lucia } from "lucia"; -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export type Auth = typeof auth; - -export const githubAuth = github(auth, { - clientId, - clientSecret -}); -``` - -```ts -import { auth, githubAuth } from "./auth"; -import { OAuthRequestError } from "@lucia-auth/oauth"; - -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { getCookie, setCookie } from "hono/cookie"; - -const app = new Hono(); - -app.get("/user", async (c) => { - const authRequest = auth.handleRequest(c); - const session = await authRequest.validateBearerToken(); - if (!session) { - return c.newResponse(null, 401); - } - return c.json(session.user); -}); - -app.get("/login/github", async (c) => { - const [authorizationUrl, state] = await githubAuth.getAuthorizationUrl(); - setCookie(c, "github_oauth_state", state, { - path: "/", - maxAge: 60 * 10, // 10 min - httpOnly: true, - secure: process.env.NODE_ENV === "production" - }); - return c.redirect(authorizationUrl.toString()); -}); - -app.get("/login/github/callback", async (c) => { - const url = new URL(c.req.url); - const code = url.searchParams.get("code"); - if (!code) return c.newResponse(null, 400); - const state = url.searchParams.get("state"); - const storedState = getCookie(c, "github_oauth_state"); - if (!state || !storedState || state !== storedState) { - return c.newResponse(null, 400); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - let user = await getExistingUser(); - if (!user) { - user = await createUser({ - attributes: { - username: githubUser.login - } - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - return c.redirect( - `exp://192.168.2.100:8081/login?session_token=${session.sessionId}` - ); - } catch (e) { - console.log(e); - if (e instanceof OAuthRequestError) { - // invalid code - return c.newResponse(null, 400); - } - return c.newResponse(null, 500); - } -}); - -app.post("/logout", async (c) => { - const authRequest = auth.handleRequest(c); - const session = await authRequest.validateBearerToken(); - if (!session) return c.newResponse(null, 401); - await auth.invalidateSession(session.sessionId); - return c.newResponse(null, 200); -}); - -serve(app); -``` - -## Expo app - -Make sure you have installed `expo-web-browser`, `expo-linking`, and `expo-secure-store`. - -``` -npm i expo-web-browser expo-linking expo-secure-store -``` - -Use `Browser.openAuthSessionAsync()` to open a new browser window within the app and listen for the callback. Parse the url and store the session token with `SecureStore`. - -```tsx -// app/App.tsx -import * as Browser from "expo-web-browser"; -import * as Linking from "expo-linking"; -import * as SecureStore from "expo-secure-store"; - -export default function App() { - const signIn = async (): Promise => { - const result = await Browser.openAuthSessionAsync( - "http://localhost:3000/login/github", - "exp://192.168.2.100:8081/login" - ); - if (result.type !== "success") return; - const url = Linking.parse(result.url); - const sessionToken = url.queryParams?.session_token?.toString() ?? null; - if (!sessionToken) return; - const user = await getUser(sessionToken); - await SecureStore.setItemAsync("session_token", sessionToken); - // ... - }; - - // ... -} - -const signOut = async () => { - const sessionToken = await SecureStore.getItemAsync("session_token"); - const response = await fetch("http://localhost:3000/logout", { - method: "POST", - headers: { - Authorization: `Bearer ${sessionToken}` - } - }); - if (!response.ok) return; - await SecureStore.deleteItemAsync("session_token"); -}; - -const getUser = async (sessionToken: string): Promise => { - const response = await fetch("http://localhost:3000/user", { - headers: { - Authorization: `Bearer ${sessionToken}` - } - }); - if (!response.ok) return null; - return await response.json(); -}; - -type User = { - userId: string; - username: string; -}; -``` diff --git a/documentation/content/guidebook/github-oauth-native/index.md b/documentation/content/guidebook/github-oauth-native/index.md deleted file mode 100644 index e24cccb84..000000000 --- a/documentation/content/guidebook/github-oauth-native/index.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: "Github OAuth in native applications" -description: "Learn how to implement Github OAuth in desktop and mobile applications" ---- - -These guides are not beginner friendly and do not cover the basics of Lucia. We recommend reading the [Github OAuth guide]() for regular websites first. In addition, Lucia is a library to be used in a server environment, and as such all these guides will require a TypeScript server. - -- [Electron](/guidebook/github-oauth-native/electron) -- [Expo](/guidebook/github-oauth-native/expo) -- [Tauri](/guidebook/github-oauth-native/tauri) -- (more in progress) diff --git a/documentation/content/guidebook/github-oauth-native/tauri.md b/documentation/content/guidebook/github-oauth-native/tauri.md deleted file mode 100644 index eb80ac278..000000000 --- a/documentation/content/guidebook/github-oauth-native/tauri.md +++ /dev/null @@ -1,317 +0,0 @@ ---- -title: "Github OAuth in Tauri" -description: "Learn how to implement Github OAuth in Tauri desktop applications" ---- - -> These guides are not beginner friendly and do not cover the basics of Lucia. We recommend reading the [Github OAuth](/guidebook/github-oauth) guide for regular websites first. - -We'll be using bearer tokens instead of cookies to validate users. For the most part, authenticating the user is identical to regular web applications. The user is redirected to Github, then back to your server with a `code`, which is then exchanged for an access token, and a new user/session is created. The hard part is sending the session token (ie. session id) from the server back to our application. - -One option is to use a deep-links, but getting that to work in a dev environment is tricky and isn't officially supported in Tauri. Another option is to open the Github authorization url in a webview window, which would allow us to intercept navigation and read urls (where we can store the session id). However, since a webview window is in its own isolated context, the user would have to enter their Github username/password every time. - -The strategy we'll be using is to create a super basic local server in the background. After creating a session, the server can redirect the user to the localhost server with the session token. - -### Clone project - -You can get started immediately by cloning the [example](https://github.com/lucia-auth/examples/tree/main/tauri/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/tauri/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/tauri/github-oauth). - -## Server - -Make sure you've installed `lucia` and `@lucia-auth/oauth`, create 4 API routes: - -- GET `/user`: Returns the current user -- GET `/login/github`: Redirects the user to the Github authorization url -- GET `/login/github/callback`: Handles callback from Github and redirects the user to the localhost server with the session id -- POST `/logout`: Handles logouts - -This example uses [Hono](https://hono.dev) but you should be able to easily convert it to whatever framework you use. - -There are few key differences between the code for regular web applications. First, we'll be using bearer tokens instead of cookies. As such, [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) is used instead of `AuthRequest.validate()`. We're also passing a `port` to `/login/github`. This is the port number of the localhost server created by the app (determined at runtime), and it will used for the callback url; - -```ts -import { lucia } from "lucia"; -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export type Auth = typeof auth; - -export const githubAuth = github(auth, { - clientId, - clientSecret -}); -``` - -```ts -import { auth, githubAuth } from "./auth"; -import { OAuthRequestError } from "@lucia-auth/oauth"; - -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { getCookie, setCookie } from "hono/cookie"; - -const app = new Hono(); - -app.get("/user", async (c) => { - const authRequest = auth.handleRequest(c); - const session = await authRequest.validateBearerToken(); - if (!session) { - return c.newResponse(null, 401); - } - return c.json(session.user); -}); - -app.get("/login/github", async (c) => { - const url = new URL(c.req.url); - const port = url.searchParams.get("port"); - if (!port) return c.newResponse(null, 400); - const [authorizationUrl, state] = await githubAuth.getAuthorizationUrl(); - setCookie(c, "github_oauth_state", state, { - path: "/", - maxAge: 60 * 10, // 10 min - httpOnly: true, - secure: process.env.NODE_ENV === "production" - }); - setCookie(c, "redirect_port", port, { - path: "/", - maxAge: 60 * 10, // 10 min - httpOnly: true, - secure: process.env.NODE_ENV === "production" - }); - return c.redirect(authorizationUrl.toString()); -}); - -app.get("/login/github/callback", async (c) => { - const url = new URL(c.req.url); - const code = url.searchParams.get("code"); - if (!code) return c.newResponse(null, 400); - const state = url.searchParams.get("state"); - const storedState = getCookie(c, "github_oauth_state"); - if (!state || !storedState || state !== storedState) { - return c.newResponse(null, 400); - } - const redirectPort = getCookie(c, "redirect_port"); // get port we set in /login/github - if (!redirectPort) return c.newResponse(null, 400); - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - let user = await getExistingUser(); - if (!user) { - user = await createUser({ - attributes: { - username: githubUser.login - } - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - return c.redirect( - `http://localhost:${redirectPort}?session_token=${session.sessionId}` - ); - } catch (e) { - console.log(e); - if (e instanceof OAuthRequestError) { - // invalid code - return c.newResponse(null, 400); - } - return c.newResponse(null, 500); - } -}); - -app.post("/logout", async (c) => { - const authRequest = auth.handleRequest(c); - const session = await authRequest.validateBearerToken(); - if (!session) return c.newResponse(null, 401); - await auth.invalidateSession(session.sessionId); - return c.newResponse(null, 200); -}); - -serve(app); -``` - -## App - -### Setup - -Update your `allowlist` to include `shell.open` and `http.request`. Make sure to add your server url to `http.scope` array. - -```json -// tauri.conf.json -{ - "tauri": { - "allowlist": { - "shell": { - "open": true - }, - "http": { - "request": true, - "scope": [" http://localhost:3000/*"] // wherever you server is hosted - } - } - // ... - } - // ... -} -``` - -In `src-tauri`, install `tokio`. - -```toml -# Cargo.toml -[dependencies] -# ... -tokio = { version = "1.32.0", features = ["net"] } -``` - -### Frontend - -We first define 3 basic functions: - -- `signInWithGithub()`: Mostly a wrapper for Rust code. Waits for the session id and gets the user object. -- `signOut()`: Calls `/logout` to sign out the user -- `getUser()`: Calls `/user` to get the current user - -While storing tokens in local storage isn't the most optimal, it should be fine for now. - -```ts -// src/main.ts -import { invoke } from "@tauri-apps/api/tauri"; -import { getClient, ResponseType } from "@tauri-apps/api/http"; - -const signInWithGithub = async () => { - try { - // call `authenticate()` internal function (see next section) - // this opens a new browser tab to authenticate with Github - // and listens for the callback from the server - const sessionToken = await invoke("authenticate"); - localStorage.setItem("session_token", sessionToken); - const user = await getUser(sessionToken); - // ... - } catch (e) { - console.log(e); - } -}; - -const signOut = async () => { - const sessionToken = localStorage.getItem("session_token"); - if (!sessionToken) return; - const client = await getClient(); - const response = await client.request({ - url: "http://localhost:3000/logout", - method: "POST", - headers: { - Authorization: `Bearer ${sessionToken}` - } - }); - if (!response.ok) return; - localStorage.removeItem("session_token"); -}; - -const getUser = async (sessionToken: string): Promise => { - const client = await getClient(); - const response = await client.get("http://localhost:3000/user", { - headers: { - Authorization: `Bearer ${sessionToken}` // remember to send your session id as bearer token - }, - responseType: ResponseType.JSON - }); - if (!response.ok) { - localStorage.removeItem("session_token"); - return null; - } - return response.data; -}; - -type User = { - userId: string; - username: string; -}; -``` - -### Internals - -> Note: The author of this library has very limited experience with Rust. If you have any suggestions, please open a new issue or PR. - -`authenticate()` will create a new HTTP server locally. This will listen for a request, which will indicate that a user has successfully signed in, and the session id will be stored in the query string. - -We're not looping over the listener since we only except the user to visit this page once. - -```rust -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use tauri::api::shell; -use tauri::{AppHandle, Manager}; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::net::TcpListener; - -#[tauri::command] -async fn authenticate(app_handle: AppHandle) -> Result { - // create new server - // port 0 = let the computer find an unused port - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - // open the /login page with the default browser - shell::open( - &app_handle.shell_scope(), - format!("http://localhost:3000/login/github?port={}", port), - None, - ) - .unwrap(); - // wait until incoming request - let (mut stream, _) = listener.accept().await.unwrap(); - let (reader, writer) = stream.split(); - let mut buf_reader = BufReader::new(reader); - let mut buf = String::new(); - // get first line of request message - buf_reader.read_line(&mut buf).await.unwrap(); - // get url (2nd item) - let url = buf.split_ascii_whitespace().nth(1).unwrap(); - // get query string - let (_, query) = url.split_once('?').unwrap_or_default(); - for query_pair in query.split('&') { - // parse query string and find `session_token` - if let Some(("session_token", value)) = query_pair.split_once('=') { - // send a success message - // you can optionally send a redirect response to a proper success page - // or even a deep/universal link to open the application - writer - .try_write( - b"HTTP/1.1 200 OK\r\n\r\nSuccessfully logged in. You can now close this tab.", - ) - .unwrap(); - // return session id as session token - return Ok(value.to_string()); - } - } - Err("Missing session".to_string()) -} - -fn main() { - tauri::Builder::default() - .invoke_handler(tauri::generate_handler![authenticate]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} -``` - -#### Request message - -A standard request message looks like this: - -``` -GET /path?key=value HTTP/1.1 -Host: localhost:3000 - -some body text -``` diff --git a/documentation/content/guidebook/github-oauth/$astro.md b/documentation/content/guidebook/github-oauth/$astro.md deleted file mode 100644 index 185b2ba83..000000000 --- a/documentation/content/guidebook/github-oauth/$astro.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -title: "GitHub OAuth in Astro" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/astro) and that you've implement the [recommended middleware](/getting-started/astro#set-up-middleware)._ - -This guide will cover how to implement GitHub OAuth using Lucia in Astro. It will have 3 parts: - -- A sign up page -- An endpoint to authenticate users with GitHub -- A profile page with a logout button - -As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -### Clone project - -You can get started immediately by cloning the [Astro example](https://github.com/lucia-auth/examples/tree/main/astro/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/astro/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/astro/github-oauth). - -## Create an OAuth app - -[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 client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` in `env.d.ts` whenever you add any new columns to the user table. - -```ts -// src/env.d.ts -/// -declare namespace Lucia { - type Auth = import("./lib/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// src/lib/lucia.ts -import { lucia } from "lucia"; -import { astro } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: import.meta.env.DEV ? "DEV" : "PROD", - middleware: astro(), - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// src/lib/lucia.ts -import { lucia } from "lucia"; -import { astro } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: import.meta.env.GITHUB_CLIENT_ID, - clientSecret: import.meta.env.GITHUB_CLIENT_SECRET -}); - -export type Auth = typeof auth; -``` - -## Sign in page - -Create `pages/login/index.astro`. It will have a "Sign in with GitHub" button (actually a link). - -```astro ---- -// pages/login/index.astro ---- - -

Sign in

-Sign in with GitHub -``` - -When a user clicks the link, the destination (`/login/github`) will redirect the user to GitHub to be authenticated. - -## Generate authorization url - -Create `pages/login/github/index.ts` and handle GET requests. [`GithubProvider.getAuthorizationUrl()`](/oauth/providers/github#getauthorizationurl) will create a new GitHub authorization url, where the user will be authenticated in github.com. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -// pages/login/github/index.ts -import { githubAuth } from "../../../lib/lucia"; - -import type { APIRoute } from "astro"; - -export const get: APIRoute = async (context) => { - const [url, state] = await githubAuth.getAuthorizationUrl(); - // store state - context.cookies.set("github_oauth_state", state, { - httpOnly: true, - secure: !import.meta.env.DEV, - path: "/", - maxAge: 60 * 60 - }); - return context.redirect(url.toString(), 302); -}; -``` - -## Validate callback - -Create `pages/login/github/callback.ts` and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). Since we've setup middleware, `AuthRequest` is accessible as `context.locals.auth`. - -```ts -// pages/login/github/callback.ts -import { auth, githubAuth } from "../../../lib/lucia.js"; -import { OAuthRequestError } from "@lucia-auth/oauth"; - -import type { APIRoute } from "astro"; - -export const get: APIRoute = async (context) => { - const storedState = context.cookies.get("github_oauth_state").value; - const state = context.url.searchParams.get("state"); - const code = context.url.searchParams.get("code"); - // validate state - if (!storedState || !state || storedState !== state || !code) { - return new Response(null, { - status: 400 - }); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - context.locals.auth.setSession(session); - return context.redirect("/login", 302); // redirect to profile page - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return new Response(null, { - status: 400 - }); - } - return new Response(null, { - status: 500 - }); - } -}; -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign in page. You can validate requests by creating a new [`AuthRequest` instance](/reference/lucia/interfaces/authrequest) with [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest), which is stored as `Astro.locals.auth`, and calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```astro ---- -// src/pages/login.astro -import { auth } from "../lib/lucia"; - -const session = await Astro.locals.auth.validate(); -if (session) return Astro.redirect("/", 302); // redirect to profile page ---- - -

Sign in

-Sign in with GitHub -``` - -## Profile page - -Create `src/pages/index.astro`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you'll see that `User.githubUsername` exists because we defined it in first step with `getUserAttributes()` configuration. - -```astro ---- -// src/pages/index.astro -const session = await Astro.locals.auth.validate(); -if (!session) return Astro.redirect("/login", 302); ---- - - - - - - - - -

Profile

-

User id: {session.user.userId}

-

GitHub username: {session.user.githubUsername}

-
- -
- - -``` - -### Sign out users - -Create `src/pages/logout.ts` and handle POST requests. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be done by passing `null` to `AuthRequest.setSession()`. - -```ts -// src/pages/logout.ts -import { auth } from "../lib/lucia"; - -import type { APIRoute } from "astro"; - -export const post: APIRoute = async (context) => { - const session = await context.locals.auth.validate(); - if (!session) { - return new Response("Unauthorized", { - status: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - context.locals.auth.setSession(null); - return context.redirect("/login", 302); -}; -``` diff --git a/documentation/content/guidebook/github-oauth/$express.md b/documentation/content/guidebook/github-oauth/$express.md deleted file mode 100644 index bd7789c30..000000000 --- a/documentation/content/guidebook/github-oauth/$express.md +++ /dev/null @@ -1,270 +0,0 @@ ---- -title: "GitHub OAuth in Express" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/express)._ - -This guide will cover how to implement GitHub OAuth using Lucia in Express with session cookies. As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -### Clone project - -You can get started immediately by cloning the [Express example](https://github.com/lucia-auth/examples/tree/main/express/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/express/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/express/github-oauth). - -## Create an OAuth app - -[Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). Set the redirect uri, for example `http://localhost:3000/login/github/callback`. - -Copy and paste the client id and client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { express } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: express(), - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Configure Express - -Set up the body parser middleware. - -```ts -import express from "express"; - -const app = express(); - -app.use(express.urlencoded()); // for application/x-www-form-urlencoded (forms) -app.use(express.json()); // for application/json -``` - -## Initialize the OAuth integration - -Install the OAuth integration and `dotenv`. - -``` -npm i @lucia-auth/oauth dotenv -pnpm add @lucia-auth/oauth dotenv -yarn add @lucia-auth/oauth dotenv -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { express } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; -import dotenv from "dotenv"; - -dotenv.config(); - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: process.env.GITHUB_CLIENT_ID ?? "", - clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "" -}); - -export type Auth = typeof auth; -``` - -## Generate authorization url - -Create a new GitHub authorization url, where the user should be redirected to. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -import { auth, githubAuth } from "./lucia.js"; - -app.get("/login/github", async (req, res) => { - const [url, state] = await githubAuth.getAuthorizationUrl(); - res.cookie("github_oauth_state", state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: 60 * 60 * 1000 // 1 hour - }); - return res.status(302).setHeader("Location", url.toString()).end(); -}); -``` - -For example, the user should be redirected to `/login/github` when they click "Sign in with GitHub." - -```html -Sign in with GitHub -``` - -## Validate callback - -Create your OAuth callback route that you defined when registering an OAuth app with GitHub, and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with Express' `Request` and `Response`. - -You can use [`parseCookie()`](/reference/lucia/modules/utils#parsecookie) provided by Lucia to read the state cookie. - -```ts -import { auth, githubAuth } from "./lucia.js"; -import { parseCookie } from "lucia/utils"; -import { OAuthRequestError } from "@lucia-auth/oauth"; - -app.get("/login/github/callback", async (req, res) => { - const cookies = parseCookie(req.headers.cookie ?? ""); - const storedState = cookies.github_oauth_state; - const state = req.query.state; - const code = req.query.code; - // validate state - if ( - !storedState || - !state || - storedState !== state || - typeof code !== "string" - ) { - return res.sendStatus(400); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(req, res); - authRequest.setSession(session); - return res.status(302).setHeader("Location", "/").end(); - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return res.sendStatus(400); - } - return res.sendStatus(500); - } -}); -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Get authenticated user - -You can validate requests and get the current session/user by using [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). It returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -You can see that `User.username` exists because we defined it with `getUserAttributes()` configuration. - -```ts -get("/user", async (req, res) => { - const authRequest = auth.handleRequest(req, res); - const session = await authRequest.validate(); - if (session) { - const user = session.user; - const username = user.username; - // ... - } - // ... -}); -``` - -## Sign out users - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -import { auth } from "./lucia.js"; - -app.post("/logout", async (req, res) => { - const authRequest = auth.handleRequest(req, res); - const session = await authRequest.validate(); - if (!session) { - return res.sendStatus(401); - } - await auth.invalidateSession(session.sessionId); - authRequest.setSession(null); - // redirect back to login page - return res.status(302).setHeader("Location", "/login").end(); -}); -``` diff --git a/documentation/content/guidebook/github-oauth/$hono.md b/documentation/content/guidebook/github-oauth/$hono.md deleted file mode 100644 index c95f87d3c..000000000 --- a/documentation/content/guidebook/github-oauth/$hono.md +++ /dev/null @@ -1,247 +0,0 @@ ---- -title: "GitHub OAuth in Hono" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/hono)._ - -This guide will cover how to implement GitHub OAuth using Lucia in Hono with session cookies. As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -## Create an OAuth app - -[Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). Set the redirect uri, for example `http://localhost:3000/login/github/callback`. - -Copy and paste the client id and client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { hono } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: hono(), - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration and `dotenv`. - -``` -npm i @lucia-auth/oauth dotenv -pnpm add @lucia-auth/oauth dotenv -yarn add @lucia-auth/oauth dotenv -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { hono } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; -import dotenv from "dotenv"; - -dotenv.config(); - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: process.env.GITHUB_CLIENT_ID ?? "", - clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "" -}); - -export type Auth = typeof auth; -``` - -## Generate authorization url - -Create a new GitHub authorization url, where the user should be redirected to. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -import { setCookie } from "hono/cookie"; -import { auth, githubAuth } from "./lucia.js"; - -app.get("/login/github", async (context) => { - const [url, state] = await githubAuth.getAuthorizationUrl(); - setCookie(context, "github_oauth_state", state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: 60 * 60 - }); - return context.redirect(url.toString()); -}); -``` - -For example, the user should be redirected to `/login/github` when they click "Sign in with GitHub." - -```html -Sign in with GitHub -``` - -## Validate callback - -Create your OAuth callback route that you defined when registering an OAuth app with GitHub, and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with Hono request `Context`. - -You can use [`parseCookie()`](/reference/lucia/modules/utils#parsecookie) provided by Lucia to read the state cookie. - -```ts -import { auth, githubAuth } from "./lucia.js"; -import { parseCookie } from "lucia/utils"; -import { OAuthRequestError } from "@lucia-auth/oauth"; -import { getCookie } from "hono/cookie"; - -app.get("/login/github/callback", async (context) => { - const storedState = getCookie(context, "github_oauth_state"); - const { code, state } = context.req.query(); - // validate state - if ( - !storedState || - !state || - storedState !== state || - typeof code !== "string" - ) { - return context.text("Bad request", 400); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(context); - authRequest.setSession(session); - return context.redirect("/"); - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return context.text("Bad request", 400); - } - return context.text("An unknown error occurred", 500); - } -}); -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Get authenticated user - -You can validate requests and get the current session/user by using [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). It returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -You can see that `User.username` exists because we defined it with `getUserAttributes()` configuration. - -```ts -app.get("/user", async (context) => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (session) { - const user = session.user; - const username = user.username; - // ... - } - // ... -}); -``` - -## Sign out users - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -import { auth } from "./lucia.js"; - -app.post("/logout", async (context) => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (!session) { - return context.text("Unauthorized", 401); - } - await auth.invalidateSession(session.sessionId); - authRequest.setSession(null); - // redirect back to login page - return context.redirect("/login"); -}); -``` diff --git a/documentation/content/guidebook/github-oauth/$nextjs-app.md b/documentation/content/guidebook/github-oauth/$nextjs-app.md deleted file mode 100644 index 14d4e13c9..000000000 --- a/documentation/content/guidebook/github-oauth/$nextjs-app.md +++ /dev/null @@ -1,443 +0,0 @@ ---- -title: "GitHub OAuth in Next.js App Router" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/nextjs-app)._ - -This guide will cover how to implement GitHub OAuth using Lucia in Next.js App router. It will have 3 parts: - -- A sign up page -- An endpoint to authenticate users with GitHub -- A profile page with a logout button - -As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -### Clone project - -You can get started immediately by cloning the [Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-app/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/nextjs-app/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-app/github-oauth). - -## Create an OAuth app - -[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 client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -Set [`sessionCookie.expires`](/basics/configuration#sessioncookie) to false since we can't update the session cookie when validating them. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - - sessionCookie: { - expires: false - } -}); - -export type Auth = typeof auth; -``` - -We'll also expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: process.env.GITHUB_CLIENT_ID ?? "", - clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "" -}); - -export type Auth = typeof auth; -``` - -## Sign in page - -Create `app/login/page.tsx`. It will have a "Sign in with GitHub" button (actually a link). - -```tsx -// app/login/page.tsx - -const Page = async () => { - return ( - <> -

Sign in

- Sign in with GitHub - - ); -}; - -export default Page; -``` - -When a user clicks the link, the destination (`/login/github`) will redirect the user to GitHub to be authenticated. - -## Generate authorization url - -Create `app/login/github/route.ts` and handle GET requests. [`GithubProvider.getAuthorizationUrl()`](/oauth/providers/github#getauthorizationurl) will create a new GitHub authorization url, where the user will be authenticated in github.com. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -// app/login/github/route.ts -import { githubAuth } from "@/auth/lucia"; -import * as context from "next/headers"; - -import type { NextRequest } from "next/server"; - -export const GET = async (request: NextRequest) => { - const [url, state] = await githubAuth.getAuthorizationUrl(); - // store state - context.cookies().set("github_oauth_state", state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: 60 * 60 - }); - return new Response(null, { - status: 302, - headers: { - Location: url.toString() - } - }); -}; -``` - -## Validate callback - -Create `app/login/github/callback/route.ts` and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with the request method, `cookies()`, and `headers(). - -```ts -// app/login/github/callback/route.ts -import { auth, githubAuth } from "@/auth/lucia"; -import { OAuthRequestError } from "@lucia-auth/oauth"; -import { cookies, headers } from "next/headers"; - -import type { NextRequest } from "next/server"; - -export const GET = async (request: NextRequest) => { - const storedState = cookies().get("github_oauth_state")?.value; - const url = new URL(request.url); - const state = url.searchParams.get("state"); - const code = url.searchParams.get("code"); - // validate state - if (!storedState || !state || storedState !== state || !code) { - return new Response(null, { - status: 400 - }); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(request.method, { - cookies, - headers - }); - authRequest.setSession(session); - return new Response(null, { - status: 302, - headers: { - Location: "/" // redirect to profile page - } - }); - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return new Response(null, { - status: 400 - }); - } - return new Response(null, { - status: 500 - }); - } -}; -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign in page. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -For `Auth.handleRequest()`, pass `"GET"` as the request method. - -```tsx -// app/login/page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (session) redirect("/"); - return ( - <> -

Sign in

- Sign in with GitHub - - ); -}; - -export default Page; -``` - -## Profile page - -Create `app/page.tsx`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you'll see that `User.username` exists because we defined it in first step with `getUserAttributes()` configuration. - -```tsx -// app/page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -import Form from "@/components/form"; // expect error - see next section - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (!session) redirect("/login"); - return ( - <> -

Profile

-

User id: {session.user.userId}

-

Username: {session.user.username}

-
- -
- - ); -}; - -export default Page; -``` - -### Form component - -Since the form will require client side JS, we will extract it into its own client component. We need to manually handle redirect responses as the default behavior is to make another request to the redirect location. We're going to use `refresh()` to reload the page (and redirect the user in the server) since we want to re-render the entire page, including `layout.tsx`. - -```tsx -// components/form.tsx -"use client"; - -import { useRouter } from "next/navigation"; - -const Form = ({ - children, - action -}: { - children: React.ReactNode; - action: string; -}) => { - const router = useRouter(); - return ( -
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch(action, { - method: "POST", - body: formData, - redirect: "manual" - }); - - if (response.status === 0) { - // redirected - // when using `redirect: "manual"`, response status 0 is returned - return router.refresh(); - } - }} - > - {children} -
- ); -}; - -export default Form; -``` - -### Sign out users - -Create `app/api/logout/route.ts` and handle POST requests. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `AuthRequest.setSession()`. - -```ts -// app/api/logout/route.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const authRequest = auth.handleRequest(request.method, context); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - return new Response(null, { - status: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - authRequest.setSession(null); - return new Response(null, { - status: 302, - headers: { - Location: "/login" // redirect to login page - } - }); -}; -``` - -## Additional notes - -For getting the current user in `page.tsx` and `layout.tsx`, we recommend wrapping `AuthRequest.validate()` in `cache()`, which is provided by React. This should not be used inside `route.tsx` as Lucia will assume the request is a GET request. - -```ts -export const getPageSession = cache(() => { - const authRequest = auth.handleRequest("GET", context); - return authRequest.validate(); -}); -``` - -This allows you share the session across pages and layouts, making it possible to validate the request in multiple layouts and page files without making unnecessary database calls. - -```ts -const Page = async () => { - const session = await getPageSession(); -}; -``` diff --git a/documentation/content/guidebook/github-oauth/$nextjs-pages.md b/documentation/content/guidebook/github-oauth/$nextjs-pages.md deleted file mode 100644 index d912bd9d8..000000000 --- a/documentation/content/guidebook/github-oauth/$nextjs-pages.md +++ /dev/null @@ -1,389 +0,0 @@ ---- -title: "GitHub OAuth in Next.js Pages Router" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/nextjs-pages)._ - -This guide will cover how to implement GitHub OAuth using Lucia in Next.js Pages Router. It will have 3 parts: - -- A sign up page -- An endpoint to authenticate users with GitHub -- A profile page with a logout button - -As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -### Clone project - -You can get started immediately by cloning the [Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-pages/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/nextjs-pages/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-pages/github-oauth). - -## Create an OAuth app - -[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/api/login/github/callback -``` - -Copy and paste the client id and client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: process.env.GITHUB_CLIENT_ID ?? "", - clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "" -}); - -export type Auth = typeof auth; -``` - -## Sign in page - -Create `pages/login.tsx`. It will have a "Sign in with GitHub" button (actually a link). - -```tsx -// pages/login.tsx - -const Page = () => { - return ( - <> -

Sign in

- Sign in with GitHub - - ); -}; - -export default Page; -``` - -When a user clicks the link, the destination (`/api/login/github`) will redirect the user to GitHub to be authenticated. - -## Generate authorization url - -Create ` pages/api/login/github.ts` and handle GET requests. [`GithubProvider.getAuthorizationUrl()`](/oauth/providers/github#getauthorizationurl) will create a new GitHub authorization url, where the user will be authenticated in github.com. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -// pages/api/login/github.ts -import { auth, githubAuth } from "@/auth/lucia"; -import { serializeCookie } from "lucia/utils"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "GET") return res.status(405); - const [url, state] = await githubAuth.getAuthorizationUrl(); - const stateCookie = serializeCookie("github_oauth_state", state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: 60 * 60 - }); - return res - .status(302) - .setHeader("Set-Cookie", stateCookie) - .setHeader("Location", url.toString()) - .end(); -}; - -export default handler; -``` - -## Validate callback - -Create `pages/api/login/github/callback.ts` and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with `IncomingMessage` and `OutgoingMessage`. - -```ts -// pages/api/login/github/callback.ts -import { auth, githubAuth } from "@/auth/lucia"; -import { OAuthRequestError } from "@lucia-auth/oauth"; -import { parseCookie } from "lucia/utils"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "GET") return res.status(405); - const cookies = parseCookie(req.headers.cookie ?? ""); - const storedState = cookies.github_oauth_state; - const state = req.query.state; - const code = req.query.code; - // validate state - if ( - !storedState || - !state || - storedState !== state || - typeof code !== "string" - ) { - return res.status(400).end(); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest({ req, res }); - authRequest.setSession(session); - return res.status(302).setHeader("Location", "/").end(); // redirect to profile page - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return res.status(400).end(); - } - return res.status(500).end(); - } -}; - -export default handler; -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign in page. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -Since `Request` is not available in pages, set it to `null`. This should only be done for GET requests. - -```tsx -// app/login/page.tsx -import { auth } from "@/auth/lucia"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (session) redirect("/"); - return ( - <> -

Sign in

- Sign in with GitHub - - ); -}; - -export default Page; -``` - -## Profile page - -Create `pages/index.tsx`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you’ll see that `User.githubUsername` exists because we defined it in first step with `getUserAttributes()` configuration. - -```tsx -// pages/index.tsx -import { auth } from "@/auth/lucia"; -import { useRouter } from "next/router"; - -import type { - GetServerSidePropsContext, - GetServerSidePropsResult, - InferGetServerSidePropsType -} from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise< - GetServerSidePropsResult<{ - userId: string; - githubUsername: string; - }> -> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (!session) { - return { - redirect: { - destination: "/login", - permanent: false - } - }; - } - return { - props: { - userId: session.user.userId, - githubUsername: session.user.githubUsername - } - }; -}; - -const Page = ( - props: InferGetServerSidePropsType -) => { - const router = useRouter(); - return ( - <> -

Profile

-

User id: {props.userId}

-

GitHub username: {props.githubUsername}

-
{ - e.preventDefault(); - const response = await fetch("/api/logout", { - method: "POST", - redirect: "manual" - }); - if (response.status === 0 || response.ok) { - router.push("/login"); // redirect to login page on success - } - }} - > - -
- - ); -}; - -export default Page; -``` - -### Sign out users - -Create `pages/api/logout.ts` and handle POST requests. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -// pages/api/logout.ts -import { auth } from "@/auth/lucia"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") return res.status(405); - const authRequest = auth.handleRequest({ req, res }); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - return res.status(401).json({ - error: "Unauthorized" - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - authRequest.setSession(null); - return res.redirect(302, "/login"); -}; - -export default handler; -``` diff --git a/documentation/content/guidebook/github-oauth/$nuxt.md b/documentation/content/guidebook/github-oauth/$nuxt.md deleted file mode 100644 index bd49cbafc..000000000 --- a/documentation/content/guidebook/github-oauth/$nuxt.md +++ /dev/null @@ -1,409 +0,0 @@ ---- -title: "GitHub OAuth in Nuxt" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/nuxt)._ - -This guide will cover how to implement GitHub OAuth using Lucia in Nuxt. It will have 3 parts: - -- A sign up page -- An endpoint to authenticate users with GitHub -- A profile page with a logout button - -As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -### Clone project - -You can get started immediately by cloning the [Nuxt example](https://github.com/lucia-auth/examples/tree/main/nuxt/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/nuxt/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nuxt/github-oauth). - -## Create an OAuth app - -[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/api/login/github/callback -``` - -Copy and paste the client id and client secret into your `.env` file: - -```bash -# .env -NUXT_GITHUB_CLIENT_ID="..." -NUXT_GITHUB_CLIENT_SECRET="..." -``` - -Expose the environment variables by updating your Nuxt config. - -```ts -// nuxt.config.ts -export default defineNuxtConfig({ - // ... - runtimeConfig: { - // keep these empty! - githubClientId: "", - githubClientSecret: "" - } - // When using node < 20 we need to uncomment the following section in order to polyfill the Web Crypto API. - // nitro: { - // moduleSideEffects: ["lucia/polyfill/node"] - // }, -}); -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// server/app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./utils/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// server/utils/lucia.ts -import { lucia } from "lucia"; -import { h3 } from "lucia/middleware"; -// When using node < 20 uncomment the following line. -// import 'lucia/polyfill/node' - -export const auth = lucia({ - adapter: ADAPTER, - env: process.dev ? "DEV" : "PROD", - middleware: h3(), - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// server/utils/lucia.ts -import { lucia } from "lucia"; -import { h3 } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -const runtimeConfig = useRuntimeConfig(); - -export const githubAuth = github(auth, { - clientId: runtimeConfig.githubClientId, - clientSecret: runtimeConfig.githubClientSecret -}); - -export type Auth = typeof auth; -``` - -## Sign in page - -Create `pages/login.vue`. It will have a "Sign in with GitHub" button (actually a link). - -```vue - - -``` - -When a user clicks the link, the destination (`/api/login/github`) will redirect the user to GitHub to be authenticated. - -## Generate authorization url - -Create `server/api/login/github/index.get.ts`. [`GithubProvider.getAuthorizationUrl()`](/oauth/providers/github#getauthorizationurl) will create a new GitHub authorization url, where the user will be authenticated in github.com. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -// server/api/login/github/index.get.ts -export default defineEventHandler(async (event) => { - const [url, state] = await githubAuth.getAuthorizationUrl(); - setCookie(event, "github_oauth_state", state, { - httpOnly: true, - secure: !process.dev, - path: "/", - maxAge: 60 * 60 - }); - return sendRedirect(event, url.toString()); -}); -``` - -## Validate callback - -Create `server/api/login/github/callback.get.ts` - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with `H3Event`. - -```ts -// server/api/login/github/callback.get.ts -import { OAuthRequestError } from "@lucia-auth/oauth"; - -export default defineEventHandler(async (event) => { - const storedState = getCookie(event, "github_oauth_state"); - const query = getQuery(event); - const state = query.state?.toString(); - const code = query.code?.toString(); - // validate state - if (!storedState || !state || storedState !== state || !code) { - return sendError( - event, - createError({ - statusCode: 400 - }) - ); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(event); - authRequest.setSession(session); - return sendRedirect(event, "/"); // redirect to profile page - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return sendError( - event, - createError({ - statusCode: 400 - }) - ); - } - return sendError( - event, - createError({ - statusCode: 500 - }) - ); - } -}); -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Managing auth state - -### Get authenticated user - -Create `server/api/user.get.ts`. This endpoint will return the current user. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```ts -// server/api/user.get.ts -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - const session = await authRequest.validate(); - return { - user: session?.user ?? null; - } -}); -``` - -### Composables - -Create `useUser()` and `useAuthenticatedUser()` composables. `useUser()` will return the user state. `useAuthenticatedUser()` can only be used inside protected routes, which allows the ref value type to be always defined (never `null`). - -```ts -// composables/auth.ts -import type { User } from "lucia"; - -export const useUser = () => { - const user = useState("user", () => null); - return user; -}; - -export const useAuthenticatedUser = () => { - const user = useUser(); - return computed(() => { - const userValue = unref(user); - if (!userValue) { - throw createError( - "useAuthenticatedUser() can only be used in protected pages" - ); - } - return userValue; - }); -}; -``` - -### Define middleware - -Define a global `auth` middleware that gets the current user and populates the user state. This will run on every navigation. - -```ts -// middleware/auth.global.ts -export default defineNuxtRouteMiddleware(async () => { - const user = useUser(); - const { data, error } = await useFetch("/api/user"); - if (error.value) throw createError("Failed to fetch data"); - user.value = data.value?.user ?? null; -}); -``` - -Next, define a regular `protected` middleware that redirects unauthenticated users to the login page. - -```ts -// middleware/protected.ts -export default defineNuxtRouteMiddleware(async () => { - const user = useUser(); - if (!user.value) return navigateTo("/login"); -}); -``` - -## Redirect authenticated user - -Redirect authenticated users to the profile page in `pages/login.vue`. - -```vue - - -``` - -## Profile page - -Create `pages/index.vue`. This will show some basic user info and include a logout button. - -Use the `protected` middleware to redirect unauthenticated users, and call `useAuthenticatedUser()` to get the authenticated user. - -```vue - - - - -``` - -### Sign out users - -Create `server/api/logout.post.ts`. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -// server/api/logout.post.ts -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - throw createError({ - message: "Unauthorized", - statusCode: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - authRequest.setSession(null); - return sendRedirect(event, "/login"); -}); -``` diff --git a/documentation/content/guidebook/github-oauth/$solidstart.md b/documentation/content/guidebook/github-oauth/$solidstart.md deleted file mode 100644 index d4a939f4c..000000000 --- a/documentation/content/guidebook/github-oauth/$solidstart.md +++ /dev/null @@ -1,390 +0,0 @@ ---- -title: "GitHub OAuth in SolidStart" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/solidstart) and that you've implement the recommended middleware._ - -This guide will cover how to implement GitHub OAuth using Lucia in SolidStart. It will have 3 parts: - -- A sign up page -- An endpoint to authenticate users with GitHub -- A profile page with a logout button - -As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -### Clone project - -You can get started immediately by cloning the [SolidStart example](https://github.com/lucia-auth/examples/tree/main/solidstart/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/solidstart/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/solidstart/github-oauth). - -## Create an OAuth app - -[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 client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` in `app.d.ts` whenever you add any new columns to the user table. - -```ts -// src/app.d.ts -/// -declare namespace Lucia { - type Auth = import("./lib/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// src/auth/lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: web(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// src/auth/lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET -}); - -export type Auth = typeof auth; -``` - -## Sign in page - -Create `src/routes/login/index.tsx`. It will have a "Sign in with GitHub" button (actually a link). Make sure you use the regular HTML anchor tags. - -```tsx -// src/routes/login/index.tsx -const Page = () => { - return ( - <> -

Sign in

- Sign in with GitHub - - ); -}; - -export default Page; -``` - -When a user clicks the link, the destination (`/login/github`) will redirect the user to GitHub to be authenticated. - -## Generate authorization url - -Create `src/routes/login/github/index.ts` and handle GET requests. [`GithubProvider.getAuthorizationUrl()`](/oauth/providers/github#getauthorizationurl) will create a new GitHub authorization url, where the user will be authenticated in github.com. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -// src/routes/login/github/index.ts -import { auth, githubAuth } from "~/auth/lucia"; -import { redirect } from "solid-start"; -import { serializeCookie } from "solid-start"; - -import type { APIEvent } from "solid-start"; - -export const GET = async (event: APIEvent) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (session) { - return redirect("/", 302); // redirect to profile page - } - const [url, state] = await githubAuth.getAuthorizationUrl(); - return new Response(null, { - status: 302, - headers: { - Location: url.toString(), - "Set-Cookie": serializeCookie("github_oauth_state", state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: 60 * 60 - }) - } - }); -}; -``` - -## Validate callback - -Create `src/routes/login/github/callback.ts` and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). Since we've setup middleware, `AuthRequest` is accessible as `context.locals.auth`. - -```ts -// src/routes/login/github/callback.ts -import { auth, githubAuth } from "~/auth/lucia"; -import { OAuthRequestError } from "@lucia-auth/oauth"; -import { parseCookie, redirect } from "solid-start"; - -import type { APIEvent } from "solid-start"; - -export const GET = async (event: APIEvent) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (session) { - return redirect("/", 302); // redirect to profile page - } - const cookies = parseCookie(event.request.headers.get("Cookie") ?? ""); - const storedState = cookies.github_oauth_state; - const url = new URL(event.request.url); - const state = url.searchParams.get("state"); - const code = url.searchParams.get("code"); - // validate state - if (!storedState || !state || storedState !== state || !code) { - return new Response(null, { - status: 400 - }); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() - } - }); - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return new Response(null, { - status: 400 - }); - } - return new Response(null, { - status: 500 - }); - } -}; -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign in page. You can validate requests by creating a new [`AuthRequest` instance](/reference/lucia/interfaces/authrequest) with [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) and calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -Since we're using the `web()` middleware, `Auth.handleRequest()` expects the standard `Request`. - -```tsx -// src/routes/login/index.tsx -import { auth } from "~/auth/lucia"; -import { createServerData$, redirect } from "solid-start/server"; - -export const routeData = () => { - return createServerData$(async (_, event) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (session) { - return redirect("/"); - } - }); -}; - -const Page = () => { - // ... -}; - -export default Page; -``` - -## Profile page - -Create `src/routes/index.tsx`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you’ll see that `User.githubUsername` exists because we defined it in first step with `getUserAttributes()` configuration. - -```tsx -// src/routes/index.tsx -import { useRouteData } from "solid-start"; -import { createServerData$, redirect } from "solid-start/server"; -import { auth } from "~/auth/lucia"; - -export const routeData = () => { - return createServerData$(async (_, event) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (!session) { - return redirect("/login") as never; - } - return session.user; - }); -}; - -const Page = () => { - const user = useRouteData(); - return ( - <> -

Profile

-

User id: {user()?.userId}

-

GitHub username: {user()?.githubUsername}

- - ); -}; - -export default Page; -``` - -### Sign out users - -The form submission will be handled within a server action. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```tsx -// src/routes/index.tsx -import { useRouteData } from "solid-start"; -import { - ServerError, - createServerAction$, - createServerData$, - redirect -} from "solid-start/server"; -import { auth } from "~/auth/lucia"; - -export const routeData = () => { - // ... -}; - -const Page = () => { - const user = useRouteData(); - const [_, { Form }] = createServerAction$(async (_, event) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (!session) { - throw new ServerError("Unauthorized", { - status: 401 - }); - } - await auth.invalidateSession(session.sessionId); // invalidate session - const sessionCookie = auth.createSessionCookie(null); - return new Response(null, { - status: 302, - headers: { - Location: "/login", - "Set-Cookie": sessionCookie.serialize() - } - }); - }); - return ( - <> -

Profile

-

User id: {user()?.userId}

-

Username: {user()?.username}

-
- -
- - ); -}; - -export default Page; -``` diff --git a/documentation/content/guidebook/github-oauth/$sveltekit.md b/documentation/content/guidebook/github-oauth/$sveltekit.md deleted file mode 100644 index 9dfb11dd0..000000000 --- a/documentation/content/guidebook/github-oauth/$sveltekit.md +++ /dev/null @@ -1,346 +0,0 @@ ---- -title: "GitHub OAuth in SvelteKit" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/sveltekit) and that you've implement the recommended `handle()` hook._ - -This guide will cover how to implement GitHub OAuth using Lucia in SvelteKit. It will have 3 parts: - -- A sign up page -- An endpoint to authenticate users with GitHub -- A profile page with a logout button - -As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -### Clone project - -You can get started immediately by cloning the [SvelteKit example](https://github.com/lucia-auth/examples/tree/main/sveltekit/github-oauth) from the repository. - -``` -npx degit lucia-auth/examples/sveltekit/github-oauth -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/sveltekit/github-oauth). - -## Create an OAuth app - -[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 client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` in `app.d.ts` whenever you add any new columns to the user table. - -```ts -// src/app.d.ts -/// -declare global { - namespace Lucia { - type Auth = import("$lib/server/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; - } -} - -// THIS IS IMPORTANT!!! -export {}; -``` - -## Configure Lucia - -We'll expose the user's GitHub username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// src/lib/server/lucia.ts -import { lucia } from "lucia"; -import { sveltekit } from "lucia/middleware"; -import { dev } from "$app/environment"; - -export const auth = lucia({ - adapter: ADAPTER, - env: dev ? "DEV" : "PROD", - middleware: sveltekit(), - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// src/lib/server/lucia.ts -import { lucia } from "lucia"; -import { sveltekit } from "lucia/middleware"; -import { dev } from "$app/environment"; - -import { github } from "@lucia-auth/oauth/providers"; -import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from "$env/static/private"; - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET -}); - -export type Auth = typeof auth; -``` - -If you're getting TypeScript errors, try generating your project types (and restart your IDE if needed). - -``` -npx svelte-kit sync -pnpm svelte-kit sync -``` - -## Sign in page - -Create `routes/login/+page.svelte`. It will have a "Sign in with GitHub" button (actually a link). - -```svelte - -

Sign in

-Sign in with GitHub -``` - -When a user clicks the link, the destination (`/login/github`) will redirect the user to GitHub to be authenticated. - -## Generate authorization url - -Create `routes/login/github/+server.ts` and handle GET requests. [`GithubProvider.getAuthorizationUrl()`](/oauth/providers/github#getauthorizationurl) will create a new GitHub authorization url, where the user will be authenticated in github.com. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -```ts -// routes/login/github/+server.ts -import { dev } from "$app/environment"; -import { githubAuth } from "$lib/server/lucia.js"; - -export const GET = async ({ cookies }) => { - const [url, state] = await githubAuth.getAuthorizationUrl(); - // store state - cookies.set("github_oauth_state", state, { - httpOnly: true, - secure: !dev, - path: "/", - maxAge: 60 * 60 - }); - return new Response(null, { - status: 302, - headers: { - Location: url.toString() - } - }); -}; -``` - -## Validate callback - -Create `routes/login/github/callback/+server.ts` and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). Since we've setup a handle hook, `AuthRequest` is accessible as `locals.auth`. - -```ts -// routes/login/github/callback/+server.ts -import { auth, githubAuth } from "$lib/server/lucia.js"; -import { OAuthRequestError } from "@lucia-auth/oauth"; - -export const GET = async ({ url, cookies, locals }) => { - const storedState = cookies.get("github_oauth_state"); - const state = url.searchParams.get("state"); - const code = url.searchParams.get("code"); - // validate state - if (!storedState || !state || storedState !== state || !code) { - return new Response(null, { - status: 400 - }); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - locals.auth.setSession(session); - return new Response(null, { - status: 302, - headers: { - Location: "/" - } - }); - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return new Response(null, { - status: 400 - }); - } - return new Response(null, { - status: 500 - }); - } -}; -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Redirect authenticated users - -Define a server load function in `routes/login/+page.server.ts`. - -Authenticated users should be redirected to the profile page whenever they try to access the sign in page. You can validate requests by creating a new [`AuthRequest` instance](/reference/lucia/interfaces/authrequest) with [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest), which is stored in `locals.auth`, and calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```ts -// routes/login/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { redirect } from "@sveltejs/kit"; - -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (session) throw redirect(302, "/"); - return {}; -}; -``` - -## Profile page - -Create `routes/+page.svelte`. This will show some basic user info and include a logout button. Expect TS error for now since we have populated `PageData` yet. - -```svelte - - -

Profile

-

User id: {data.userId}

-

GitHub username: {data.githubUsername}

-
- -
-``` - -### Get authenticated user - -Create `routes/+page.server.ts` and define a load function. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you'll see that `User.githubUsername` exists because we defined it in first step with `getUserAttributes()` configuration. - -```ts -// routes/+page.server.ts -import { redirect } from "@sveltejs/kit"; - -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) throw redirect(302, "/login"); - return { - userId: session.user.userId, - githubUsername: session.user.githubUsername - }; -}; -``` - -### Sign out users - -Define a new server action in `routes/+page.server.ts`. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be done by passing `null` to `AuthRequest.setSession()`. - -```ts -// routes/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { Actions, PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - // ... -}; - -export const actions: Actions = { - logout: async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) return fail(401); - await auth.invalidateSession(session.sessionId); // invalidate session - locals.auth.setSession(null); // remove cookie - throw redirect(302, "/login"); // redirect to login page - } -}; -``` diff --git a/documentation/content/guidebook/github-oauth/index.md b/documentation/content/guidebook/github-oauth/index.md deleted file mode 100644 index bf28c580b..000000000 --- a/documentation/content/guidebook/github-oauth/index.md +++ /dev/null @@ -1,321 +0,0 @@ ---- -title: "GitHub OAuth" -description: "Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started)._ - -This guide will cover how to implement GitHub OAuth using Lucia with session cookies. As a general overview of OAuth, the user is redirected to github.com to be authenticated, and GitHub redirects the user back to your application with a code that can be validated and used to get the user's identity. - -## Create an OAuth app - -[Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). Set the redirect uri, for example `http://localhost:3000/login/github/callback`. - -Copy and paste the client id and client secret into your `.env` file: - -```bash -# .env -GITHUB_CLIENT_ID="..." -GITHUB_CLIENT_SECRET="..." -``` - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type (optionally unique). - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -Since we're dealing with the standard `Request` and `Response`, we'll use the [`web()`](/reference/lucia/modules/middleware#web) middleware. We're also setting [`sessionCookie.expires`](/basics/configuration#sessioncookie) to false since we can't update the session cookie when validating them. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: "DEV", // "PROD" for production - - middleware: web(), - sessionCookie: { - expires: false - } -}); - -export type Auth = typeof auth; -``` - -We also want to expose the user's username to the `User` object returned by Lucia's APIs. We'll define [`getUserAttributes`](/basics/configuration#getuserattributes) and return the username. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: "DEV", // "PROD" for production - middleware: web(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - githubUsername: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Initialize the OAuth integration - -Install the OAuth integration and `dotenv`. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Import the GitHub OAuth integration, and initialize it using your credentials. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia({ - // ... -}); - -export const githubAuth = github(auth, { - clientId: GITHUB_CLIENT_ID, // env var - clientSecret: GITHUB_CLIENT_SECRET // env var -}); - -export type Auth = typeof auth; -``` - -## Generate authorization url - -Create a new GitHub authorization url, where the user should be redirected to. When generating an authorization url, Lucia will also create a new state. This should be stored as a http-only cookie to be used later. - -You can use [`serializeCookie()`](/reference/lucia/modules/utils#serializecookie) provided by Lucia to get the `Set-Cookie` header. - -```ts -import { serializeCookie } from "lucia/utils"; -import { auth, githubAuth } from "./lucia.js"; - -get("/login/github", async () => { - const [url, state] = await githubAuth.getAuthorizationUrl(); - const stateCookie = serializeCookie("github_oauth_state", state, { - httpOnly: true, - secure: false, // `true` for production - path: "/", - maxAge: 60 * 60 - }); - return new Response(null, { - status: 302, - headers: { - Location: url.toString(), - "Set-Cookie": stateCookie - } - }); -}); -``` - -For example, the user should be redirected to `/login/github` when they click "Sign in with GitHub." - -```html -Sign in with GitHub -``` - -## Validate callback - -Create your OAuth callback route that you defined when registering an OAuth app with GitHub, and handle GET requests. - -When the user authenticates with GitHub, GitHub will redirect back the user to your site with a code and a state. This state should be checked with the one stored as a cookie, and if valid, validate the code with [`GithubProvider.validateCallback()`](/oauth/providers/github#validatecallback). This will return [`GithubUserAuth`](/oauth/providers/github#githubuserauth) if the code is valid, or throw an error if not. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession). This session should be stored as a cookie, which can be created with [`Auth.createSessionCookie()`](/reference/lucia/interfaces/auth#createsessioncookie). - -You can use [`parseCookie()`](/reference/lucia/modules/utils#parsecookie) provided by Lucia to read the state cookie. - -```ts -import { auth, githubAuth } from "./lucia.js"; -import { parseCookie } from "lucia/utils"; -import { OAuthRequestError } from "@lucia-auth/oauth"; - -get("/login/github/callback", async (request: Request) => { - const cookies = parseCookie(request.headers.get("Cookie") ?? ""); - const storedState = cookies.github_oauth_state; - const url = new URL(request.url); - const state = url.searchParams.get("state"); - const code = url.searchParams.get("code"); - // validate state - if (!storedState || !state || storedState !== state || !code) { - return new Response(null, { - status: 400 - }); - } - try { - const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - - const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; - }; - - const user = await getUser(); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - // redirect to profile page - return new Response(null, { - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() // store session cookie - }, - status: 302 - }); - } catch (e) { - if (e instanceof OAuthRequestError) { - // invalid code - return new Response(null, { - status: 400 - }); - } - return new Response(null, { - status: 500 - }); - } -}); -``` - -### Authenticate user with Lucia - -You can check if the user has already registered with your app by checking `GithubUserAuth.getExistingUser`. Internally, this is done by checking if a [key](/basics/keys) with the GitHub user id already exists. - -If they're a new user, you can create a new Lucia user (and key) with [`GithubUserAuth.createUser()`](/reference/oauth/interfaces#createuser). The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. You can access the GitHub user data with `GithubUserAuth.githubUser`, as well as the access tokens with `GithubUserAuth.githubTokens`. - -```ts -const { getExistingUser, githubUser, createUser } = - await githubAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - const user = await createUser({ - attributes: { - username: githubUser.login - } - }); - return user; -}; - -const user = await getUser(); -``` - -## Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign in page. You can validate requests by creating a new [`AuthRequest` instance](/reference/lucia/interfaces/authrequest) with [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) and calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -Since we're using the `web()` middleware, `Auth.handleRequest()` expects the standard `Request`. - -```ts -import { auth } from "./lucia.js"; - -get("/signup", async (request: Request) => { - const authRequest = auth.handleRequest(request); - const session = await authRequest.validate(); - if (session) { - // redirect to profile page - return new Response(null, { - headers: { - Location: "/" - }, - status: 302 - }); - } - return renderPage(); -}); -``` - -## Get authenticated user - -You can validate requests and get the current session/user by using [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). It returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -You can see that `User.username` exists because we defined it with `getUserAttributes()` configuration. - -```ts -import { auth } from "./lucia.js"; - -get("/", async (request: Request) => { - const authRequest = auth.handleRequest(request); - const session = await authRequest.validate(); - if (session) { - const user = session.user; - const username = user.username; - // ... - } - // ... -}); -``` - -## Sign out users - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -import { auth } from "./lucia.js"; - -post("/logout", async (request: Request) => { - const authRequest = auth.handleRequest(request); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - return new Response("Unauthorized", { - status: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // create blank session cookie - const sessionCookie = auth.createSessionCookie(null); - return new Response(null, { - headers: { - Location: "/login", // redirect to login page - "Set-Cookie": sessionCookie.serialize() // delete session cookie - }, - status: 302 - }); -}); -``` diff --git a/documentation/content/guidebook/improve-session-security.md b/documentation/content/guidebook/improve-session-security.md deleted file mode 100644 index 9e57e3e45..000000000 --- a/documentation/content/guidebook/improve-session-security.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: "Improving session security" -description: "Learn how to configure and extend Lucia to improve security" ---- - -What can be considered "secure enough" really differs from application to application. Improving security often comes with the tradeoff of impacting usability, and it's up to you to decide whether that's worth it. As such, while Lucia provides a good baseline, you may need to configure and extend Lucia to meet your requirements. - -## Session cookies - -By default, session cookies are set to `SameSite: Lax`, not `Strict`. This provides a good balance between CSRF protection and usability since your users will be logged if they visit your site via link. However, if that doesn't apply to your site (e.g. banks), you can configure session cookies to use `SameSite: Strict` with the `sessionCookie` configuration. - -```ts -lucia({ - // ... - sessionCookie: { - attributes: { - sameSite: "strict" - } - } -}); -``` - -## Adjust expiration for inactive users - -Sessions will continue to be valid as long as they're used at least once every 2 weeks. This allows sessions to be persisted for active users, while invalidating them for inactive users. You can configure sessions to be invalidated as quick as possible without interrupting active users with the `sessionExpiresIn` configuration. See the [Session](/basics/sessions#session-states-and-session-reset) docs for more on session states. - -```ts -lucia({ - // ... - // sessions will expire within 30 minutes (max) since inactivity - sessionExpiresIn: { - activePeriod: 1000 * 60 * 15, // 15 minutes - idlePeriod: 1000 * 60 * 15 // 15 minutes - } -}); -``` - -## Absolute session expiration - -Sessions do not have a fixed expiration and will continue to be valid as long as its used. While absolute expiration is not provided by Lucia, this can be easily implemented using custom attributes. - -```ts -lucia({ - getSessionAttributes: (data) => { - return { - absoluteExpiration: data.absolute_expiration as Date - }; - } -}); -``` - -```ts -await auth.createSession({ - userId, - attributes: { - absolute_expiration: Date.now() + 1000 * 60 * 60 * 12 // valid for 12 hours - } -}); -``` - -```ts -import { isWithinExpiration } from "lucia/utils"; - -const authRequest = auth.handleRequest(); -const session = await authRequest.validate(); -if (!session || !isWithinExpiration(session.absoluteExpiration.getTime())) { - // invalid session -} -``` - -## Detect stolen sessions - -There are few ways to detect if a session cookie is being used by a different device/person. All these approaches are imperfect in some way but provide a good layer of security. See each hosting provider's documentation on custom headers: - -- [Cloudflare](https://developers.cloudflare.com/fundamentals/reference/http-request-headers) -- [Vercel](https://vercel.com/docs/edge-network/headers) - -### IP addresses - -You can just store the IP address, but since IP addresses is usually dynamic (especially for cellular), you may want to consider storing the general location determined from the IP address instead. - -```ts -lucia({ - // ... - getSessionAttributes: (data) => { - return { - countryCode: data.country_code as string - }; - } -}); -``` - -```ts -const countryCode = getCountryCodeFromRequest(request); -await auth.createSession({ - userId, - country_code: countryCode -}); -``` - -```ts -const countryCode = getCountryCodeFromRequest(request); - -const authRequest = auth.handleRequest(); -const session = await authRequest.validate(); -if (!session || session.countryCode !== countryCode) { - // invalid session -} -``` - -### User agent - -The `User-Agent` header includes information about the browser and the device of the client. You can optionally combine it with the IP address or the country code of the IP address to get a more unique id. Before relying on it too much, keep in mind that headers can be easily spoofed - -You can hash the value with a fixed length algorithm to save on storage. - -```ts -lucia({ - // ... - getSessionAttributes: (data) => { - return { - userAgentHash: data.user_agent_hash as string - }; - } -}); -``` - -```ts -const userAgent = request.headers.get("User-Agent") ?? ""; // optionally throw error if `null` -const userAgentHash = md5(userAgent); -await auth.createSession({ - userId, - attributes: { - user_agent_hash: userAgentHash - } -}); -``` - -```ts -const userAgent = request.headers.get("User-Agent") ?? ""; // optionally throw error if `null` -const userAgentHash = md5(userAgent); - -const authRequest = auth.handleRequest(); -const session = await authRequest.validate(); -if (!session || session.userAgentHash !== userAgentHash) { - // invalid session -} -``` diff --git a/documentation/content/guidebook/kysely.md b/documentation/content/guidebook/kysely.md deleted file mode 100644 index b9a2c7242..000000000 --- a/documentation/content/guidebook/kysely.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -title: "Using Kysely" -description: "Learn how to use Kysely with Lucia" ---- - -[Kysely](https://github.com/kysely-org/kysely) is a type-safe and autocompletion-friendly TypeScript SQL query builder. While Lucia doesn't provide an adapter for Kysely itself, it does provide adapters for all database drivers supported by Kysely out of the box. - -See the next section for setting up the dialects, and make sure to change the table names in type `Database` to match your database. - -```ts -// db.ts -import { Kysely } from "kysely"; - -export const db = new Kysely({ - dialect -}); - -type Database = { - user: UserTable; - key: KeyTable; - session: SessionTable; -}; - -type UserTable = { - id: string; -}; - -type KeyTable = { - id: string; - user_id: string; - hashed_password: string | null; -}; - -type SessionTable = { - id: string; - user_id: string; - active_expires: bigint; - idle_expires: bigint; -}; -``` - -## Dialects - -## MySQL - -Install `mysql2` and follow the [adapter documentation](/database-adapters/mysql2) to setup your database. - -``` -npm install mysql2 -``` - -Create a new `Pool` from `mysql/promise` and use it to initialize both Kysely and Lucia. - -```ts -// db.ts -import { createPool } from "mysql2/promise"; -import { Kysely, MysqlDialect } from "kysely"; - -export const pool = createPool({ - // ... -}); - -const dialect = new MysqlDialect({ - pool: pool.pool // IMPORTANT NOT TO JUST PASS `pool` -}); - -export const db = new Kysely({ - dialect -}); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { mysql2 } from "@lucia-auth/adapter-mysql"; - -import { pool } from "./db.js"; - -export const auth = lucia({ - adapter: mysql2(pool, tableNames) -}); -``` - -## PostgreSQL - -Install `pg` and follow the [adapter documentation](/database-adapters/pg) to setup your database. - -``` -npm install pg -``` - -Create a new `Pool` and use it to initialize both Kysely and Lucia. - -```ts -// db.ts -import { Pool } from "pg"; -import { Kysely, PostgresDialect } from "kysely"; - -export const pool = new Pool({ - // ... -}); - -const dialect = new PostgresDialect({ - pool -}); - -export const db = new Kysely({ - dialect -}); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { pg } from "@lucia-auth/adapter-postgresql"; - -import { pool } from "./db.js"; - -export const auth = lucia({ - adapter: pg(pool, tableNames) -}); -``` - -## SQLite - -Install `better-sqlite3` and follow the [adapter documentation](/database-adapters/better-sqlite3) to setup your database. - -``` -npm install better-sqlite3 -``` - -Create a new `Database` and use it to initialize both Kysely and Lucia. - -```ts -// db.ts -import sqlite from "better-sqlite3"; -import { Kysely, SqliteDialect } from "kysely"; - -export const sqliteDatabase = sqlite(/* ... */); - -const dialect = new SqliteDialect({ - database: sqliteDatabase -}); - -export const db = new Kysely({ - dialect -}); -``` - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; - -import { sqliteDatabase } from "./db.js"; - -const auth = lucia({ - adapter: betterSqlite3(sqliteDatabase, tableNames) - // ... -}); -``` diff --git a/documentation/content/guidebook/login-throttling.md b/documentation/content/guidebook/login-throttling.md deleted file mode 100644 index d0580ea4e..000000000 --- a/documentation/content/guidebook/login-throttling.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: "Login throttling" -description: "Prevent password brute force attacks with login throttling" ---- - -When implementing password based authentication, a common attack is a brute force attack. While the complexity of the password is likely going to be the most important factor, you can implement login throttling to limit the number of login attempts an attacker can make. - -One simple approach is to use exponential backoff to increase the timeout on every unsuccessful login attempt. Since determining the exact origin of an attack is hard, throttling should be done on a per-username/account basis. However, an attacker may try to use a common password across multiple accounts. As such, throttling based on IP addresses should also be considered. - -## Basic example - -The following example stores the attempts in memory. You can of course use a regular database but running it in within a transaction is recommended. The timeout doubles on every failed login attempt until the user is successfully authenticated. A [demo](https://github.com/lucia-auth/examples/tree/main/other/login-throttling) is available in the repository. - -```ts -const loginTimeout = new Map< - string, - { - timeoutUntil: number; - timeoutSeconds: number; - } ->(); -``` - -```ts -// for traditional databases - START TRANSACTION -const storedTimeout = loginTimeout.get(username); -const timeoutUntil = storedTimeout?.timeoutUntil ?? 0; -if (Date.now() < timeoutUntil) { - // 429 too many requests - throw new Error(); -} -// increase timeout -const timeoutSeconds = storedTimeout ? storedTimeout.timeoutSeconds * 2 : 1; -loginTimeout.set(username, { - timeoutUntil: Date.now() + timeoutSeconds * 1000, - timeoutSeconds -}); -// for traditional databases - END TRANSACTION - -try { - await auth.validateKeyPassword("username", username, password); - loginTimeout.delete(username); - // success! -} catch { - // invalid username or password - throw new Error(); -} -``` - -## Prevent DOS with device cookies - -One issue with the basic example above is that a valid user may be locked out if an attacker attempts to sign in. This is of course much better than being susceptible to brute force attacks, but one way to avoid it is to remember users/devices that signed in once and skipping the timeout for the first few attempts. - -The following example stores the attempts and valid device cookies in memory. When a user is authenticated, a new device cookie is created. This cookie allows the user to bypass the throttling for the first 5 login attempts if they sign out. A [demo](https://github.com/lucia-auth/examples/tree/main/other/login-throtting-device-cookie) is available in the repository. - -```ts -const loginTimeout = new Map< - string, - { - timeoutUntil: number; - timeoutSeconds: number; - } ->(); - -const deviceCookie = new Map< - string, - { - username: string; - attempts: number; - } ->(); -``` - -```ts -const storedDeviceCookieId = getCookie("device_cookie") ?? null; -const validDeviceCookie = isValidateDeviceCookie( - storedDeviceCookieId, - username -); -if (!validDeviceCookie) { - setCookie("device_cookie", "", { - path: "/", - secure: false, // true for production - maxAge: 0, - httpOnly: true - }); - const storedTimeout = loginTimeout.get(username) ?? null; - const timeoutUntil = storedTimeout?.timeoutUntil ?? 0; - if (Date.now() < timeoutUntil) { - // 429 too many requests - throw new Error(); - } - const timeoutSeconds = storedTimeout ? storedTimeout.timeoutSeconds * 2 : 1; - loginTimeout.set(username, { - timeoutUntil: Date.now() + timeoutSeconds * 1000, - timeoutSeconds - }); - await auth.validateKeyPassword("username", username, password); - loginTimeout.delete(username); -} else { - await auth.validateKeyPassword("username", username, password); -} - -const newDeviceCookieId = generateRandomString(40); -deviceCookie.set(newDeviceCookieId, { - username, - attempts: 0 -}); -setCookie("device_cookie", newDeviceCookieId, { - path: "/", - secure: false, // true for production - maxAge: 60 * 60 * 24 * 365, // 1 year - httpOnly: true -}); -// success! -``` - -```ts -const isValidateDeviceCookie = ( - deviceCookieId: string | null, - username: string -) => { - if (!deviceCookieId) return false; - const deviceCookieAttributes = deviceCookie.get(deviceCookieId) ?? null; - if (!deviceCookieAttributes) return false; - const currentAttempts = deviceCookieAttributes.attempts + 1; - if (currentAttempts > 5 || deviceCookieAttributes.username !== username) { - deviceCookie.delete(deviceCookieId); - return false; - } - deviceCookie.set(deviceCookieId, { - username, - attempts: currentAttempts - }); - return true; -}; -``` diff --git a/documentation/content/guidebook/oauth-account-linking.md b/documentation/content/guidebook/oauth-account-linking.md deleted file mode 100644 index 334e22e48..000000000 --- a/documentation/content/guidebook/oauth-account-linking.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "OAuth account linking" -description: "Learn how to link multiple providers to a single user" ---- - -When providing more than one ways to sign in, you may want to link multiple providers to a single user. This allows the user to sign in with any of the provided options and be logged in as the same application user. One way of achieving this is to automatically link accounts with the same email. - -> (red) **Make sure your OAuth provider has verified the user's email.** - -Here's a basic OAuth implementation using the official integration. - -```ts -const { getExistingUser, createUser, providerUser } = - providerAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - if (!providerUser.email_verified) { - throw new Error("Email not verified"); - } - return await createUser({ - attributes: { - email: await getGithubUserEmail(githubUser) - } - }); -}; - -const user = await getUser(); - -// create session and sign in -``` - -Instead of creating a new user, we can check if a user with the email already exists, and if so, link the authentication method to that user by creating a new key. - -It's important to note `existingUser` is defined if a user linked to the provider's user id (e.g. GitHub user id) exists. It is _not_ based on the email. As such, you will have to query the user table to find if a user with the email already exists. If it does, use [`ProviderUserAuth.createKey()`](/reference/oauth/interfaces/provideruserauth#createkey) to link the method to the user. You can use [`transformDatabaseUser()`](/reference/lucia/interfaces/auth#transformdatabaseuser) to get Lucia's `User` object from the database result. - -**It's crucial to ensure that the email has been verified.** - -```ts -import { auth } from "./lucia.js"; - -const { getExistingUser, createUser, providerUser, createKey } = - providerAuth.validateCallback(code); - -const getUser = async () => { - const existingUser = await getExistingUser(); - if (existingUser) return existingUser; - if (!providerUser.email_verified) { - throw new Error("Email not verified"); - } - const existingDatabaseUserWithEmail = await db.getUserByEmail( - providerUser.email - ); - if (existingDatabaseUserWithEmail) { - // transform `UserSchema` to `User` - const user = auth.transformDatabaseUser(existingDatabaseUserWithEmail); - await createKey(user.userId); - return user; - } - return await createUser({ - attributes: { - email: providerUser.email - } - }); -}; - -const user = await getUser(); - -// create session and sign in -``` diff --git a/documentation/content/guidebook/password-reset-link/$express.md b/documentation/content/guidebook/password-reset-link/$express.md deleted file mode 100644 index 323ec21ee..000000000 --- a/documentation/content/guidebook/password-reset-link/$express.md +++ /dev/null @@ -1,206 +0,0 @@ ---- -title: "Password reset links in Express" -description: "Learn how to implement password reset using reset links" ---- - -This guide expects access to the user's verified email. See [Sign in with email and password with verification links](/guidebook/email-verification-links/express) guide to learn how to verify the user's email, and email and password authentication in general. - -```ts -// lucia.ts -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "production" ? "PROD" : "DEV", - middleware: express(), - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified - }; - } -}); - -export type Auth = typeof auth; -``` - -### Clone project - -The [email and password Express example](https://github.com/lucia-auth/examples/tree/main/express/email-and-password) includes password reset. - -``` -npx degit lucia-auth/examples/express/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/express/email-and-password). - -## Database - -### Password reset token - -Create a new `password_reset_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ----------------------------------- | -| `id` | `string` | ✓ | | Token to send inside the reset link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Password reset tokens - -The token will be sent as part of the reset link. - -``` -http://localhost:/password-reset/ -``` - -When a user clicks the link, we prompt the user to enter their new password. When a user submits that form, we'll validate the token stored in the url and update the password of the user's key. - -### Create new tokens - -`generatePasswordResetToken()` will first check if a reset token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - const storedUserTokens = await db - .table("password_reset_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db - .insertInto("password_reset_token") - .values({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }) - .executeTakeFirst(); - return token; -}; -``` - -### Validate tokens - -`validatePasswordResetToken()` will get the token and delete the token. We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - // ... -}; - -export const validatePasswordResetToken = async (token: string) => { - const storedToken = await db.transaction().execute(async (trx) => { - const storedToken = await trx - .table("password_reset_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("password_reset_token") - .where("id", "=", storedToken.id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Send password reset link - -Lucia allows us to use raw database queries when needed, for example checking the validity of an email. If the email is valid, create a new password reset link and send it to the user's inbox. - -```ts -import { generatePasswordResetToken } from "./token.js"; - -app.post("/password-reset", async (req, res) => { - const { email } = req.body as { - email: unknown; - }; - if (!isValidEmail(email)) { - return res.status(400).send("Invalid email"); - } - try { - const storedUser = await db - .table("user") - .where("email", "=", email.toLowerCase()) - .get(); - if (!storedUser) { - return res.status(400).send("User does not exist"); - } - const user = auth.transformDatabaseUser(storedUser); - const token = await generatePasswordResetToken(user.userId); - await sendPasswordResetLink(token); - return res.send(); - } catch (e) { - return res.status(500).send("Invalid or expired password reset link"); - } -}); -``` - -## Reset password - -Get the token from the url and validate the token with `validatePasswordResetToken()`. Update the key password with [`Auth.updateKeyPassword()`](/reference/lucia/interfaces/auth#updatekeypassword), and optionally verify the user's email. **Make sure you invalidate all user sessions with [`Auth.invalidateAllUserSessions()`](/reference/lucia/interfaces/auth#invalidateallusersessions) before updating the password.** - -```ts -import { auth } from "./lucia.js"; -import { validatePasswordResetToken } from "./token.js"; - -app.post("/password-reset/:token", async (req, res) => { - const { password } = req.body as { - password: unknown; - }; - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return res.status(400).send("Invalid password"); - } - try { - const { token } = req.params; - const userId = await validatePasswordResetToken(token); - let user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateKeyPassword("email", user.email, password); - if (!user.emailVerified) { - user = await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(req, res); - authRequest.setSession(session); - return res.status(302).setHeader("Location", "/").end(); - } catch (e) { - return res.status(500).send("Invalid or expired password reset link"); - } -}); -``` diff --git a/documentation/content/guidebook/password-reset-link/$nextjs-app.md b/documentation/content/guidebook/password-reset-link/$nextjs-app.md deleted file mode 100644 index 41c4f6942..000000000 --- a/documentation/content/guidebook/password-reset-link/$nextjs-app.md +++ /dev/null @@ -1,361 +0,0 @@ ---- -title: "Password reset links in Next.js App Router" -description: "Learn how to implement password reset using reset links" ---- - -This guide expects access to the user's verified email. See [Sign in with email and password with verification links](/guidebook/email-verification-links/nextjs-app) guide to learn how to verify the user's email, and email and password authentication in general. - -```ts -// auth/lucia.ts -export const auth = lucia({ - adapter: ADAPTER, - env: dev ? "DEV" : "PROD", - middleware: nextjs_future(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified - }; - } -}); - -export type Auth = typeof auth; -``` - -### Clone project - -The [email and password Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-app/email-and-password) includes password reset. - -``` -npx degit lucia-auth/examples/nextjs-app/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-app/email-and-password). - -## Database - -### Password reset token - -Create a new `password_reset_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ----------------------------------- | -| `id` | `string` | ✓ | | Token to send inside the reset link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Password reset tokens - -The token will be sent as part of the reset link. - -``` -http://localhost:3000/password-reset/ -``` - -When a user clicks the link, we prompt the user to enter their new password. When a user submits that form, we'll validate the token stored in the url and update the password of the user's key. - -### Create new tokens - -`generatePasswordResetToken()` will first check if a reset token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - const storedUserTokens = await db - .table("password_reset_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db - .insertInto("password_reset_token") - .values({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }) - .executeTakeFirst(); - return token; -}; -``` - -### Validate tokens - -`validatePasswordResetToken()` will get the token and delete the token. We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - // ... -}; - -export const validatePasswordResetToken = async (token: string) => { - const storedToken = await db.transaction().execute(async (trx) => { - const storedToken = await trx - .table("password_reset_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("password_reset_token") - .where("id", "=", storedToken.id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Form component - -Since the form will require client side JS, we will extract it into its own client component. We need to manually handle redirect responses as the default behavior is to make another request to the redirect location. We're going to use `refresh()` to reload the page (and redirect the user in the server) since we want to re-render the entire page, including `layout.tsx`. - -```tsx -// components/form.tsx -import { useRouter } from "next/navigation"; - -const Form = (props: { children: React.ReactNode; action: string }) => { - const router = useRouter(); - return ( - <> -
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch(props.action, { - method: "POST", - body: formData, - redirect: "manual" - }); - if (response.status === 0) { - // redirected - // when using `redirect: "manual"`, response status 0 is returned - return router.refresh(); - } - }} - > - {props.children} -
- - ); -}; - -export default Form; -``` - -## Send password reset link - -Create `app/password-reset/page.tsx` and add a form with an input for the email. - -```tsx -// app/password-reset/page.tsx -import Form from "@/components/form"; - -const Page = async () => { - return ( - <> -

Reset password

-
- - -
- -
- - ); -}; - -export default Page; -``` - -Create `app/api/password-reset/route.ts` and handle POST requests. - -Lucia allows us to use raw database queries when needed, for example checking the validity of an email. If the email is valid, create a new password reset link and send it to the user's inbox. - -```ts -// app/api/password-reset/route.ts -import { auth } from "@/auth/lucia"; -import { sendPasswordResetLink } from "@/auth/email"; -import { generatePasswordResetToken } from "@/auth/verification-token"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const formData = await request.formData(); - const email = formData.get("email"); - // basic check - if (!isValidEmail(email)) { - return new Response( - JSON.stringify({ - error: "Invalid email" - }), - { - status: 400 - } - ); - } - try { - const storedUser = await db - .table("user") - .where("email", "=", email.toLowerCase()) - .get(); - if (!storedUser) { - return new Response( - JSON.stringify({ - error: "User does not exist" - }), - { - status: 400 - } - ); - } - const user = auth.transformDatabaseUser(storedUser); - const token = await generatePasswordResetToken(user.userId); - await sendPasswordResetLink(token); - return new Response(); - } catch (e) { - return new Response( - JSON.stringify({ - error: "An unknown error occurred" - }), - { - status: 500 - } - ); - } -}; -``` - -## Reset password - -Create `app/password-reset/[token]/page.tsx` and add a form with an input for the new password. - -```tsx -// app/password-reset/[token]/page.tsx -import Form from "@/components/form"; - -const Page = async ({ - params -}: { - params: { - token: string; - }; -}) => { - return ( - <> -

Reset password

-
- - -
- -
- - ); -}; - -export default Page; -``` - -Create `app/api/password-reset/[token]/route.ts` and handle POST requests. - -Get the token from the url with `params.token` and validate it with `validatePasswordResetToken()`. Update the key password with [`Auth.updateKeyPassword()`](/reference/lucia/interfaces/auth#updatekeypassword), and optionally verify the user's email. **Make sure you invalidate all user sessions with [`Auth.invalidateAllUserSessions()`](/reference/lucia/interfaces/auth#invalidateallusersessions) before updating the password.** - -```ts -// app/api/password-reset/[token]/route.ts -import { auth } from "@/auth/lucia"; -import { validatePasswordResetToken } from "@/auth/verification-token"; - -import type { NextRequest } from "next/server"; - -export const POST = async ( - request: NextRequest, - { - params - }: { - params: { - token: string; - }; - } -) => { - const formData = await request.formData(); - const password = formData.get("password"); - // basic check - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return new Response( - JSON.stringify({ - error: "Invalid password" - }), - { - status: 400 - } - ); - } - try { - const { token } = params; - const userId = await validatePasswordResetToken(token); - let user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateKeyPassword("email", user.email, password); - if (!user.emailVerified) { - user = await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() - } - }); - } catch (e) { - return new Response( - JSON.stringify({ - error: "Invalid or expired password reset link" - }), - { - status: 400 - } - ); - } -}; -``` diff --git a/documentation/content/guidebook/password-reset-link/$nextjs-pages.md b/documentation/content/guidebook/password-reset-link/$nextjs-pages.md deleted file mode 100644 index c760629b4..000000000 --- a/documentation/content/guidebook/password-reset-link/$nextjs-pages.md +++ /dev/null @@ -1,338 +0,0 @@ ---- -title: "Password reset links in Next.js Pages Router" -description: "Learn how to implement password reset using reset links" ---- - -This guide expects access to the user's verified email. See [Sign in with email and password with verification links](/guidebook/email-verification-links/nextjs-pages) guide to learn how to verify the user's email, and email and password authentication in general. - -```ts -// auth/lucia.ts -export const auth = lucia({ - adapter: ADAPTER, - env: dev ? "DEV" : "PROD", - middleware: nextjs_future(), - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified - }; - } -}); - -export type Auth = typeof auth; -``` - -### Clone project - -The [email and password Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-pages/email-and-password) includes password reset. - -``` -npx degit lucia-auth/examples/nextjs-pages/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-pages/email-and-password). - -## Database - -### Password reset token - -Create a new `password_reset_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ----------------------------------- | -| `id` | `string` | ✓ | | Token to send inside the reset link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Password reset tokens - -The token will be sent as part of the reset link. - -``` -http://localhost:3000/password-reset/ -``` - -When a user clicks the link, we prompt the user to enter their new password. When a user submits that form, we'll validate the token stored in the url and update the password of the user's key. - -### Create new tokens - -`generatePasswordResetToken()` will first check if a reset token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - const storedUserTokens = await db - .table("password_reset_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db - .insertInto("password_reset_token") - .values({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }) - .executeTakeFirst(); - return token; -}; -``` - -### Validate tokens - -`validatePasswordResetToken()` will get the token and delete the token. We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// auth/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - // ... -}; - -export const validatePasswordResetToken = async (token: string) => { - const storedToken = await db.transaction().execute(async (trx) => { - const storedToken = await trx - .table("password_reset_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("password_reset_token") - .where("id", "=", storedToken.id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Send password reset link - -Create `pages/password-reset/index.tsx` and add a form with an input for the email. - -```tsx -// pages/password-reset/index.tsx -const Page = () => { - return ( - <> -

Reset password

-
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch("/api/password-reset", { - method: "POST", - body: JSON.stringify({ - email: formData.get("email") - }), - headers: { - "Content-Type": "application/json" - } - }); - }} - > - - -
- -
- - ); -}; - -export default Page; -``` - -Create `pages/api/password-reset/index.ts` and handle POST requests. - -Lucia allows us to use raw database queries when needed, for example checking the validity of an email. If the email is valid, create a new password reset link and send it to the user's inbox. - -```ts -// pages/api/password-reset/index.ts` -import { auth } from "@/auth/lucia"; -import { sendPasswordResetLink } from "@/auth/email"; -import { generatePasswordResetToken } from "@/auth/verification-token"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const formData = await request.formData(); - const email = formData.get("email"); - // basic check - if (!isValidEmail(email)) { - return new Response( - JSON.stringify({ - error: "Invalid email" - }), - { - status: 400 - } - ); - } - try { - const storedUser = await db - .table("user") - .where("email", "=", email.toLowerCase()) - .get(); - if (!storedUser) { - return new Response( - JSON.stringify({ - error: "User does not exist" - }), - { - status: 400 - } - ); - } - const user = auth.transformDatabaseUser(storedUser); - const token = await generatePasswordResetToken(user.userId); - await sendPasswordResetLink(token); - return new Response(); - } catch (e) { - return new Response( - JSON.stringify({ - error: "An unknown error occurred" - }), - { - status: 500 - } - ); - } -}; -``` - -## Reset password - -Create `pages/password-reset/[token].tsx` and add a form with an input for the new password. - -```tsx -// pages/password-reset/[token].tsx -import { useRouter } from "next/router"; - -const Page = () => { - const router = useRouter(); - return ( - <> -

Sign in

-
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch( - `/api/password-reset/${router.query.token}`, - { - method: "POST", - body: JSON.stringify({ - password: formData.get("password") - }), - headers: { - "Content-Type": "application/json" - }, - redirect: "manual" - } - ); - - if (response.status === 0) { - // redirected - // when using `redirect: "manual"`, response status 0 is returned - return router.push("/"); - } - }} - > - - -
- -
- - ); -}; - -export default Page; -``` - -Create `pages/api/password-reset/[token].ts` and handle POST requests. - -Get the token from the url with `req.query.token` and validate it with `validatePasswordResetToken()`. Update the key password with [`Auth.updateKeyPassword()`](/reference/lucia/interfaces/auth#updatekeypassword), and optionally verify the user's email. **Make sure you invalidate all user sessions with [`Auth.invalidateAllUserSessions()`](/reference/lucia/interfaces/auth#invalidateallusersessions) before updating the password.** - -```ts -// pages/api/password-reset/[token].ts -import { auth } from "@/auth/lucia"; -import { validatePasswordResetToken } from "@/auth/verification-token"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") return res.status(405).end(); - const { password } = req.body as { - password: unknown; - }; - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return res.status(400).json({ - error: "Invalid password" - }); - } - try { - const { token } = req.query as { - token: string; - }; - const userId = await validatePasswordResetToken(token); - let user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateKeyPassword("email", user.email, password); - if (!user.emailVerified) { - user = await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest({ - req, - res - }); - authRequest.setSession(session); - return res.end(); - } catch (e) { - return res.status(400).json({ - error: "Invalid or expired password reset link" - }); - } -}; - -export default handler; -``` diff --git a/documentation/content/guidebook/password-reset-link/$nuxt.md b/documentation/content/guidebook/password-reset-link/$nuxt.md deleted file mode 100644 index 08f5e9324..000000000 --- a/documentation/content/guidebook/password-reset-link/$nuxt.md +++ /dev/null @@ -1,281 +0,0 @@ ---- -title: "Password reset links in Nuxt" -description: "Learn how to implement password reset using reset links" ---- - -This guide expects access to the user's verified email. See [Sign in with email and password with verification links](/guidebook/email-verification-links/nuxt) guide to learn how to verify the user's email, and email and password authentication in general. - -```ts -// server/utils/lucia.ts -export const auth = lucia({ - adapter: ADAPTER, - env: process.dev ? "DEV" : "PROD", - middleware: h3(), - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified - }; - } -}); - -export type Auth = typeof auth; -``` - -### Clone project - -The [email and password Nuxt example](https://github.com/lucia-auth/examples/tree/main/nuxt/email-and-password) includes password reset. - -``` -npx degit lucia-auth/examples/nuxt/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nuxt/email-and-password). - -## Database - -### Password reset token - -Create a new `password_reset_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ----------------------------------- | -| `id` | `string` | ✓ | | Token to send inside the reset link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Password reset tokens - -The token will be sent as part of the reset link. - -``` -http://localhost:3000/password-reset/ -``` - -When a user clicks the link, we prompt the user to enter their new password. When a user submits that form, we'll validate the token stored in the url and update the password of the user's key. - -### Create new tokens - -`generatePasswordResetToken()` will first check if a reset token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// server/utils/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - const storedUserTokens = await db - .table("password_reset_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db - .insertInto("password_reset_token") - .values({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }) - .executeTakeFirst(); - return token; -}; -``` - -### Validate tokens - -`validatePasswordResetToken()` will get the token and delete the token. We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// server/utils/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - // ... -}; - -export const validatePasswordResetToken = async (token: string) => { - const storedToken = await db.transaction().execute(async (trx) => { - const storedToken = await trx - .table("password_reset_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("password_reset_token") - .where("id", "=", storedToken.id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Send password reset link - -Create `pages/password-reset/index.vue` and add a form with an input for the email. - -```vue - - - - -``` - -Create `server/api/password-reset/index.post.ts`. - -Lucia allows us to use raw database queries when needed, for example checking the validity of an email. If the email is valid, create a new password reset link and send it to the user's inbox. - -```ts -// server/api/password-reset/index.post.ts` -export default defineEventHandler(async (event) => { - const { email } = await readBody<{ - email: unknown; - }>(event); - // basic check - if (!isValidEmail(email)) { - throw createError({ status: 400, message: "Invalid email" }); - } - try { - const storedUser = await db - .table("user") - .where("email", "=", email.toLowerCase()) - .get(); - if (!storedUser) { - throw createError({ status: 400, message: "User does not exist" }); - } - const user = auth.transformDatabaseUser(storedUser); - const token = await generatePasswordResetToken(user.userId); - await sendPasswordResetLink(token); - return {}; - } catch (e) { - throw createError({ - message: "An unknown error occurred", - statusCode: 500 - }); - } -}); -``` - -## Reset password - -Create `pages/password-reset/[token].vue` and add a form with an input for the new password. - -```vue - - - - -``` - -Create `server/api/password-reset/[token].post.ts`. - -Get the token from the url with `event.context.params.token` and validate it with `validatePasswordResetToken()`. Update the key password with [`Auth.updateKeyPassword()`](/reference/lucia/interfaces/auth#updatekeypassword), and optionally verify the user's email. **Make sure you invalidate all user sessions with [`Auth.invalidateAllUserSessions()`](/reference/lucia/interfaces/auth#invalidateallusersessions) before updating the password.** - -```ts -// server/api/password-reset/[token].ts -export default defineEventHandler(async (event) => { - const { password } = await readBody<{ - password: unknown; - }>(event); - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - throw createError({ status: 400, message: "Invalid password" }); - } - try { - const { token } = event.context.params ?? { - token: "" - }; - const userId = await validatePasswordResetToken(token); - let user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateKeyPassword("email", user.email, password); - if (!user.emailVerified) { - user = await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(event); - authRequest.setSession(session); - return {}; - } catch (e) { - throw createError({ - message: "Invalid or expired password reset link", - statusCode: 400 - }); - } -}); -``` diff --git a/documentation/content/guidebook/password-reset-link/$sveltekit.md b/documentation/content/guidebook/password-reset-link/$sveltekit.md deleted file mode 100644 index a53cf701d..000000000 --- a/documentation/content/guidebook/password-reset-link/$sveltekit.md +++ /dev/null @@ -1,267 +0,0 @@ ---- -title: "Password reset links in SvelteKit" -description: "Learn how to implement password reset using reset links" ---- - -This guide expects access to the user's verified email. See [Sign in with email and password with verification links](/guidebook/email-verification-links/sveltekit) guide to learn how to verify the user's email, and email and password authentication in general. - -```ts -// $lib/server/lucia.ts -export const auth = lucia({ - adapter: ADAPTER, - env: dev ? "DEV" : "PROD", - middleware: sveltekit(), - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified - }; - } -}); - -export type Auth = typeof auth; -``` - -### Clone project - -The [email and password SvelteKit example](https://github.com/lucia-auth/examples/tree/main/sveltekit/email-and-password) includes password reset. - -``` -npx degit lucia-auth/examples/sveltekit/email-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/sveltekit/email-and-password). - -## Database - -### Password reset token - -Create a new `password_reset_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ----------------------------------- | -| `id` | `string` | ✓ | | Token to send inside the reset link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Password reset tokens - -The token will be sent as part of the reset link. - -``` -http://localhost:5173/password-reset/ -``` - -When a user clicks the link, we prompt the user to enter their new password. When a user submits that form, we'll validate the token stored in the url and update the password of the user's key. - -### Create new tokens - -`generatePasswordResetToken()` will first check if a reset token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// $lib/server/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - const storedUserTokens = await db - .table("password_reset_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db - .insertInto("password_reset_token") - .values({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }) - .executeTakeFirst(); - return token; -}; -``` - -### Validate tokens - -`validatePasswordResetToken()` will get the token and delete the token. We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// $lib/server/token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - // ... -}; - -export const validatePasswordResetToken = async (token: string) => { - const storedToken = await db.transaction().execute(async (trx) => { - const storedToken = await trx - .table("password_reset_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("password_reset_token") - .where("id", "=", storedToken.id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Send password reset link - -Create `routes/password-reset/+page.svelte` and add a form with an input for the email. - -```svelte - - - -

Reset password

-
- -
- -
-``` - -Create `routes/password-reset/+page.server.ts` and define a new form action. - -Lucia allows us to use raw database queries when needed, for example checking the validity of an email. If the email is valid, create a new password reset link and send it to the user's inbox. - -```ts -// routes/password-reset/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail } from "@sveltejs/kit"; -import { generatePasswordResetToken } from "$lib/server/token"; -import { sendPasswordResetLink } from "$lib/server/email"; - -import type { Actions } from "./$types"; - -export const actions: Actions = { - default: async ({ request }) => { - const formData = await request.formData(); - const email = formData.get("email"); - // basic check - if (!isValidEmail(email)) { - return fail(400, { - message: "Invalid email" - }); - } - try { - const storedUser = await db - .table("user") - .where("email", "=", email.toLowerCase()) - .get(); - if (!storedUser) { - return fail(400, { - message: "User does not exist" - }); - } - const user = auth.transformDatabaseUser(storedUser); - const token = await generatePasswordResetToken(user.userId); - await sendPasswordResetLink(token); - return { - success: true - }; - } catch (e) { - return fail(500, { - message: "An unknown error occurred" - }); - } - } -}; -``` - -## Reset password - -Create `routes/password-reset/[token]/+page.svelte` and add a form with an input for the new password. - -```svelte - - - -

Reset password

-
- -
- -
-``` - -Create `routes/password-reset/[token]/+page.server.ts` and define a new form action. - -Get the token from the url with `params.token` and validate it with `validatePasswordResetToken()`. Update the key password with [`Auth.updateKeyPassword()`](/reference/lucia/interfaces/auth#updatekeypassword), and optionally verify the user's email. **Make sure you invalidate all user sessions with [`Auth.invalidateAllUserSessions()`](/reference/lucia/interfaces/auth#invalidateallusersessions) before updating the password.** - -```ts -// routes/password-reset/[token]/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; -import { validatePasswordResetToken } from "$lib/server/verification-token"; - -import type { Actions } from "./$types"; - -export const actions: Actions = { - default: async ({ request, params, locals }) => { - const formData = await request.formData(); - const password = formData.get("password"); - // basic check - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return fail(400, { - message: "Invalid password" - }); - } - try { - const { token } = params; - const userId = await validatePasswordResetToken(token); - let user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateKeyPassword("email", user.email, password); - if (!user.emailVerified) { - user = await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - } - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - locals.auth.setSession(session); - } catch (e) { - return fail(400, { - message: "Invalid or expired password reset link" - }); - } - throw redirect(302, "/"); - } -}; -``` diff --git a/documentation/content/guidebook/password-reset-link/index.md b/documentation/content/guidebook/password-reset-link/index.md deleted file mode 100644 index 157b62ac8..000000000 --- a/documentation/content/guidebook/password-reset-link/index.md +++ /dev/null @@ -1,213 +0,0 @@ ---- -title: "Password reset links" -description: "Learn how to implement password reset using reset links" ---- - -This guide expects access to the user's verified email. See [Sign in with email and password with verification links](/guidebook/email-verification-links) guide to learn how to verify the user's email, and email and password authentication in general. - -```ts -// lucia.ts -export const auth = lucia({ - adapter: ADAPTER, - env: "DEV", - middleware: web(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - email: data.email, - emailVerified: data.email_verified - }; - } -}); - -export type Auth = typeof auth; -``` - -## Database - -### Password reset token - -Create a new `password_reset_token` table. This will have 3 fields. - -| name | type | primary | references | description | -| --------- | --------------------------- | :-----: | ---------- | ----------------------------------- | -| `id` | `string` | ✓ | | Token to send inside the reset link | -| `expires` | `bigint` (unsigned 8 bytes) | | | Expiration (in milliseconds) | -| `user_id` | `string` | | `user(id)` | | - -We'll be storing the expiration date as a `bigint` since Lucia uses handles expiration in milliseconds, but you can of course store it in seconds or the native `timestamp` type. Just make sure to adjust the expiration check accordingly. - -## Password reset tokens - -The token will be sent as part of the reset link. - -``` -http://localhost:/password-reset/ -``` - -When a user clicks the link, we prompt the user to enter their new password. When a user submits that form, we'll validate the token stored in the url and update the password of the user's key. - -### Create new tokens - -`generatePasswordResetToken()` will first check if a reset token already exists for the user. If it does, it will re-use the token if the expiration is over 1 hour away (half the expiration of 2 hours). If not, it will create a new token using [`generateRandomString()`](/reference/lucia/modules/utils#generaterandomstring) with a length of 63. The length is arbitrary, and anything around or longer than 64 characters should be sufficient (recommend minimum is 40). - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - const storedUserTokens = await db - .table("password_reset_token") - .where("user_id", "=", userId) - .getAll(); - if (storedUserTokens.length > 0) { - const reusableStoredToken = storedUserTokens.find((token) => { - // check if expiration is within 1 hour - // and reuse the token if true - return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2); - }); - if (reusableStoredToken) return reusableStoredToken.id; - } - const token = generateRandomString(63); - await db - .insertInto("password_reset_token") - .values({ - id: token, - expires: new Date().getTime() + EXPIRES_IN, - user_id: userId - }) - .executeTakeFirst(); - return token; -}; -``` - -### Validate tokens - -`validatePasswordResetToken()` will get the token and delete the token. We recommend handling this in a transaction or a batched query. It thens check the expiration with [`isWithinExpiration()`](/reference/lucia/modules/utils#iswithinexpiration), provided by Lucia, which checks if the current time is within the provided expiration time (in milliseconds). - -It will throw if the token is invalid. - -```ts -// token.ts -import { generateRandomString, isWithinExpiration } from "lucia/utils"; - -const EXPIRES_IN = 1000 * 60 * 60 * 2; // 2 hours - -export const generatePasswordResetToken = async (userId: string) => { - // ... -}; - -export const validatePasswordResetToken = async (token: string) => { - const storedToken = await db.transaction().execute(async (trx) => { - const storedToken = await trx - .table("password_reset_token") - .where("id", "=", token) - .get(); - if (!storedToken) throw new Error("Invalid token"); - await trx - .table("password_reset_token") - .where("id", "=", storedToken.id) - .delete(); - return storedToken; - }); - const tokenExpires = Number(storedToken.expires); // bigint => number conversion - if (!isWithinExpiration(tokenExpires)) { - throw new Error("Expired token"); - } - return storedToken.user_id; -}; -``` - -## Send password reset link - -Lucia allows us to use raw database queries when needed, for example checking the validity of an email. If the email is valid, create a new password reset link and send it to the user's inbox. - -```ts -import { generatePasswordResetToken } from "./token.js"; - -post("/password-reset", async (request: Request) => { - const { email } = await request.json(); - // check email - if (!isValidEmail(email)) { - return new Response("Invalid email", { - status: 400 - }); - } - try { - // query from user table - const storedUser = await db - .table("user") - .where("email", "=", email.toLowerCase()) - .get(); - if (!storedUser) { - return new Response("User does not exist", { - status: 400 - }); - } - const token = await generatePasswordResetToken(storedUser.id); - await sendPasswordResetLink(token); - return new Response(); - } catch (e) { - return new Response("An unknown error occurred", { - status: 500 - }); - } -}); -``` - -## Reset password - -Get the token from the url and validate the token with `validatePasswordResetToken()`. Update the key password with [`Auth.updateKeyPassword()`](/reference/lucia/interfaces/auth#updatekeypassword), and optionally verify the user's email. **Make sure you invalidate all user sessions with [`Auth.invalidateAllUserSessions()`](/reference/lucia/interfaces/auth#invalidateallusersessions) before updating the password.** - -```ts -import { auth } from "./lucia.js"; -import { validatePasswordResetToken } from "./token.js"; - -post("/password-reset/[token]", async (request: Request) => { - const { password } = await request.json(); - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return new Response("Invalid password", { - status: 400 - }); - } - try { - const { token } = req.params; - const userId = await validatePasswordResetToken(token); - let user = await auth.getUser(userId); - await auth.invalidateAllUserSessions(user.userId); - await auth.updateKeyPassword("email", user.email, password); - - if (!user.emailVerified) { - user = await auth.updateUserAttributes(user.userId, { - email_verified: Number(true) - }); - } - - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() - } - }); - } catch (e) { - return new Response("Invalid or expired password reset link", { - status: 400 - }); - } -}); -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$astro.md b/documentation/content/guidebook/sign-in-with-username-and-password/$astro.md deleted file mode 100644 index 684e56fa1..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$astro.md +++ /dev/null @@ -1,376 +0,0 @@ ---- -title: "Sign in with username and password in Astro" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/astro) and that you've implement the [recommended middleware](/getting-started/astro#set-up-middleware)._ - -This guide will cover how to implement a simple username and password authentication using Lucia in Astro. It will have 3 parts: - -- A sign up page -- A sign in page -- A profile page with a logout button - -### Clone project - -You can get started immediately by cloning the [Astro example](https://github.com/lucia-auth/examples/tree/main/astro/username-and-password) from the repository. - -``` -npx degit lucia-auth/examples/astro/username-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/astro/username-and-password). - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` in `env.d.ts` whenever you add any new columns to the user table. - -```ts -// src/env.d.ts -/// -declare namespace Lucia { - type Auth = import("./lib/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// src/lib/lucia.ts -import { lucia } from "lucia"; -import { astro } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: import.meta.env.DEV ? "DEV" : "PROD", - middleware: astro(), - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Sign up page - -Create `pages/signup.astro` and a form with inputs for username and password - -```astro ---- -// src/pages/signup.astro ---- - - - - - - - - -

Sign up

-
- -
- -
- -
- Sign in - - -``` - -### Create users - -The form submission can be handled within the same Astro page. - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). Since we've setup middleware, `AuthRequest` is accessible as `Astro.locals.auth`. - -```astro ---- -// src/pages/signup.astro -import { auth } from "../lib/lucia"; - -let errorMessage: string | null = null; - -// check for form submissions -if (Astro.request.method === "POST") { - const formData = await Astro.request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - const validUsername = - typeof username === "string" && - username.length >= 4 && - username.length <= 31; - const validPassword = - typeof password === "string" && - password.length >= 6 && - password.length <= 255; - if (validUsername && validPassword) { - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - Astro.locals.auth.setSession(session); // set session cookie - return Astro.redirect("/", 302); // redirect to profile page - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - errorMessage = "Username already taken"; - } else { - errorMessage = "An unknown error occurred"; - } - } - } else { - errorMessage = "Invalid input"; - } -} ---- -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -### Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign up page. You can validate requests by creating a new [`AuthRequest` instance](/reference/lucia/interfaces/authrequest) with [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest), which is stored as `Astro.locals.auth`, and calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```astro ---- -// src/pages/signup.astro -import { auth } from "../lib/lucia"; - -if (Astro.request.method === "POST") { - // ... -} - -const session = await Astro.locals.auth.validate(); -if (session) return Astro.redirect("/", 302); // redirect to profile page ---- -``` - -## Sign in page - -Create `src/pages/login.astro` and also add a form with inputs for username and password - -```astro ---- -// src/pages/login.astro ---- - - - - - - - - -

Sign in

-
- -
- -
- -
- Create an account - - -``` - -### Authenticate users - -This will be handled in a POST endpoint. - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```astro ---- -// src/pages/login.astro -import { auth } from "../lib/lucia"; -import { LuciaError } from "lucia"; - -let errorMessage: string | null = null; - -// check for form submissions -if (Astro.request.method === "POST") { - const formData = await Astro.request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - const validUsername = - typeof username === "string" && - username.length >= 4 && - username.length <= 31; - const validPassword = - typeof password === "string" && - password.length >= 6 && - password.length <= 255; - if (validUsername && validPassword) { - try { - // find user by key - // and validate password - const key = await auth.useKey( - "username", - username.toLowerCase(), - password - ); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - Astro.locals.auth.setSession(session); // set session cookie - return Astro.redirect("/", 302); // redirect to profile page - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - errorMessage = "Incorrect username or password"; - } else { - errorMessage = "An unknown error occurred"; - } - } - } else { - errorMessage = "Invalid input"; - } -} ---- -``` - -### Redirect authenticated users - -As we did in the sign up page, redirect authenticated users to the profile page. - -```astro ---- -// src/pages/login.astro -import { auth } from "../lib/lucia"; - -if (Astro.request.method === "POST") { - // ... -} - -const session = await Astro.locals.auth.validate(); -if (session) return Astro.redirect("/", 302); // redirect to profile page ---- -``` - -## Profile page - -Create `src/pages/index.astro`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you'll see that `User.username` exists because we defined it in first step with `getUserAttributes()` configuration. - -```astro ---- -// src/pages/index.astro -const session = await Astro.locals.auth.validate(); -if (!session) return Astro.redirect("/login", 302); ---- - - - - - - - - -

Profile

-

User id: {session.user.userId}

-

Username: {session.user.username}

-
- -
- - -``` - -### Sign out users - -Create `src/pages/logout.ts` and handle POST requests. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be done by passing `null` to `AuthRequest.setSession()`. - -```ts -// src/pages/logout.ts -import { auth } from "../lib/lucia"; - -import type { APIRoute } from "astro"; - -export const post: APIRoute = async (context) => { - const session = await context.locals.auth.validate(); - if (!session) { - return new Response("Unauthorized", { - status: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - context.locals.auth.setSession(null); - return context.redirect("/login", 302); -}; -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$express.md b/documentation/content/guidebook/sign-in-with-username-and-password/$express.md deleted file mode 100644 index cffbfd1bb..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$express.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -title: "Sign in with username and password in Express" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/express)._ - -This guide will cover how to implement a simple username and password authentication using Lucia. - -### Clone project - -You can get started immediately by cloning the [Express example](https://github.com/lucia-auth/examples/tree/main/express/username-and-password) from the repository. - -``` -npx degit lucia-auth/examples/express/username-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/express/username-and-password). - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { express } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: express(), - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Configure Express - -Set up the body parser middleware. - -```ts -import express from "express"; - -const app = express(); - -app.use(express.urlencoded()); // for application/x-www-form-urlencoded (forms) -app.use(express.json()); // for application/json -``` - -## Sign up user - -### Create users - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession), which will be stored in the user's device. - -```ts -import { auth } from "./lucia.js"; - -app.get("/signup", async () => { - return renderPage("signup.html"); // example -}); - -app.post("/signup", async (req, res) => { - const { username, password } = req.body; - // basic check - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - return res.status(400).send("Invalid username"); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return res.status(400).send("Invalid password"); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(req, res); - authRequest.setSession(session); - // redirect to profile page - return res.status(302).setHeader("Location", "/").end(); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return res.status(400).send("Username already taken"); - } - - return res.status(500).send("An unknown error occurred"); - } -}); -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Store session - -Cookies can be stored with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). A new [`AuthRequest`](/reference/lucia/interfaces/authrequest) instance can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with Express' `Request` and `Response`. - -Alternatively, you can return the session in the response and store it locally in the device for single page and native applications. - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -## Sign in user - -### Authenticate users - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -app.get("/login", async (req, res) => { - return renderPage("login.html"); // example -}); - -app.post("/login", async (req, res) => { - const { username, password } = req.body; - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - return res.status(400).send("Invalid username"); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return res.status(400).send("Invalid password"); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("username", username.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(req, res); - authRequest.setSession(session); - // redirect to profile page - return res.status(302).setHeader("Location", "/").end(); - } catch (e) { - // check for unique constraint error in user table - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return res.status(400).send("Incorrect username or password"); - } - - return res.status(500).send("An unknown error occurred"); - } -}); -``` - -## Get authenticated user - -You can validate requests and get the current session/user by either using [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate) for session cookies, and [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) for session ids sent via the authorization header as a `Bearer` token. Both of these method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -You can see that `User.username` exists because we defined it with `getUserAttributes()` configuration. - -```ts -get("/user", async (req, res) => { - const authRequest = auth.handleRequest(req, res); - const session = await authRequest.validate(); // or `authRequest.validateBearerToken()` - if (session) { - const user = session.user; - const username = user.username; - // ... - } - // ... -}); -``` - -## Sign out users - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -import { auth } from "./lucia.js"; - -app.post("/logout", async (req, res) => { - const authRequest = auth.handleRequest(req, res); - const session = await authRequest.validate(); // or `authRequest.validateBearerToken()` - if (!session) { - return res.sendStatus(401); - } - await auth.invalidateSession(session.sessionId); - - authRequest.setSession(null); // for session cookie - - // redirect back to login page - return res.status(302).setHeader("Location", "/login").end(); -}); -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$hono.md b/documentation/content/guidebook/sign-in-with-username-and-password/$hono.md deleted file mode 100644 index 6f9943d40..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$hono.md +++ /dev/null @@ -1,255 +0,0 @@ ---- -title: "Sign in with username and password in Hono" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/hono)._ - -This guide will cover how to implement a simple username and password authentication using Lucia. - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { hono } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: hono(), - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Sign up user - -### Create users - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession), which will be stored in the user's device. - -```ts -import { auth } from "./lucia.js"; - -app.get("/signup", async () => { - return renderPage("signup.html"); // example -}); - -app.post("/signup", async (context) => { - const { username, password } = await context.req.parseBody(); - // basic check - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - return context.text("Invalid username", 400); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return context.text("Invalid password", 400); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(context); - authRequest.setSession(session); - // redirect to profile page - return context.redirect("/login"); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return context.text("Username already taken", 400); - } - return context.text("An unknown error occurred", 500); - } -}); -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Store session - -Cookies can be stored with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). A new [`AuthRequest`](/reference/lucia/interfaces/authrequest) instance can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with Hono request `Context`. - -Alternatively, you can return the session in the response and store it locally in the device for single page and native applications. - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -## Sign in user - -### Authenticate users - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -app.get("/login", async () => { - return renderPage("login.html"); // example -}); - -app.post("/login", async (context) => { - const { username, password } = await context.req.parseBody(); - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - return context.text("Invalid username", 400); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return context.text("Invalid password", 400); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("username", username.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(context); - authRequest.setSession(session); - // redirect to profile page - return context.redirect("/login"); - } catch (e) { - // check for unique constraint error in user table - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return context.text("Incorrect username or password", 400); - } - - return context.text("An unknown error occurred", 500); - } -}); -``` - -## Get authenticated user - -You can validate requests and get the current session/user by either using [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate) for session cookies, and [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) for session ids sent via the authorization header as a `Bearer` token. Both of these method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -You can see that `User.username` exists because we defined it with `getUserAttributes()` configuration. - -```ts -get("/user", async (context) => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); // or `authRequest.validateBearerToken()` - if (session) { - const user = session.user; - const username = user.username; - // ... - } - // ... -}); -``` - -## Sign out users - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -import { auth } from "./lucia.js"; - -app.post("/logout", async (context) => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); // or `authRequest.validateBearerToken()` - if (!session) { - return context.text("Unauthorized", 401); - } - await auth.invalidateSession(session.sessionId); - - authRequest.setSession(null); // for session cookie - - // redirect back to login page - return context.redirect("/login"); -}); -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-app.md b/documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-app.md deleted file mode 100644 index 07cba7df2..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-app.md +++ /dev/null @@ -1,553 +0,0 @@ ---- -title: "Sign in with username and password in Next.js App Router" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/nextjs-app)._ - -This guide will cover how to implement a simple username and password authentication using Lucia in Next.js App Router. It will have 3 parts: - -- A sign up page -- A sign in page -- A profile page with a logout button - -### Clone project - -You can get started immediately by cloning the [Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-app/username-and-password) from the repository. - -``` -npx degit lucia-auth/examples/nextjs-app/username-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-app/username-and-password). - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -Set [`sessionCookie.expires`](/basics/configuration#sessioncookie) to false since we can't update the session cookie when validating them. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - - sessionCookie: { - expires: false - } -}); - -export type Auth = typeof auth; -``` - -We'll also expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Form component - -Since the form will require client side JS, we will extract it into its own client component. We need to manually handle redirect responses as the default behavior is to make another request to the redirect location. We're going to use `refresh()` to reload the page (and redirect the user in the server) since we want to re-render the entire page, including `layout.tsx`. - -```tsx -// components/form.tsx -"use client"; - -import { useRouter } from "next/navigation"; - -const Form = ({ - children, - action -}: { - children: React.ReactNode; - action: string; -}) => { - const router = useRouter(); - return ( -
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch(action, { - method: "POST", - body: formData, - redirect: "manual" - }); - - if (response.status === 0) { - // redirected - // when using `redirect: "manual"`, response status 0 is returned - return router.refresh(); - } - }} - > - {children} -
- ); -}; - -export default Form; -``` - -## Sign up page - -Create `app/signup/page.tsx` and add a form with inputs for username and password. The form should make a POST request to `/api/signup`. - -```tsx -// app/signup/page.tsx -import Form from "@/components/form"; -import Link from "next/link"; - -const Page = async () => { - return ( - <> -

Sign up

-
- - -
- - -
- -
- Sign in - - ); -}; - -export default Page; -``` - -### Create users - -Create `app/api/signup/route.ts` and handle POST requests. - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with the request method, `cookies()`, and `headers().. - -```ts -// app/api/signup/route.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { NextResponse } from "next/server"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - return NextResponse.json( - { - error: "Invalid username" - }, - { - status: 400 - } - ); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return NextResponse.json( - { - error: "Invalid password" - }, - { - status: 400 - } - ); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(request.method, context); - authRequest.setSession(session); - return new Response(null, { - status: 302, - headers: { - Location: "/" // redirect to profile page - } - }); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return NextResponse.json( - { - error: "Username already taken" - }, - { - status: 400 - } - ); - } - - return NextResponse.json( - { - error: "An unknown error occurred" - }, - { - status: 500 - } - ); - } -}; -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -### Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign up page. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -For `Auth.handleRequest()`, pass `"GET"` as the request method. - -```tsx -// app/signup/page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -import Form from "@/components/form"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (session) redirect("/"); - // ... -}; - -export default Page; -``` - -## Sign in page - -Create `app/login/page.tsx` and also add a form with inputs for username and password. The form should make a POST request to `/api/login`. - -```tsx -// app/login/page.tsx -import Form from "@/components/form"; -import Link from "next/link"; - -const Page = async () => { - return ( - <> -

Sign in

-
- - -
- - -
- -
- Create an account - - ); -}; - -export default Page; -``` - -### Authenticate users - -Create `app/api/login/route.ts` and handle POST requests. - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```ts -// app/api/login/route.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { NextResponse } from "next/server"; -import { LuciaError } from "lucia"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - return NextResponse.json( - { - error: "Invalid username" - }, - { - status: 400 - } - ); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return NextResponse.json( - { - error: "Invalid password" - }, - { - status: 400 - } - ); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("username", username.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(request.method, context); - authRequest.setSession(session); - return new Response(null, { - status: 302, - headers: { - Location: "/" // redirect to profile page - } - }); - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist or invalid password - return NextResponse.json( - { - error: "Incorrect username or password" - }, - { - status: 400 - } - ); - } - return NextResponse.json( - { - error: "An unknown error occurred" - }, - { - status: 500 - } - ); - } -}; -``` - -### Redirect authenticated users - -As we did in the sign up page, redirect authenticated users to the profile page. - -```ts -// app/login/page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -import Form from "@/components/form"; -import Link from "next/link"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (session) redirect("/"); - // ... -}; - -export default Page; -``` - -## Profile page - -Create `app/page.tsx`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you'll see that `User.username` exists because we defined it in first step with `getUserAttributes()` configuration. - -```tsx -// app/page.tsx -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; -import { redirect } from "next/navigation"; - -import Form from "@/components/form"; - -const Page = async () => { - const authRequest = auth.handleRequest("GET", context); - const session = await authRequest.validate(); - if (!session) redirect("/login"); - return ( - <> -

Profile

-

User id: {session.user.userId}

-

Username: {session.user.username}

-
- -
- - ); -}; - -export default Page; -``` - -### Sign out users - -Create `app/api/logout/route.ts` and handle POST requests. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `AuthRequest.setSession()`. - -```ts -// app/api/logout/route.ts -import { auth } from "@/auth/lucia"; -import * as context from "next/headers"; - -import type { NextRequest } from "next/server"; - -export const POST = async (request: NextRequest) => { - const authRequest = auth.handleRequest(request.method, context); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - return new Response(null, { - status: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - authRequest.setSession(null); - return new Response(null, { - status: 302, - headers: { - Location: "/login" // redirect to login page - } - }); -}; -``` - -## Additional notes - -For getting the current user in `page.tsx` and `layout.tsx`, we recommend wrapping `AuthRequest.validate()` in `cache()`, which is provided by React. This should not be used inside `route.tsx` as Lucia will assume the request is a GET request. - -```ts -export const getPageSession = cache(() => { - const authRequest = auth.handleRequest("GET", context); - return authRequest.validate(); -}); -``` - -This allows you share the session across pages and layouts, making it possible to validate the request in multiple layouts and page files without making unnecessary database calls. - -```ts -const Page = async () => { - const session = await getPageSession(); -}; -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-pages.md b/documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-pages.md deleted file mode 100644 index 569b96d64..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$nextjs-pages.md +++ /dev/null @@ -1,540 +0,0 @@ ---- -title: "Sign in with username and password in Next.js Pages Router" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/nextjs-pages)._ - -This guide will cover how to implement a simple username and password authentication using Lucia in Next.js Pages Router. It will have 3 parts: - -- A sign up page -- A sign in page -- A profile page with a logout button - -### Clone project - -You can get started immediately by cloning the [Next.js example](https://github.com/lucia-auth/examples/tree/main/nextjs-pages/username-and-password) from the repository. - -``` -npx degit lucia-auth/examples/nextjs-pages/username-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nextjs-pages/username-and-password). - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: nextjs_future(), - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Sign up page - -Create `pages/signup.tsx` and add a form with inputs for username and password. The form should make a POST request to `/api/signup`. - -```tsx -// pages/signup.tsx -import { useRouter } from "next/router"; - -import Link from "next/link"; - -const Page = () => { - const router = useRouter(); - return ( - <> -

Sign up

-
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch(e.currentTarget.action, { - method: "POST", - body: JSON.stringify({ - username: formData.get("username"), - password: formData.get("password") - }), - headers: { - "Content-Type": "application/json" - }, - redirect: "manual" - }); - if (response.status === 0 || response.ok) { - router.push("/"); // redirect to profile page on success - } - }} - > - - -
- - -
- -
- Sign in - - ); -}; - -export default Page; -``` - -### Create users - -Create `pages/api/signup.ts` and handle POST requests. - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with `IncomingMessage` and `OutgoingMessage`. - -```ts -// pages/api/signup.ts -import { auth } from "@/auth/lucia"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") return res.status(405); - const { username, password } = req.body as { - username: unknown; - password: unknown; - }; - // basic check - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - return res.status(400).json({ - error: "Invalid username" - }); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return res.status(400).json({ - error: "Invalid password" - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest({ - req, - res - }); - authRequest.setSession(session); - return res.redirect(302, "/"); // profile page - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return res.status(400).json({ - error: "Username already taken" - }); - } - - return res.status(500).json({ - error: "An unknown error occurred" - }); - } -}; - -export default handler; -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -### Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign up page. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```tsx -// pages/signup.tsx -import { useRouter } from "next/router"; -import { auth } from "@/auth/lucia"; - -import Link from "next/link"; - -import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (session) { - return { - redirect: { - destination: "/", - permanent: false - } - }; - } - return { - props: {} - }; -}; - -const Page = () => { - // ... -}; - -export default Page; -``` - -## Sign in page - -Create `pages/login.tsx` and also add a form with inputs for username and password. The form should make a POST request to `/api/login`. - -```tsx -// pages/login.tsx -import { useRouter } from "next/router"; - -import Link from "next/link"; - -const Page = () => { - const router = useRouter(); - return ( - <> -

Sign in

-
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const response = await fetch(e.currentTarget.action, { - method: "POST", - body: JSON.stringify({ - username: formData.get("username"), - password: formData.get("password") - }), - headers: { - "Content-Type": "application/json" - }, - redirect: "manual" - }); - - if (response.status === 0 || response.ok) { - router.push("/"); // redirect to profile page on success - } - }} - > - - -
- - -
- -
- Create an account - - ); -}; - -export default Page; -``` - -### Authenticate users - -Create `pages/api/login.ts` and handle POST requests. - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```ts -// pages/api/login.ts -import { auth } from "@/auth/lucia"; -import { LuciaError } from "lucia"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") return res.status(405); - const { username, password } = req.body as { - username: unknown; - password: unknown; - }; - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - return res.status(400).json({ - error: "Invalid username" - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return res.status(400).json({ - error: "Invalid password" - }); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("username", username.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest({ - req, - res - }); - authRequest.setSession(session); - return res.redirect(302, "/"); // profile page - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return res.status(400).json({ - error: "Incorrect username or password" - }); - } - return res.status(500).json({ - error: "An unknown error occurred" - }); - } -}; - -export default handler; -``` - -### Redirect authenticated users - -As we did in the sign up page, redirect authenticated users to the profile page. - -```ts -// pages/login.tsx -import { auth } from "@/auth/lucia"; - -import Link from "next/link"; - -import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (session) { - return { - redirect: { - destination: "/", - permanent: false - } - }; - } - return { - props: {} - }; -}; - -const Page = () => { - // ... -}; - -export default Page; -``` - -## Profile page - -Create `pages/index.tsx`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you’ll see that `User.username` exists because we defined it in first step with `getUserAttributes()` configuration. - -```tsx -// pages/index.tsx -import { auth } from "@/auth/lucia"; -import { useRouter } from "next/router"; - -import type { - GetServerSidePropsContext, - GetServerSidePropsResult, - InferGetServerSidePropsType -} from "next"; - -export const getServerSideProps = async ( - context: GetServerSidePropsContext -): Promise< - GetServerSidePropsResult<{ - userId: string; - username: string; - }> -> => { - const authRequest = auth.handleRequest(context); - const session = await authRequest.validate(); - if (!session) { - return { - redirect: { - destination: "/login", - permanent: false - } - }; - } - return { - props: { - userId: session.user.userId, - username: session.user.username - } - }; -}; - -const Page = ( - props: InferGetServerSidePropsType -) => { - const router = useRouter(); - return ( - <> -

Profile

-

User id: {props.userId}

-

Username: {props.username}

-
{ - e.preventDefault(); - const response = await fetch("/api/logout", { - method: "POST", - redirect: "manual" - }); - if (response.status === 0 || response.ok) { - router.push("/login"); // redirect to login page on success - } - }} - > - -
- - ); -}; - -export default Page; -``` - -### Sign out users - -Create `pages/api/logout.ts` and handle POST requests. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -// pages/api/logout.ts -import { auth } from "@/auth/lucia"; - -import type { NextApiRequest, NextApiResponse } from "next"; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") return res.status(405); - const authRequest = auth.handleRequest({ req, res }); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - return res.status(401).json({ - error: "Unauthorized" - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - authRequest.setSession(null); - return res.redirect(302, "/login"); -}; - -export default handler; -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$nuxt.md b/documentation/content/guidebook/sign-in-with-username-and-password/$nuxt.md deleted file mode 100644 index 700ff8e0c..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$nuxt.md +++ /dev/null @@ -1,463 +0,0 @@ ---- -title: "Sign in with username and password in Nuxt" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/nuxt)._ - -This guide will cover how to implement a simple username and password authentication using Lucia in Nuxt. It will have 3 parts: - -- A sign up page -- A sign in page -- A profile page with a logout button - -### Clone project - -You can get started immediately by cloning the [Nuxt example](https://github.com/lucia-auth/examples/tree/main/nuxt/username-and-password) from the repository. - -``` -npx degit lucia-auth/examples/nuxt/username-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/nuxt/username-and-password). - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// server/app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./utils/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// server/utils/lucia.ts -import { lucia } from "lucia"; -import { h3 } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.dev ? "DEV" : "PROD", - middleware: h3(), - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Sign up page - -Create `pages/signup.vue`. It will have a form with inputs for username and password - -```vue - - - - -``` - -### Create users - -Create `server/api/signup.post.ts`. - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). [`AuthRequest`](/reference/lucia/interfaces/authrequest) can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with `H3Event`. - -```ts -// server/api/signup.post.ts -export default defineEventHandler(async (event) => { - const { username, password } = await readBody<{ - username: unknown; - password: unknown; - }>(event); - // basic check - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - throw createError({ - message: "Invalid username", - statusCode: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - throw createError({ - message: "Invalid password", - statusCode: 400 - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(event); - authRequest.setSession(session); - return sendRedirect(event, "/"); // redirect to profile page - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - throw createError({ - message: "Username already taken", - statusCode: 400 - }); - } - throw createError({ - message: "An unknown error occurred", - statusCode: 500 - }); - } -}); -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -## Sign in page - -Create `pages/login.vue`. This will have a form with inputs for username and password - -```vue - - - - -``` - -### Authenticate users - -Create `server/api/login.post.ts`. - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```ts -// server/api/login.post.ts -import { LuciaError } from "lucia"; - -export default defineEventHandler(async (event) => { - const { username, password } = await readBody<{ - username: unknown; - password: unknown; - }>(event); - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - throw createError({ - message: "Invalid username", - statusCode: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - throw createError({ - message: "Invalid password", - statusCode: 400 - }); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("username", username.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const authRequest = auth.handleRequest(event); - authRequest.setSession(session); - return sendRedirect(event, "/"); // redirect to profile page - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - throw createError({ - message: "Incorrect username or password", - statusCode: 400 - }); - } - throw createError({ - message: "An unknown error occurred", - statusCode: 500 - }); - } -}); -``` - -## Managing auth state - -### Get authenticated user - -Create `server/api/user.get.ts`. This endpoint will return the current user. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```ts -// server/api/user.get.ts -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - const session = await authRequest.validate(); - return { - user: session?.user ?? null; - } -}); -``` - -### Composables - -Create `useUser()` and `useAuthenticatedUser()` composables. `useUser()` will return the current user. `useAuthenticatedUser()` can only be used inside protected routes, which allows the ref value type to be always defined (never `null`). - -```ts -// composables/auth.ts -import type { User } from "lucia"; - -export const useUser = () => { - const user = useState("user", () => null); - return user; -}; - -export const useAuthenticatedUser = () => { - const user = useUser(); - return computed(() => { - const userValue = unref(user); - if (!userValue) { - throw createError( - "useAuthenticatedUser() can only be used in protected pages" - ); - } - return userValue; - }); -}; -``` - -### Define middleware - -Define a global `auth` middleware that gets the current user and populates the user state. This will run on every navigation. - -```ts -// middleware/auth.global.ts -export default defineNuxtRouteMiddleware(async () => { - const user = useUser(); - const { data, error } = await useFetch("/api/user"); - if (error.value) throw createError("Failed to fetch data"); - user.value = data.value?.user ?? null; -}); -``` - -Next, define a regular `protected` middleware that redirects unauthenticated users to the login page. - -```ts -// middleware/protected.ts -export default defineNuxtRouteMiddleware(async () => { - const user = useUser(); - if (!user.value) return navigateTo("/login"); -}); -``` - -## Redirect authenticated user - -For both `pages/signup.vue` and `pages/login.vue`, redirect authenticated users to the profile page. - -```vue - - - -``` - -## Profile page - -Create `pages/index.vue`. This will show some basic user info and include a logout button. - -Use the `protected` middleware to redirect unauthenticated users, and call `useAuthenticatedUser()` to get the authenticated user. - -```vue - - - - -``` - -### Sign out users - -Create `server/api/logout.post.ts`. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -// server/api/logout.post.ts -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - // check if user is authenticated - const session = await authRequest.validate(); - if (!session) { - throw createError({ - message: "Unauthorized", - statusCode: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - // delete session cookie - authRequest.setSession(null); - return sendRedirect(event, "/login"); -}); -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$solidstart.md b/documentation/content/guidebook/sign-in-with-username-and-password/$solidstart.md deleted file mode 100644 index 7414eef94..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$solidstart.md +++ /dev/null @@ -1,471 +0,0 @@ ---- -title: "Sign in with username and password in SolidStart" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/solidstart)._ - -This guide will cover how to implement a simple username and password authentication using Lucia in SolidStart. It will have 3 parts: - -- A sign up page -- A sign in page -- A profile page with a logout button - -### Clone project - -You can get started immediately by cloning the [SolidStart example](https://github.com/lucia-auth/examples/tree/main/solidstart/username-and-password) from the repository. - -``` -npx degit lucia-auth/examples/solidstart/username-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/solidstart/username-and-password). - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// src/app.d.ts -/// -declare namespace Lucia { - type Auth = import("./auth/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -We'll expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// src/auth/lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", - middleware: web(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Sign up page - -Create `src/routes/signup.tsx`. Use `createServerAction$()` to create a form with inputs for username and password. - -```tsx -// src/routes/signup.tsx -import { A } from "solid-start"; -import { createServerAction$ } from "solid-start/server"; - -const Page = () => { - const [_, { Form }] = createServerAction$(async (formData: FormData) => {}); - return ( - <> -

Sign up

-
- - -
- - -
- -
- Sign in - - ); -}; - -export default Page; -``` - -### Create users - -The form submission will be handled within a server action. - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession). This session should be stored as a cookie, which can be created with [`Auth.createSessionCookie()`](/reference/lucia/interfaces/auth#createsessioncookie). You can store this cooking by passing `Cooke.serialize()` to the `Set-Cookie` response header. - -```ts -// src/routes/signup.tsx -import { A } from "solid-start"; -import { auth } from "~/auth/lucia"; -import { createServerAction$, ServerError } from "solid-start/server"; - -const Page = () => { - const [_, { Form }] = createServerAction$(async (formData: FormData) => { - const username = formData.get("username"); - const password = formData.get("password"); - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - throw new ServerError("Invalid username"); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - throw new ServerError("Invalid password"); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - // set cookie and redirect - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() - } - }); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - throw new ServerError("Username already taken"); - } - throw new ServerError("An unknown error occurred", { - status: 500 - }); - } - }); - // ... -}; - -export default Page; -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -### Redirect authenticated users - -Authenticated users should be redirected to the profile page whenever they try to access the sign up page. You can validate requests by creating by calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. A new [`AuthRequest`](/reference/lucia/interfaces/authrequest) instance can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with `Request`. - -Make sure to do the check inside `createServerData$()`. - -```tsx -// src/routes/signup.tsx -import { A } from "solid-start"; -import { auth } from "~/auth/lucia"; -import { - createServerAction$, - createServerData$, - redirect, - ServerError -} from "solid-start/server"; - -export const routeData = () => { - return createServerData$(async (_, event) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (session) { - return redirect("/"); - } - }); -}; - -const Page = () => { - // ... -}; - -export default Page; -``` - -## Sign in page - -Create `src/routes/login.tsx` and also add a form with inputs for username and password with `createServerAction$()`. - -```tsx -// src/routes/login.ts -import { A } from "solid-start"; -import { createServerAction$ } from "solid-start/server"; - -const Page = () => { - const [_, { Form }] = createServerAction$(async (formData: FormData) => {}); - return ( - <> -

Sign in

-
- - -
- - -
- -
- Create an account - - ); -}; - -export default Page; -``` - -### Authenticate users - -The form submission will be handled within a server action. - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```tsx -// src/routes/login.tsx -import { A } from "solid-start"; -import { auth } from "~/auth/lucia"; -import { ServerError, createServerAction$ } from "solid-start/server"; -import { LuciaError } from "lucia"; - -const Page = () => { - const [enrolling, { Form }] = createServerAction$( - async (formData: FormData) => { - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - throw new ServerError("Invalid username"); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - throw new ServerError("Invalid password"); - } - try { - // find user by key - // and validate password - const key = await auth.useKey( - "username", - username.toLowerCase(), - password - ); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - // set cookie and redirect - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() - } - }); - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - throw new ServerError("Incorrect username or password"); - } - throw new ServerError("An unknown error occurred"); - } - } - ); - // ... -}; - -export default Page; -``` - -### Redirect authenticated users - -As we did in the sign up page, redirect authenticated users to the profile page. - -```ts -// src/routes/login.tsx -import { A } from "solid-start"; -import { auth } from "~/auth/lucia"; -import { - createServerAction$, - createServerData$, - redirect, - ServerError -} from "solid-start/server"; -import { LuciaError } from "lucia"; - -export const routeData = () => { - return createServerData$(async (_, event) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (session) { - return redirect("/"); - } - }); -}; - -const Page = () => { - // ... -}; - -export default Page; -``` - -## Profile page - -Create `src/routes/index.tsx`. This page will show some basic user info and include a logout button. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you’ll see that `User.username` exists because we defined it in first step with `getUserAttributes()` configuration. - -```tsx -// src/routes/index.tsx -import { useRouteData } from "solid-start"; -import { createServerData$, redirect } from "solid-start/server"; -import { auth } from "~/auth/lucia"; - -export const routeData = () => { - return createServerData$(async (_, event) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (!session) { - return redirect("/login") as never; - } - return session.user; - }); -}; - -const Page = () => { - const user = useRouteData(); - return ( - <> -

Profile

-

User id: {user()?.userId}

-

Username: {user()?.username}

- - ); -}; - -export default Page; -``` - -### Sign out users - -The form submission will be handled within a server action. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```tsx -// src/routes/index.tsx -import { useRouteData } from "solid-start"; -import { - ServerError, - createServerAction$, - createServerData$, - redirect -} from "solid-start/server"; -import { auth } from "~/auth/lucia"; - -export const routeData = () => { - // ... -}; - -const Page = () => { - const user = useRouteData(); - const [_, { Form }] = createServerAction$(async (_, event) => { - const authRequest = auth.handleRequest(event.request); - const session = await authRequest.validate(); - if (!session) { - throw new ServerError("Unauthorized", { - status: 401 - }); - } - await auth.invalidateSession(session.sessionId); // invalidate session - const sessionCookie = auth.createSessionCookie(null); - return new Response(null, { - status: 302, - headers: { - Location: "/login", - "Set-Cookie": sessionCookie.serialize() - } - }); - }); - return ( - <> -

Profile

-

User id: {user()?.userId}

-

Username: {user()?.username}

-
- -
- - ); -}; - -export default Page; -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/$sveltekit.md b/documentation/content/guidebook/sign-in-with-username-and-password/$sveltekit.md deleted file mode 100644 index b27a7a0f5..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/$sveltekit.md +++ /dev/null @@ -1,408 +0,0 @@ ---- -title: "Sign in with username and password in SvelteKit" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started/sveltekit) and that you've implement the recommended `handle()` hook._ - -This guide will cover how to implement a simple username and password authentication using Lucia in SvelteKit. It will have 3 parts: - -- A sign up page -- A sign in page -- A profile page with a logout button - -### Clone project - -You can get started immediately by cloning the [SvelteKit example](https://github.com/lucia-auth/examples/tree/main/sveltekit/username-and-password) from the repository. - -``` -npx degit lucia-auth/examples/sveltekit/username-and-password -``` - -Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/lucia-auth/examples/tree/main/sveltekit/username-and-password). - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` in `app.d.ts` whenever you add any new columns to the user table. - -```ts -// src/app.d.ts -/// -declare global { - namespace Lucia { - type Auth = import("$lib/server/lucia").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; - } -} - -// THIS IS IMPORTANT!!! -export {}; -``` - -## Configure Lucia - -We'll expose the user's username to the `User` object by defining [`getUserAttributes`](/basics/configuration#getuserattributes). - -```ts -// src/lib/server/lucia.ts -import { lucia } from "lucia"; -import { sveltekit } from "lucia/middleware"; -import { dev } from "$app/environment"; - -export const auth = lucia({ - adapter: ADAPTER, - env: dev ? "DEV" : "PROD", - middleware: sveltekit(), - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Sign up page - -Create `routes/signup/+page.svelte`. It will have a form with inputs for username and password - -```svelte - - - -

Sign up

-
- -
- -
- -
-Sign in -``` - -### Create users - -Create `routes/signup/+page.server.ts` and define a new form action to handle form submissions. - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and, if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used to get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) and store it as a cookie with [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). Since we've setup a handle hook, `AuthRequest` is accessible as `locals.auth`. - -```ts -// routes/signup/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { Actions } from "./$types"; - -export const actions: Actions = { - default: async ({ request, locals }) => { - const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - return fail(400, { - message: "Invalid username" - }); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return fail(400, { - message: "Invalid password" - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - locals.auth.setSession(session); // set session cookie - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return fail(400, { - message: "Username already taken" - }); - } - return fail(500, { - message: "An unknown error occurred" - }); - } - // redirect to - // make sure you don't throw inside a try/catch block! - throw redirect(302, "/"); - } -}; -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -### Redirect authenticated users - -Define a server load function in `routes/signup/+page.server.ts`. - -Authenticated users should be redirected to the profile page whenever they try to access the sign up page. You can validate requests by creating a new [`AuthRequest` instance](/reference/lucia/interfaces/authrequest) with [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest), which is stored in `locals.auth`, and calling [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate). This method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. - -```ts -// routes/signup/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { PageServerLoad, Actions } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (session) throw redirect(302, "/"); - return {}; -}; -``` - -## Sign in page - -Create `routes/login/+page.svelte`. It will also have a form with inputs for username and password. - -```svelte - - - -

Sign in

-
- -
- -
- -
-Create an account -``` - -### Authenticate users - -Create routes/login/+page.server.ts and define a new form action to handle form submissions. - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```ts -// routes/login/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { LuciaError } from "lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { Actions } from "./$types"; - -export const actions: Actions = { - default: async ({ request, locals }) => { - const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - return fail(400, { - message: "Invalid username" - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return fail(400, { - message: "Invalid password" - }); - } - try { - // find user by key - // and validate password - const key = await auth.useKey( - "username", - username.toLowerCase(), - password - ); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - locals.auth.setSession(session); // set session cookie - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return fail(400, { - message: "Incorrect username or password" - }); - } - return fail(500, { - message: "An unknown error occurred" - }); - } - // redirect to - // make sure you don't throw inside a try/catch block! - throw redirect(302, "/"); - } -}; -``` - -### Redirect authenticated users - -As we did in the sign up page, redirect authenticated users to the profile page by defining a server load function in `routes/login/+page.server.ts`. - -```ts -// routes/login/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { PageServerLoad, Actions } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (session) throw redirect(302, "/"); - return {}; -}; - -export const actions: Actions = { - // ... -}; -``` - -## Profile page - -Create `routes/+page.svelte`. This will show some basic user info and include a logout button. Expect TS error for now since we have populated `PageData` yet. - -```svelte - - -

Profile

-

User id: {data.userId}

-

Username: {data.username}

-
- -
-``` - -### Get authenticated user - -Create `routes/+page.server.ts` and define a load function. - -Unauthenticated users should be redirected to the login page. The user object is available in `Session.user`, and you'll see that `User.username` exists because we defined it in first step with `getUserAttributes()` configuration. - -```ts -// routes/+page.server.ts -import { redirect } from "@sveltejs/kit"; - -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) throw redirect(302, "/login"); - return { - userId: session.user.userId, - username: session.user.username - }; -}; -``` - -### Sign out users - -Define a new server action in `routes/+page.server.ts`. - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be done by passing `null` to `AuthRequest.setSession()`. - -```ts -// routes/+page.server.ts -import { auth } from "$lib/server/lucia"; -import { fail, redirect } from "@sveltejs/kit"; - -import type { Actions, PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async ({ locals }) => { - // ... -}; - -export const actions: Actions = { - logout: async ({ locals }) => { - const session = await locals.auth.validate(); - if (!session) return fail(401); - await auth.invalidateSession(session.sessionId); // invalidate session - locals.auth.setSession(null); // remove cookie - throw redirect(302, "/login"); // redirect to login page - } -}; -``` diff --git a/documentation/content/guidebook/sign-in-with-username-and-password/index.md b/documentation/content/guidebook/sign-in-with-username-and-password/index.md deleted file mode 100644 index 4f48004a8..000000000 --- a/documentation/content/guidebook/sign-in-with-username-and-password/index.md +++ /dev/null @@ -1,306 +0,0 @@ ---- -title: "Sign in with username and password" -description: "Learn the basic of Lucia by implementing a basic username and password authentication" ---- - -_Before starting, make sure you've [setup Lucia and your database](/getting-started)._ - -This guide will cover how to implement a simple username and password authentication using Lucia. - -## Update your database - -Add a `username` column to your table. It should be a `string` (`TEXT`, `VARCHAR` etc) type that's unique. - -Make sure you update `Lucia.DatabaseUserAttributes` whenever you add any new columns to the user table. - -```ts -// app.d.ts - -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = {}; -} -``` - -## Configure Lucia - -Since we're dealing with the standard `Request` and `Response`, we'll use the [`web()`](/reference/lucia/modules/middleware#web) middleware. We're also setting [`sessionCookie.expires`](/basics/configuration#sessioncookie) to false since we can't update the session cookie when validating them. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: "DEV", // "PROD" for production - - middleware: web(), - sessionCookie: { - expires: false - } -}); - -export type Auth = typeof auth; -``` - -We also want to expose the user's username to the `User` object returned by Lucia's APIs. We'll define [`getUserAttributes`](/basics/configuration#getuserattributes) and return the username. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - adapter: ADAPTER, - env: "DEV", // "PROD" for production - middleware: web(), - sessionCookie: { - expires: false - }, - - getUserAttributes: (data) => { - return { - username: data.username - }; - } -}); - -export type Auth = typeof auth; -``` - -## Sign up user - -### Create users - -This will be handled in a POST request. - -Users can be created with [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This will create a new user, and if `key` is defined, a new key. The key here defines the connection between the user and the provided unique username (`providerUserId`) when using the username & password authentication method (`providerId`). We'll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for `attributes` property is `Lucia.DatabaseUserAttributes`, which we added `username` to previously. - -After successfully creating a user, we'll create a new session with [`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession). This session should be stored as a cookie, which can be created with [`Auth.createSessionCookie()`](/reference/lucia/interfaces/auth#createsessioncookie). - -```ts -import { auth } from "./lucia.js"; - -post("/signup", async (request: Request) => { - const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - if ( - typeof username !== "string" || - username.length < 4 || - username.length > 31 - ) { - return new Response("Invalid username", { - status: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 6 || - password.length > 255 - ) { - return new Response("Invalid password", { - status: 400 - }); - } - try { - const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } - }); - const session = await auth.createSession({ - userId: user.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - // redirect to profile page - return new Response(null, { - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize() // store session cookie - }, - status: 302 - }); - } catch (e) { - // this part depends on the database you're using - // check for unique constraint error in user table - if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR - ) { - return new Response("Username already taken", { - status: 400 - }); - } - - return new Response("An unknown error occurred", { - status: 500 - }); - } -}); -``` - -#### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key. - -On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url `/user/user123`), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using `LOWER()` in SQL). - -```ts -const user = await auth.createUser({ - key: { - providerId: "username", // auth method - providerUserId: username.toLowerCase(), // unique id when using "username" auth method - password // hashed by Lucia - }, - attributes: { - username - } -}); -``` - -#### Error handling - -Lucia throws 2 types of errors: [`LuciaError`](/reference/lucia/modules/main#luciaerror) and database errors from the database driver or ORM you're using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM. - -```ts -if ( - e instanceof SomeDatabaseError && - e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR -) { - // username already taken -} -``` - -## Sign in user - -### Authenticate users - -The key we created for the user allows us to get the user via their username, and validate their password. This can be done with [`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey). If the username and password is correct, we'll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling `useKey()`. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -post("/login", async (request: Request) => { - const formData = await request.formData(); - const username = formData.get("username"); - const password = formData.get("password"); - // basic check - if ( - typeof username !== "string" || - username.length < 1 || - username.length > 31 - ) { - return new Response("Invalid username", { - status: 400 - }); - } - if ( - typeof password !== "string" || - password.length < 1 || - password.length > 255 - ) { - return new Response("Invalid password", { - status: 400 - }); - } - try { - // find user by key - // and validate password - const key = await auth.useKey("username", username.toLowerCase(), password); - const session = await auth.createSession({ - userId: key.userId, - attributes: {} - }); - const sessionCookie = auth.createSessionCookie(session); - return new Response(null, { - headers: { - Location: "/", // redirect to profile page - "Set-Cookie": sessionCookie.serialize() // store session cookie - }, - status: 302 - }); - } catch (e) { - if ( - e instanceof LuciaError && - (e.message === "AUTH_INVALID_KEY_ID" || - e.message === "AUTH_INVALID_PASSWORD") - ) { - // user does not exist - // or invalid password - return new Response("Incorrect username or password", { - status: 400 - }); - } - return new Response("An unknown error occurred", { - status: 500 - }); - } -}); -``` - -## Get authenticated user - -You can validate requests and get the current session/user by either using [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate) for session cookies, and [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) for session ids sent via the authorization header as a `Bearer` token. Both of these method returns a [`Session`](/reference/lucia/interfaces#session) if the user is authenticated or `null` if not. A new [`AuthRequest`](/reference/lucia/interfaces/authrequest) instance can be created by calling [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) with `Request`. - -You can see that `User.username` exists because we defined it with `getUserAttributes()` configuration. - -```ts -import { auth } from "./lucia.js"; - -get("user/", async (request: Request) => { - const authRequest = auth.handleRequest(request); - const session = await authRequest.validate(); // or `authRequest.validateBearerToken()` - if (session) { - const user = session.user; - const username = user.username; - // ... - } - // ... -}); -``` - -## Sign out users - -When logging out users, it's critical that you invalidate the user's session. This can be achieved with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing `null` to `Auth.createSessionCookie()`. - -```ts -import { auth } from "./lucia.js"; - -post("/logout", async (request: Request) => { - const authRequest = auth.handleRequest(request); - // check if user is authenticated - const session = await authRequest.validate(); // or `authRequest.validateBearerToken()` - if (!session) { - return new Response("Unauthorized", { - status: 401 - }); - } - // make sure to invalidate the current session! - await auth.invalidateSession(session.sessionId); - - // for session cookies - // create blank session cookie - const sessionCookie = auth.createSessionCookie(null); - return new Response(null, { - headers: { - Location: "/login", // redirect to login page - "Set-Cookie": sessionCookie.serialize() // delete session cookie - }, - status: 302 - }); -}); -``` diff --git a/documentation/content/guidebook/vercel-postgres.md b/documentation/content/guidebook/vercel-postgres.md deleted file mode 100644 index f0b57b76a..000000000 --- a/documentation/content/guidebook/vercel-postgres.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: "Using `@vercel/postgres`" -description: "Learn how to use `@vercel/postgres` with Lucia" ---- - -`@vercel/postgres` can be used as a drop-in replacement for [`pg`](https://github.com/brianc/node-postgres). This means that it can be used with Lucia using the [`pg` adapter](/database-adapters/pg). Make sure to pass `db`, which is the equivalent to `Pool` in `pg`, since the adapter needs to have access to transactions. - -```ts -import { lucia } from "lucia"; -import { pg } from "@lucia-auth/adapter-postgresql"; -import { db } from "@vercel/postgres"; - -export const auth = lucia({ - adapter: pg(db, { - // table names - }) - // ... -}); -``` - -## Errors - -Unfortunately, `@vercel/postgres` does not export an error class that can be used to check for database errors. Nor are the error types or messages documented, though it's the same as `@neondatabase/serverless`. However, the error codes are PostgreSQL error codes, which are [well documented](https://www.postgresql.org/docs/current/errcodes-appendix.html). - -```ts -type VercelPostgresError = { - code: string; - detail: string; - schema?: string; - table?: string; - column?: string; - dataType?: string; - constraint?: "auth_user_username_key"; -}; -``` - -```ts -try { - // ... -} catch (e) { - const maybeVercelPostgresError = ( - typeof e === "object" ? e : {} - ) as Partial; - - // error code for unique constraint violation - if (maybeVercelError.code === "23505") { - // ... - } -} -``` diff --git a/documentation/content/main/basics/configuration.md b/documentation/content/main/basics/configuration.md deleted file mode 100644 index 72d693218..000000000 --- a/documentation/content/main/basics/configuration.md +++ /dev/null @@ -1,275 +0,0 @@ ---- -title: "Configuration" -description: "Learn how to configure Lucia" ---- - -This page describes all the configuration available for [`lucia()`](/reference/lucia/modules/main#lucia). `MaybePromise` indicates the function can synchronous or asynchronous. - -```ts -type Configuration = { - // required - adapter: - | InitializeAdapter - | { - user: InitializeAdapter; - session: InitializeAdapter; - }; - env: "DEV" | "PROD"; - - // optional - csrfProtection?: - | boolean - | { - allowedSubdomains: "*" | string[]; - }; - getSessionAttributes?: (databaseSession: SessionSchema) => Record; - getUserAttributes?: (databaseUser: UserSchema) => Record; - middleware?: Middleware; - passwordHash?: { - generate: (password: string) => MaybePromise; - validate: ( - password: string, - hashedPassword: string - ) => MaybePromise; - }; - sessionCookie?: { - name?: string; - attributes?: SessionCookieAttributes; - expires?: boolean; - }; - sessionExpiresIn?: { - activePeriod: number; - idlePeriod: number; - }; - - // experimental - experimental?: { - debugMode?: boolean; - }; -}; -``` - -## Required - -### `adapter` - -An adapter (specifically a function that initializes it) for your database. You can use a different adapter for your sessions (session adapters). - -```ts -const adapter: InitializeAdapter; -``` - -```ts -const adapter: { - user: InitializeAdapter; - session: InitializeAdapter; -}; -``` - -| type | description | -| ----------------------------------- | ----------------------------------- | -| `InitializeAdapter` | Initialize adapter function | -| `InitializeAdapter` | Initialize session adapter function | - -### `env` - -Provides Lucia with the current server context. - -| value | description | -| -------- | ----------------------------------------- | -| `"DEV"` | The server is running on HTTP (localhost) | -| `"PROD"` | The server is running on HTTPS | - -## Optional - -### `csrfProtection` - -`true` by default. When set to `true`, [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate) checks if the request is same-origin using the `Origin` header. You can define trusted subdomains by adding them to `csrfProtection.allowedSubdomains`. If your app is hosted on `https://foo.example.com`, adding `"bar"` will allow `https://bar.example.com`. You can add `null` in the array to allow urls without a subdomain. - -CSRF protection is applied to all requests except for GET, OPTIONS, HEAD, and TRACE request. - -```ts -const csrfProtection = boolean | { - allowedSubdomains?: "*" | (string | null)[] - host?: string, - hostHeader?: string -} -``` - -| value | description | -| -------- | ----------------------------------- | -| `true` | CSRF protection enabled | -| `false` | CSRF protection disabled | -| `object` | CSRF protection enabled - see below | - -| name | type | description | default | -| ------------------- | ----------------- | ------------------------------------------------------------------------------------ | -------- | -| `allowedSubdomains` | `"*" \| string[]` | List of allowed subdomains (not full urls/origins) - set to `*` allow all subdomains | | -| `host` | `string` | The host of the server - this will be always used when defined | | -| `hostHeader` | `string` | The header Lucia will use to define the host | `"Host"` | - -### `getSessionAttributes()` - -Generates session attributes for the user. The returned properties will be included in [`Session`](/reference/lucia/interfaces#session) as is. - -```ts -const getSessionAttributes: ( - databaseSession: SessionSchema -) => Record; -``` - -##### Parameters - -| name | type | description | -| ----------------- | ------------------------------------------------------------ | ------------------------------ | -| `databaseSession` | [`SessionSchema`](/reference/lucia/interfaces#sessionschema) | Session stored in the database | - -##### Returns - -| type | -| ------------------ | -| `Record` | - -##### Default - -```ts -const getSessionAttributes = () => { - return {}; -}; -``` - -### `getUserAttributes()` - -Generates user attributes for the user. The returned properties will be included in [`User`](/reference/lucia/interfaces#user) as is. - -```ts -const getUserAttributes: (databaseUser: UserSchema) => Record; -``` - -##### Parameters - -| name | type | description | -| -------------- | ------------------------------------------------------ | --------------------------- | -| `databaseUser` | [`UserSchema`](/reference/lucia/interfaces#userschema) | User stored in the database | - -##### Returns - -| type | -| ------------------ | -| `Record` | - -##### Default - -```ts -const getUserAttributes = () => { - return {}; -}; -``` - -### `middleware` - -```ts -const middleware: Middleware; -``` - -Lucia middleware for [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest). [Learn more about middleware](/basics/handle-requests). - -| type | default value | -| ------------------------------------------------ | ------------------------------------------------------ | -| [`Middleware`](/reference/middleware#middleware) | [`lucia()`](/reference/lucia/modules/middleware#lucia) | - -### `passwordHash` - -By default, passwords are hashed using Scrypt. - -```ts -const passwordHash: { - generate: (password: string) => MaybePromise; - validate: (password: string, hashedPassword: string) => MaybePromise; -}; -``` - -#### `passwordHash.generate()` - -Generates a hash for a password synchronously or asynchronously. - -##### Parameters - -| name | type | description | -| ---------- | -------- | -------------------- | -| `password` | `string` | The password to hash | - -##### Returns - -| type | description | -| -------- | ------------------- | -| `string` | The hashed password | - -#### `passwordHash.validate()` - -Validates a hash generated using `passwordHash.generate()` synchronously or asynchronously. - -##### Parameters - -| name | type | description | -| -------------- | -------- | -------------------------------------------------- | -| `password` | `string` | The password to validate | -| {passwordHash} | `string` | The password hash to validate the password against | - -##### Returns - -| value | description | -| ------- | --------------------------------- | -| `true` | Argument of `password` is valid | -| `false` | Argument of `password` is invalid | - -### `sessionCookie` - -```ts -const sessionCookie: { - name?: string; - attributes?: SessionCookieAttributes; - expires: boolean; -}; - -type SessionCookieAttributes = { - sameSite?: "lax" | "strict" | "none"; // default: "lax" - path?: string; // default "/"" - domain?: string; // default: undefined -}; -``` - -| property | type | optional | description | -| ----------- | ------------------------- | :------: | ------------------------------------------------------------ | -| `name` | `string` | ✓ | Session cookie name | -| `attributes | `SessionCookieAttributes` | ✓ | Session cookie attributes | -| `expires` | `boolean` | ✓ | Toggle if session cookie expires or not - enabled by default | - -### `sessionExpiresIn` - -```ts -const sessionExpiresIn: { - activePeriod: number; - idlePeriod: number; -}; -``` - -The active period is the span of time sessions are valid for, while the idle period is span of time since the end of the active period that sessions could be reset (extend expiration). - -| property | type | description | default | -| -------------- | -------- | --------------------------------------------------------------------------------------- | -------------------- | -| `activePeriod` | `number` | The [active period](/basics/sessions#session-states-and-session-reset) in milliseconds. | 86400000 (1 day) | -| `idlePeriod | `number` | The [idle period](/basics/sessions#session-states-and-session-reset) in milliseconds | 1209600000 (2 weeks) | - -## Experimental - -Experimental configurations are available in `experimental`. - -### `debugMode` - -Disabled by default. When debug mode is enabled, Lucia will log key events to the console. - -| value | description | -| ------- | ------------------- | -| `true` | Debug mode enabled | -| `false` | Debug mode disabled | diff --git a/documentation/content/main/basics/database.md b/documentation/content/main/basics/database.md deleted file mode 100644 index fa086a8b8..000000000 --- a/documentation/content/main/basics/database.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: "Database" -description: "Learn how your database works with Lucia" ---- - -A database is required for storing your users and sessions. Lucia connects to your database via an adapter, which provides a set of basic, standardized querying methods that Lucia can use. - -## Database adapters - -There are 2 types of adapters provided by Lucia: Regular adapters, and session adapters. As the name implies, session adapters only handles queries to the session table. This is useful for when you want to store your sessions in a different database than your users, such as Redis and other memory stores. - -We currently provide the following adapters: - -- [`better-sqlite3`](/database-adapters/better-sqlite3) -- [libSQL](/database-adapters/libsql) -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [Mongoose](/database-adapters/mongoose) -- [`mysql2`](/database-adapters/mysql2) -- [`pg`](/database-adapters/pg) -- [`postgres`](/database-adapters/postgres) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Prisma](/database-adapters/prisma) -- [Redis](/database-adapters/redis) -- [Unstorage](/database-adapters/unstorage) - -SDKs such as `@vercel/postgres` and `@neondatabase/serverless` provide drop-in replacements for existing drivers. You can also use query builders like Drizzle ORM and Kysely since they rely on underlying drivers that we provide adapters for. Refer to these guides: - -- [Using `@vercel/postgres`](/guidebook/vercel-postgres) -- [Using Drizzle ORM](/guidebook/drizzle-orm) -- [Using Kysely](/guidebook/kysely) - -## Database model - -Lucia requires 3 tables to work, which are then connected to Lucia via a database adapter. This is only the basic model and the specifics depend on the adapter. **Lucia does not support default database values.** - -### User table - -The `id` column must be a string type, and as such, cannot be auto-incremented integer. It can be a UUID type, but keep in mind that the default user ids generated by Lucia are not UUIDs or ObjectIDs. You cannot use default (auto-generated) database values (such as UUIDs and ObjectIDs) and IDs must be generated at runtime. - -| name | type | primary key | description | -| ---- | -------- | :---------: | ----------- | -| id | `string` | ✓ | User id | - -```ts -type UserSchema = { - id: string; -} & Lucia.DatabaseUserAttributes; -``` - -In addition to the required fields shown below, you can add any additional fields to the table, in which case they should be declared in type `Lucia.DatabaseUserAttributes`. If you're using an database driver adapter such as `pg()`, these fields must match your database columns. Keep this in mind if you're using an ORM such as Drizzle. **Lucia does not support default (auto-generated) database values.** - -```ts -declare namespace Lucia { - // ... - type DatabaseUserAttributes = { - // required fields (i.e. id) should not be defined here - username: string; - }; -} -``` - -### Session table - -The `id` column must be a string type, and as such, cannot be auto-incremented integer. It can be a UUID type, but keep in mind that the default session ids generated by Lucia are not UUIDs or ObjectIDs. You cannot use default (auto-generated) database values (such as UUIDs and ObjectIDs) and IDs must be generated at runtime. - -| name | type | primary key | references | description | -| -------------- | --------------- | :---------: | ---------- | -------------------------------------------------- | -| id | `string` | ✓ | | | -| user_id | `string` | | `user(id)` | | -| active_expires | `number` (int8) | | | The expiration time (unix) of the session (active) | -| idle_expires | `number` (int8) | | | The expiration time (unix) for the idle period | - -```ts -type SessionSchema = { - id: string; - active_expires: number; - idle_expires: number; - user_id: string; -} & Lucia.DatabaseSessionAttributes; -``` - -In addition to the required fields shown below, you can add any additional fields to the table, in which case they should be declared in type `Lucia.DatabaseSessionAttributes`. If you're using an database driver adapter such as `pg()`, these fields must match your database columns. Keep this in mind if you're using an ORM such as Drizzle. **Lucia does not support default (auto-generated) database values.** - -```ts -declare namespace Lucia { - // ... - type DatabaseSessionAttributes = { - // required fields (i.e. id) should not be defined here - username: string; - display_name: string; - }; -} -``` - -### Key table - -The `id` column must be a regular string type. IDs are generated by Lucia and cannot be configured. It cannot be an auto-incremented integer, UUID, or ObjectID, nor could it be auto-generated by the database. - -| name | type | primary key | references | description | -| --------------- | ---------------- | :---------: | ---------- | -------------------------------------------------------- | -| id | `string` | ✓ | | Key id in the form of: `${providerId}:${providerUserId}` | -| user_id | `string` | | `user(id)` | | -| hashed_password | `string \| null` | | | Hashed password of the key | - -```ts -type KeySchema = { - id: string; - user_id: string; - hashed_password: string | null; -}; -``` diff --git a/documentation/content/main/basics/error-handling.md b/documentation/content/main/basics/error-handling.md deleted file mode 100644 index e544a35ba..000000000 --- a/documentation/content/main/basics/error-handling.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: "Error handling" -description: "Learn about error handling in Lucia" ---- - -Errors in Lucia are thrown as [`LuciaError`](/reference/lucia/modules/main#luciaerror), which extends the standard `Error`. See the API reference for a full list of errors. Alternatively, the API reference for each API methods list possible errors it could throw. - -Using a try-catch block, the error message can be read like so: - -```ts -import { LuciaError } from "lucia"; - -try { - // some action -} catch (e) { - if (e instanceof LuciaError) { - const message = e.message; - } -} -``` - -However, Lucia is made to be used with any databases and heavily relies on adapters. As each database handles errors in different ways, Lucia does not expect adapters to handle every single database errors. Errors such as connection errors, and most notably, user and session attributes violating some database rule (e.g. unique constraint), are handled by re-throwing the database error thrown by the adapter. For example, if you're using the Prisma adapter, Lucia will throw both `LuciaError` and Prisma errors. - -```ts -import { auth } from "./lucia.js"; - -try { - await auth.createUser({ - attributes: { - uniqueField: valueThatAlreadyExists - } - }); -} catch (e) { - // violates unique constraint! -} -``` diff --git a/documentation/content/main/basics/fallback-database-queries.md b/documentation/content/main/basics/fallback-database-queries.md deleted file mode 100644 index 2033394de..000000000 --- a/documentation/content/main/basics/fallback-database-queries.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "Falling back to database queries" -description: "Learn how to use database queries when Lucia's API isn't enough" ---- - -Sometimes, Lucia's API isn't enough. For example, you might want to create a user within a database transaction. The great thing about Lucia is that you can always fallback to raw database queries when you need to. And since a lot of Lucia's API is really just a light wrapper around database queries, it's often easy to implement. - -That said, we discourage replacing any APIs related to session management, especially since at that point you might be better off replacing Lucia entirely. - -## Create users and keys - -[`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser) creates both a key and user in a database transaction. Creating a user is pretty straightforward. The user id is 15 characters long when using the default configuration. - -```ts -import { generateRandomString } from "lucia/utils"; - -// implement `Auth.createUser()` -// execute all in a single transaction -await db.transaction((trx) => { - const userId = generateRandomString(15); - await trx.user.insert({ - id: userId, - // any additional column for user attributes - username - }); - - // TODO: create key -}); -``` - -Keys are bit more complicated. The key id is a combination of the provider id and provider user id. You can create it using [`createKeyId()`](/reference/lucia/modules/main#createkeyid). For `hashed_password`, you can use [`generateLuciaPasswordHash()`](/reference/lucia/modules/utils#generateluciapasswordhash) to hash passwords using Lucia's default hashing function or set it to `null`. - -This part is exactly the same for [`Auth.createKey()`](/reference/lucia/interfaces/auth#createkey). - -```ts -import { generateRandomString, generateLuciaPasswordHash } from "lucia/utils"; -import { createKeyId } from "lucia"; - -// execute all in a single transaction -await db.transaction((trx) => { - const userId = generateRandomString(15); // - await trx.user.insert({ - id: userId, - username - }); - - await trx.key.insert({ - id: createKeyId("username", username), - user_id: userId, - hashed_password: await generateLuciaPasswordHash(password) - }); -}); -``` - -## Transform database objects - -Lucia's `Auth` instance includes methods to transform database query results: - -- [`Auth.transformDatabaseUser()`](/reference/lucia/interfaces/auth#transformdatabaseuser): [`UserSchema`](/reference/lucia/interfaces#userschema) to `User` -- [`Auth.transformDatabaseKey()`](/reference/lucia/interfaces/auth#transformdatabasekey): [`KeySchema`](/reference/lucia/interfaces#keyschema) to `Key` -- [`Auth.transformDatabaseSession()`](/reference/lucia/interfaces/auth#transformdatabasesession): [`SessionSchema`](/reference/lucia/interfaces#sessionschema) to `Session` - -```ts -import { auth } from "./lucia.js"; - -const databaseUser = await db.user.get(userId); -const user = auth.transformDatabaseUser(databaseUser); -``` diff --git a/documentation/content/main/basics/handle-requests.md b/documentation/content/main/basics/handle-requests.md deleted file mode 100644 index a6ca18aaa..000000000 --- a/documentation/content/main/basics/handle-requests.md +++ /dev/null @@ -1,302 +0,0 @@ ---- -title: "Handle requests" -description: "Learn how to handle and validate incoming requests with Lucia" ---- - -Reading and parsing request headers, validating sessions, and setting appropriate response headers for every protected endpoint is a bit tedious. To address this issue, Lucia provides [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) which creates a new [`AuthRequest`](/reference/lucia/interfaces/authrequest) instance. This provides a few methods that make working with session cookies and bearer tokens easier. Refer to [Using cookies](/basics/using-cookies) and [Using bearer tokens](/basics/using-bearer-tokens) page on more about those methods. - -```ts -const authRequest = auth.handleRequest(); - -const session = authRequest.validate(); -authRequest.setSession(session); - -const session = authRequest.validateBearerToken(); -``` - -However, every framework and runtime has their own representation of an incoming request and outgoing response, such as the web standard `Request`/`Response` and Node.js' `IncomingMessage`/`OutgoingMessage`. Lucia uses its own implementation of `RequestContext` as well, which is the default parameter type of `Auth.handleRequest()`. Since this is an annoying problem that is easy to solve, Lucia provides _middleware_. - -Middleware allows you to pass framework and runtime specific request objects to `Auth.handleRequest`. While we provide a number of them, it's easy to create and if you do, consider contributing to the project! - -```ts -import { node } from "lucia/middleware"; - -lucia({ - // ... - middleware: web() // pass Web middleware -}); - -// `Auth.handleRequest()` now accepts `Request` -const authRequest = auth.handleRequest(new Request()); -``` - -## List of middleware - -- [Astro](#astro) -- [Elysia](#elysia) -- [Express](#express) -- [Fastify](#fastify) -- [H3](#h3) - - [Nuxt](#nuxt) -- [Hono](#hono) -- [Next.js](#nextjs) -- [Node.js](#nodejs) -- [Qwik](#qwik) -- [SvelteKit](#sveltekit) -- [Web standard](#web-standard) - - [Remix](#remix) - - Cloudflare workers - -### Lucia (default) - -The default middleware is the Lucia middleware. `Auth.handleRequest()` accepts [`RequestContext`](/reference/middleware#requestcontext). - -```ts -import { lucia } from "lucia/middleware"; -``` - -### Astro - -```ts -import { astro } from "lucia/middleware"; -``` - -```astro ---- -// .astro component -const authRequest = auth.handleRequest(Astro); ---- -``` - -```ts -// API routes and middleware -export const get = async (context) => { - const authRequest = auth.handleRequest(context); - // ... -}; -``` - -We recommend storing `AuthRequest` in `locals`. - -### Elysia - -```ts -import { elysia } from "lucia/middleware"; -``` - -```ts -new Elysia().get("/", async (context) => { - const authRequest = auth.handleRequest(context); -}); -``` - -### Express - -```ts -import { express } from "lucia/middleware"; -``` - -```ts -app.get("/", (req, res) => { - const authRequest = auth.handleRequest(req, res); -}); -``` - -### Fastify - -```ts -import { fastify } from "lucia/middleware"; -``` - -```ts -server.get("/"(request, reply) => { - const authRequest = auth.handleRequest(request, reply); -}); -``` - -### H3 - -```ts -import { h3 } from "lucia/middleware"; -``` - -#### Nuxt - -```ts -// api routes (server/api/index.ts) -export default defineEventHandler(async (event) => { - const authRequest = auth.handleRequest(event); - // ... -}); -``` - -### Hono - -```ts -import { hono } from "lucia/middleware"; -``` - -```ts -app.get("/", async (context) => { - const authRequest = auth.handleRequest(context); -}); -``` - -### Next.js - -`nextjs_future()` will replace `nextjs()` in the next next major release. While `nextjs()` isn't deprecated, we recommend considering it as a legacy API. - -```ts -import { nextjs_future } from "lucia/middleware"; -``` - -#### Pages router - -```ts -// pages/index.tsx -export const getServerSideProps = async (context) => { - const authRequest = auth.handleRequest(context); -}; -``` - -```ts -// pages/index.ts -export default async (req: IncomingMessage, res: OutgoingMessage) => { - const authRequest = auth.handleRequest({ req, res }); -}; -``` - -```ts -// pages/index.ts (deployed to edge) -export default async (request: NextRequest) => { - // `AuthRequest.setSession()` is not supported when only `Request` is passed - auth.handleRequest(request); - // ... - const session = await auth.createSession({ - // ... - }); - const sessionCookie = auth.createSessionCookie(session); - const response = new Response(null); - response.headers.append("Set-Cookie", sessionCookie.serialize()); -}; -``` - -#### App router - -We recommend setting [`sessionCookie.expires`](/basics/configuration#sessioncookie) configuration to `false` when using this middleware. - -```ts -// app/page.tsx -import * as context from "next/headers"; - -export default () => { - const authRequest = auth.handleRequest("GET", context); - - const experimentalFormActions = async () => { - const authRequest = auth.handleRequest("POST", context); - }; - // ... -}; -``` - -```ts -// app/routes.ts -import * as context from "next/headers"; - -export const POST = async (request: NextRequest) => { - const authRequest = auth.handleRequest(request.method, context); - // ... -}; -``` - -#### Middleware - -```ts -// middleware.ts -export const middleware = async (request: NextRequest) => { - // `AuthRequest.setSession()` is not supported when only `NextRequest` is passed - const authRequest = auth.handleRequest(request); - // ... - const session = await auth.createSession({ - // ... - }); - const sessionCookie = auth.createSessionCookie(session); - const response = new Response(null); - response.headers.append("Set-Cookie", sessionCookie.serialize()); -}; -``` - -### Node.js - -```ts -import { node } from "lucia/middleware"; -``` - -```ts -const authRequest = auth.handleRequest(incomingMessage, outgoingMessage); -``` - -### Qwik - -```ts -import { qwik } from "lucia/middleware"; -``` - -```ts -const authRequest = auth.handleRequest(requestEvent as RequestEventLoader); -const authRequest = auth.handleRequest(requestEvent as RequestEventAction); -``` - -### SvelteKit - -```ts -import { sveltekit } from "lucia/middleware"; -``` - -```ts -// +page.server.ts -export const load = async (event) => { - const authRequest = auth.handleRequest(event); - // ... -}; - -export const actions = { - default: async (event) => { - const authRequest = auth.handleRequest(event); - // ... - } -}; -``` - -```ts -// hooks.server.ts -export const handle = async ({ event, resolve }) => { - event.locals.auth = auth.handleRequest(event); - // ... -}; -``` - -### Web standard - -**[`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession) is disabled when using the `web()` middleware.** We recommend setting [`sessionCookie.expires`](/basics/configuration#sessioncookie) configuration to `false` when using this middleware. - -```ts -import { web } from "lucia/middleware"; - -const authRequest = auth.handleRequest(request as Request); -``` - -```ts -const authRequest = auth.handleRequest(request); -await authRequest.validate(); -await authRequest.setSession(session); // error! -``` - -#### Remix - -```ts -export const loader = async ({ request }: LoaderArgs) => { - const authRequest = auth.handleRequest(request); - return json({}); -}; -``` diff --git a/documentation/content/main/basics/keys.md b/documentation/content/main/basics/keys.md deleted file mode 100644 index 69df8eba4..000000000 --- a/documentation/content/main/basics/keys.md +++ /dev/null @@ -1,235 +0,0 @@ ---- -title: "Keys" -description: "Learn about keys in Lucia" ---- - -Keys represent the relationship between a user and a reference to that user. While the user id is the primary way of identifying a user, there are other ways your app may reference a user during the authentication step such as by their username, email, or GitHub user id. These identifiers, be it from a user input or an external source, are provided by a _provider_, identified by a _provider id_. The unique id for that user within the provider is the _provider user id_. The unique combination of the provider id and provider user id makes up a key. - -A user can have any number of keys, allowing for multiple ways of referencing and authenticating users without cramming your user table. Keys can also optionally hold a password, which is useful for implementing a password based authentication. If provided, passwords are automatically hashed by Lucia before storage. - -```ts -const key: Key = { - providerId: "email", - providerUserId: "user@example.com", - passwordDefined: true, - userId: "laRZ8RgA34YYcgj" -}; -``` - -### Examples - -#### Email & password - -Below, you're referencing a user where they're identified with "user@example.com" when using "email" provider, and validating their password if a user exist. - -```ts -import { auth } from "./lucia.js"; - -const key = await auth.useKey("email", "user@example.com", "123456"); -const user = await auth.getUser(key.userId); -``` - -#### OAuth - -Below, you're referencing a user where they're identified with their GitHub user id when using "github" provider. - -```ts -import { auth } from "./lucia.js"; - -const githubUser = await authenticateWithGithub(); // example - exact API not provided by Lucia -const key = await auth.useKey("github", githubUser.userId); -``` - -## Create keys - -Keys can be created with [`Auth.createKey()`](/reference/lucia/interfaces/auth#createkey). This returns the newly created key, or throws `AUTH_DUPLICATE_KEY_ID` if the key already exists. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const key = await auth.createKey({ - userId, - providerId: "email", - providerUserId: "user@example.com", - password: "123456" - }); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_DUPLICATE_KEY_ID") { - // key already exists - } - // unexpected database errors -} -``` - -You must explicitly pass `null` to not store a password. - -```ts -const key = await auth.createKey({ - userId, - providerId: "github", - providerUserId: githubUserId, - password: null // a value must be provided -}); -``` - -### Create keys when creating users - -In most cases, you want to create a key whenever you create (i.e. register) a new user. [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser) includes a parameter to define a key. `null` can be passed to `key` if you don't need to create a key. This preferable to using `Auth.createUser()` and `Auth.createKey()` consecutively as the user will not be created when the key already exists. - -Similar to `Auth.createKey()`, it will throw `AUTH_DUPLICATE_KEY_ID` if the key already exists. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const user = await auth.createUser({ - key: { - providerId, - providerUserId, - password - } // same params as `Auth.createKey()`, - // ... - }); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_DUPLICATE_KEY_ID") { - // key already exists - } - // provided user attributes violates database rules (e.g. unique constraint) - // or unexpected database errors -} -``` - -### Case sensitivity - -Depending on your database, `user123` and `USER123` may be treated as different strings. This leads to issues where there can be multiple users with the same username or email with different cases. As such, it's crucial to make the provider user id lowercase when using user-provided inputs. - -```ts -const user = await auth.createKey({ - userId, - providerId: "username", - providerUserId: username.toLowerCase(), - password: "123456" -}); -``` - -### Password hashing - -Passwords are hashed using [`scrypt`](https://datatracker.ietf.org/doc/html/rfc7914) and a salt with the following parameters: - -``` -N: 16384 -r: 16 -p: 1 -derived-key-length: 64 -``` - -You can configure Lucia to use your own hashing algorithm with [`passwordHash`](/basics/configuration#passwordhash) configuration. - -## Validate keys - -[`Auth.useKey()`](/reference/lucia/interfaces/auth#usekey) can be used to validate a key password and get the key (which includes the user id). This method returns the validated key, or throws `AUTH_INVALID_KEY_ID` on invalid key and `AUTH_INVALID_PASSWORD` on invalid key password. - -You must pass `null` if the key does not hold a password, or pass a valid password if it does. To skip the password check, [use `Auth.getKey()`](/basics/keys#get-keys) instead. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const key = await auth.useKey("email", "user@example.com", "123456"); // validate password too - const user = await auth.getUser(key.userId); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_INVALID_KEY_ID") { - // invalid key - } - if (e instanceof LuciaError && e.message === "AUTH_INVALID_PASSWORD") { - // incorrect password - } - // unexpected database error -} -``` - -```ts -const githubUser = await authenticateWithGithub(); // example - exact API not provided by Lucia -try { - // must pass `null` as the password for it to be valid - const key = await auth.useKey("github", githubUser.userId, null); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_INVALID_KEY_ID") { - // invalid key - } - // unexpected database error -} -``` - -## Get keys - -You can get a key with [`Auth.getKey()`](/reference/lucia/interfaces/auth#getkey), which returns a key or throws `AUTH_INVALID_KEY_ID` if the key does not exist. Unlike `Auth.useKey()`, this does not validate the key password. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const key = await auth.getKey(providerId, providerUserId); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_INVALID_KEY_ID") { - // invalid key - } - // unexpected database error -} -``` - -## Get all keys of a user - -[`Auth.getAllUserKeys()`](/reference/lucia/interfaces/auth#getalluserkeys) can be used to get all keys linked to a user. It returns an array of keys or throw `AUTH_INVALID_USER_ID` if the user id is invalid. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const keys = await auth.getAllUserKeys(userId); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_INVALID_USER_ID") { - // invalid user id - } - // unexpected database error -} -``` - -## Update key password - -You can update a key's password with [`Auth.updateKeyPassword()`](/reference/lucia/interfaces/auth#updatekeypassword). This returns the updated key or throw `AUTH_INVALID_KEY_ID` if the key doesn't exist. You can pass `null` to `newPassword` to remove the password. - -```ts -import { auth } from "./lucia.js"; - -try { - const key = await auth.updateKeyPassword( - providerId, - providerUserId, - newPassword - ); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_INVALID_KEY_ID") { - // invalid key - } - // unexpected database error -} -``` - -```ts -await auth.updateKeyPassword("email", "user@example.com", "654321"); -``` - -## Delete keys - -You can delete a key using [`Auth.deleteKey()`](/reference/lucia/interfaces/auth#deletekey). This will succeed regardless of the existence of the key. - -```ts -await auth.deleteKey("username", username); -``` diff --git a/documentation/content/main/basics/sessions.md b/documentation/content/main/basics/sessions.md deleted file mode 100644 index 0339fbd00..000000000 --- a/documentation/content/main/basics/sessions.md +++ /dev/null @@ -1,243 +0,0 @@ ---- -title: "Sessions" -description: "Learn about sessions in Lucia" ---- - -A session allows Lucia to keep track of requests made by authenticated users. They are identified by their id, which is used as a credential that identifies and authenticate the user. Session ids can be stored in a cookie or used as a traditional token manually added to each request. - -Sessions should be created and stored on registration and login, validated on every request, and deleted on sign out. - -```ts -const session: Session = { - sessionId: "CAbc9LAUY3Q18f0s92Jo817dna8eDtmRrUrDuVFM", // 40 chars - user: { - userId: "larz8rgA34yycgj" - }, // `User` object - activePeriodExpiresAt: new Date(), - idlePeriodExpiresAt: new Date(), - state: "active", // or "idle" - fresh: false -}; -``` - -### Session id - -It's randomly generated by Lucia (a-z and 0-9) and it's 40 characters long. You can pass a custom session id when creating a session as well. - -### Session states and session reset - -Sessions can be in one of 3 states: - -- Active: A valid session. Goes "idle" after some time. -- Idle: A valid session but Lucia will reset the expiration. Becomes "dead" if the session is not used. -- Dead: An invalid session. The user must sign in again. - -Instead of invalidating an idle session and creating a new one, Lucia will _reset_ the session by extending the expiration. - -This allows sessions to be persisted for active users, while invalidating inactive users. If you have used access tokens and refresh tokens, Lucia's sessions are a combination of both. Active sessions are your access tokens, and idle sessions are your refresh tokens. - -### Validating requests - -Requests can be validated by validating the session id included. There are mainly 2 ways to send sessions: cookies and bearer tokens. Cookies are preferred for regular websites and web-apps, whereas bearer tokens are preferred for standalone servers for mobile/desktop apps. Refer to [Handle requests](/basics/handle-requests) page to learn more about how to validate and work with incoming requests from your clients. - -## Defining session attributes - -You can define custom user attributes by returning them in [`getSessionAttributes()`](/basics/configuration#getsessionattributes) configuration. The params for `getSessionAttributes()` will include every field in the `session` table. See [Database](/basics/database#session-table) for adding custom fields to your session table. - -```ts -import { lucia } from "lucia"; - -lucia({ - // ... - getSessionAttributes: (databaseSession) => { - return { - createdAt: databaseSession.created_at - }; - } -}); - -const session: Session = await auth.validateSession(sessionId); -// `sessionId` etc are always included -const { sessionId, createdAt } = session; -``` - -## Create sessions - -[`Auth.createSession()`](/reference/lucia/interfaces/auth#createsession) can be used to create a new session. It takes a user id and the attributes (empty for default configuration), and returns the newly created session. If the user id is invalid, it will throw `AUTH_INVALID_USER_ID`. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const session = await auth.createSession({ - userId, - attributes: {} // expects `Lucia.DatabaseSessionAttributes` - }); - const sessionCookie = auth.createSessionCookie(session); - setSessionCookie(session); -} catch (e) { - if (e instanceof LuciaError && e.message === `AUTH_INVALID_USER_ID`) { - // invalid user id - } - // unexpected database errors -} -``` - -If you have properties defined in `Lucia.DatabaseSessionAttributes`, pass whatever you defined to `attributes`. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const session = await auth.createSession({ - userId, - attributes: { - created_at: new Date() - } // expects `Lucia.DatabaseSessionAttributes` - }); -} catch (e) { - if (e instanceof LuciaError && e.message === `AUTH_INVALID_USER_ID`) { - // invalid user id - } - // provided session attributes violates database rules (e.g. unique constraint) - // or unexpected database errors -} -``` - -### Session attributes errors - -If the session attributes provided violates a database rule (such a unique constraint), Lucia will throw the database/driver/ORM error instead of a regular `LuciaError`. For example, if you're using Prisma, Lucia will throw a Prisma error. - -## Validate sessions - -Sessions can be validated using [`Auth.validateSession()`](/reference/lucia/interfaces/auth#validatesession). This will reset the session if its idle. - -You can check if the returned session was just reset with the `Session.fresh` property. If the session is dead (invalid), it will throw `AUTH_INVALID_SESSION_ID`. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const session = await auth.validateSession(sessionId); - if (session.fresh) { - // expiration extended - const sessionCookie = auth.createSessionCookie(session); - setSessionCookie(session); - } -} catch (e) { - if (e instanceof LuciaError && e.message === `AUTH_INVALID_SESSION_ID`) { - // invalid session - deleteSessionCookie(); - } - // unexpected database errors -} -``` - -## Get sessions - -You can get a session with [`Auth.getSession()`](/reference/lucia/interfaces/auth#getsession). Unlike `Auth.validateSession()`, this will not reset idle sessions and as such, the returned session may be active or idle. - -It takes a session id, and returns the session if it exists or throw `AUTH_INVALID_SESSION_ID` if not. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const session = await auth.getSession(sessionId); - if (session.state === "active") { - // valid sessions - } else { - // idle session - // should be reset - } -} catch (e) { - if (e instanceof LuciaError && e.message === `AUTH_INVALID_SESSION_ID`) { - // invalid session - } - // unexpected database errors -} -``` - -### Get all user sessions - -You can get all valid sessions of a user, both active and idle, with [`Auth.getAllUserSessions()`](/reference/lucia/interfaces/auth#getallusersessions). It will throw `AUTH_INVALID_USER_ID` if the provided user is invalid. - -```ts -import { auth } from "./lucia.js"; - -try { - const sessions = await auth.getAllUserSessions(userId); -} catch (e) { - if (e instanceof LuciaError && e.message === "AUTH_INVALID_USER_ID") { - // invalid user id - } - // unexpected database error -} -``` - -## Invalidate sessions - -You can invalidate sessions with [`Auth.invalidateSession()`](/reference/lucia/interfaces/auth#invalidatesession). This will succeed regardless of the validity of the session. - -```ts -import { auth } from "./lucia.js"; - -await auth.invalidateSession(sessionId); -``` - -### Invalidate all user sessions - -[`Auth.invalidateAllUserSessions()`](/reference/lucia/interfaces/auth#invalidateallusersessions) can be used to invalidate all sessions belonging to a user. This will succeed regardless of the validity of the user id. - -```ts -import { auth } from "./lucia.js"; - -await auth.invalidateAllUserSessions(userId); -``` - -## Update session attributes - -You can update attributes of a session with [`Auth.updateSessionAttributes()`](/reference/lucia/interfaces/auth#updatesessionattributes). You can update a single field or multiple fields. It returns the update session, or throws `AUTH_INVALID_SESSION_ID` if the session does not exist. - -In general however, **invalidating the current session and creating a new session is preferred.** - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const user = await auth.updateSessionAttributes( - sessionId, - { - updated_at: new Date() - } // expects partial `Lucia.DatabaseUserAttributes` - ); -} catch (e) { - if (e instanceof LuciaError && e.message === `AUTH_INVALID_SESSION_ID`) { - // invalid user id - } - // provided session attributes violates database rules (e.g. unique constraint) - // or unexpected database errors -} -``` - -## Delete dead user sessions - -You can delete dead user sessions with [`Auth.deleteDeadUserSessions()`](/reference/lucia/interfaces/auth#deletedeadusersessions) to cleanup your database. It may be useful to call this whenever a user signs in or signs out. This will succeed regardless of the validity of the user id. - -```ts -import { auth } from "./lucia.js"; - -await auth.deleteDeadUserSessions(userId); -``` - -## Configuration - -You can configure sessions in a few ways: - -- Session attributes with [`getSessionAttributes()`](/basics/configuration#getsessionattributes) -- Session expiration with [`sessionExpiresIn`](/basics/configuration#sessionexpiresin) diff --git a/documentation/content/main/basics/users.md b/documentation/content/main/basics/users.md deleted file mode 100644 index 334705c19..000000000 --- a/documentation/content/main/basics/users.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: "Users" -description: "Learn about users in Lucia" ---- - -Users are stored in your database and are represented by a `User` object within Lucia. By default, they only hold their user id. - -```ts -const user = { - userId: "laRZ8RgA34YYcgj" -}; -``` - -### User id - -The primary way to identify users is by their user id. It's randomly generated by Lucia (a-z and 0-9) and it's 15 characters long. You can pass a custom user id when creating a user as well. - -### User attributes - -You can define additional attributes of your users such as email and username. - -## Defining user attributes - -You can define custom user attributes by returning them in [`getUserAttributes()`](/basics/configuration#getuserattributes) configuration. The params for `getUserAttributes()` will include every field in the `user` table. See [Database](/basics/database#user-table) for adding custom fields to your user table. - -```ts -import { lucia } from "lucia"; - -lucia({ - // ... - getUserAttributes: (databaseUser) => { - return { - username: databaseUser.username - }; - } -}); - -const user: User = await auth.getUser(userId); -// `userId` is always defined -const { userId, username } = user; -``` - -## Create users - -You can create new users by calling [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). This returns the created user. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const user = await auth.createUser({ - key: { - providerId, - providerUserId, - password - }, - attributes: { - username - } // expects `Lucia.DatabaseUserAttributes` - }); -} catch (e) { - if (e instanceof LuciaError && e.message === `AUTH_DUPLICATE_KEY_ID`) { - // key already exists - } - // provided user attributes violates database rules (e.g. unique constraint) - // or unexpected database errors -} -``` - -The fields of the `attributes` property is whatever you defined in `Lucia.DatabaseUserAttributes`. It should be an empty object with the default configuration (no user attributes defined). - -You can optionally create a [key](/basics/keys) alongside the user. This is preferable to creating a key on its own as both the user and key will be created in a single database transaction (if the adapter supports it). It will throw `AUTH_DUPLICATE_KEY_ID` if the key already exists. To not create a key, pass `null`: - -```ts -await auth.createUser({ - key: null - // ... -}); -``` - -### User attributes errors - -If the user attributes provided violates a database rule (such a unique constraint), Lucia will throw the database/driver/ORM error instead of a regular `LuciaError`. For example, if you're using Prisma, Lucia will throw a Prisma error. - -### Custom user id - -You can use your own user id by passing `userId` to [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). - -```ts -await auth.createUser({ - userId: generateCustomUserId(), - attributes: {} -}); -``` - -## Get user - -You can get users by their user id with [`Auth.getUser()`](/reference/lucia/interfaces/auth#getuser). - -```ts -import { auth } from "./lucia.js"; - -const user = await auth.getUser(userId); -``` - -## Update user attributes - -You can update attributes of a user with [`Auth.updateUserAttributes()`](/reference/lucia/interfaces/auth#updateuserattributes). You can update a single field or multiple fields. It returns the updated user, or throws `AUTH_INVALID_USER_ID` if the user does not exist. - -> (red) **Make sure to invalidate all sessions of the user on password or privilege level change.** You can create a new session to prevent the current user from being logged out. - -```ts -import { auth } from "./lucia.js"; -import { LuciaError } from "lucia"; - -try { - const user = await auth.updateUserAttributes( - userId, - { - username: newUsername - } // expects partial `Lucia.DatabaseUserAttributes` - ); -} catch (e) { - if (e instanceof LuciaError && e.message === `AUTH_INVALID_USER_ID`) { - // invalid user id - } - // provided user attributes violates database rules (e.g. unique constraint) - // or unexpected database errors -} -``` - -```ts -const user = await auth.updateUserAttributes(userId, { - role: "admin" // new privileges -}); -await auth.invalidateAllUserSessions(user.userId); // invalidate all user sessions => logout all sessions -const session = await auth.createSession({ - userId: user.userId, - attributes: {} -}); // new session -// store new session -``` - -If you're using `AuthRequest` to validate sessions, [use `AuthRequest.invalidate()`](/basics/using-cookies#invalidation) to get the latest user data when you next call `AuthRequest.validate()` and `AuthRequest.validateBearerToken()`. - -```ts -await auth.updateUserAttributes(userId, { - username: newUsername -}); -authRequest.invalidate(); - -// returns latest user data -const session = await authRequest.validate(); -``` - -## Delete users - -You can delete users with [`Auth.deleteUser()`](/reference/lucia/interfaces/auth#deleteuser). All sessions and keys of the user will be deleted as well. This method will succeed regardless of the validity of the user id. - -```ts -import { auth } from "./lucia.js"; - -await auth.deleteUser(userId); -``` - -## Configuration - -You can configure users in a few ways: - -- User attributes with [`getUserAttributes()`](/basics/configuration#getuserattributes) diff --git a/documentation/content/main/basics/using-bearer-tokens.md b/documentation/content/main/basics/using-bearer-tokens.md deleted file mode 100644 index 1a4a8dc37..000000000 --- a/documentation/content/main/basics/using-bearer-tokens.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: "Using bearer tokens" -description: "Learn how to use bearer tokens with Lucia" ---- - -Sending session ids as bearer tokens is useful when your frontend and backend is hosted on a different domain, such as certain single page applications, mobile apps, and desktop apps. Bearer tokens are sent in the authorization header, prefixed with `Bearer`. - -```http -Authorization: Bearer -``` - -Some methods shown in this page is included in [`AuthRequest`](/reference/lucia/interfaces/authrequest), which is described in [Handle requests](/basics/handle-requests) page. - -## Validate bearer tokens - -You can use [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) to validate the bearer token. Since [`Auth.validateSession()`](/reference/lucia/interfaces/auth#validatesession) is used, idle sessions will be reset. It returns the validated session or `null` if the session is invalid. - -```ts -const authRequest = auth.handleRequest(); - -const session = await authRequest.validateBearerToken(); -if (session) { - // valid request -} -``` - -CSRF protection is not included when validating bearer tokens. - -### Read bearer tokens - -You can get the session id from the authorization header using [`Auth.readBearerToken()`](/reference/lucia/interfaces/auth#readbearertoken), which returns a session id or `null` if the token does not exist. This _does not_ validate the session id. - -```ts -const authorizationHeader = request.headers.get("Authorization"); -const sessionId = auth.readBearerToken(authorizationHeader); -``` - -### Caching - -`AuthRequest.validateBearerToken()` caches the request, so it will only run once no matter how many times you call it. This is useful when you have multiple pages/components the method can be called. - -```ts -await authRequest.validateBearerToken(); -await authRequest.validateBearerToken(); // uses cache from previous call -``` - -```ts -await Promise([ - authRequest.validateBearerToken(), - authRequest.validateBearerToken() // waits for first call to resolve -]); -``` - -### Invalidation - -After updating user attributes, for example, call [`AuthRequest.invalidate()`](/reference/lucia/interfaces/authrequest#invalidate) to invalidate internal cache so the next time you call `AuthRequest.validateBearerToken()`, it returns the latest user data. - -```ts -await auth.updateUserAttributes(userId, { - username: newUsername -}); -authRequest.invalidate(); - -// returns latest user data -const session = await authRequest.validateBearerToken(); -``` diff --git a/documentation/content/main/basics/using-cookies.md b/documentation/content/main/basics/using-cookies.md deleted file mode 100644 index 57b6c3ad6..000000000 --- a/documentation/content/main/basics/using-cookies.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Using cookies" -description: "Learn how to use session cookies with Lucia" ---- - -Cookies is the preferred way of storing and sending session ids when the frontend and backend is hosted on the same domain. - -Some methods shown in this page is included in [`AuthRequest`](/reference/lucia/interfaces/authrequest), which is described in [Handle requests](/basics/handle-requests) page. - -### Security - -If you're working with cookies, **CSRF protection must be implemented** to prevent [cross site request forgery (CSRF)](https://owasp.org/www-community/attacks/csrf). - -Lucia offers built-in CSRF protection when validating session cookies by checking the `Origin` header. This means all requests that are not GET, OPTIONS, HEAD, or TRACE methods will be rejected by default if they're not a same-origin request (domain and subdomain must match). You can disable this feature or configure its behavior with the [`csrfProtection.allowedSubdomains`](/basics/configuration#csrfprotection) configuration. - -**GET requests are not protected by Lucia and they should not modify server state (e.g. update password and profile) without additional protections.** - -### Cookie expiration - -By default, session cookies are set to expire when the session expires. This behavior may not be preferable if you cannot always set cookies after extending sessions expiration. You can set the session cookies to last indefinitely by setting [`sessionCookie.expires`](/basics/configuration#sessioncookie) configuration to `false`. Enabling this will not change the session expiration, but rather only the cookie. - -## Validate session cookies - -[`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate) validates the request origin and the session cookie stored, resetting sessions if they're idle. It returns a valid session, or `null` if the session cookie is invalid or if the request is from an untrusted request origin. - -```ts -import { auth } from "./lucia.js"; - -const authRequest = auth.handleRequest(); -const session = await authRequest.validate(); -if (session) { - // valid request -} -``` - -### Read session cookie - -Alternatively, you can use [`Auth.readSessionCookie()`](/reference/lucia/interfaces/auth#readsessioncookie) to read the session cookie. It takes a [`LuciaRequest`](/reference/lucia/interfaces#luciarequest) and returns the session cookie value if it exists or `null` if it doesn't. This _does not_ validate the session, nor does it validate the request origin. - -```ts -import { auth } from "./lucia.js"; - -const sessionId = auth.readSessionCookie(luciaRequest); -if (sessionId) { - const session = await auth.validateSession(sessionId); // note: `validateSession()` throws an error if session is invalid -} -``` - -### Caching - -`AuthRequest.validate()` caches the request, so it will only run once no matter how many times you call it. The cache is invalidated whenever `AuthRequest.setSession()` is called. This is useful when you have multiple pages/components the method can be called. - -```ts -await authRequest.validate(); -await authRequest.validate(); // uses cache from previous call -``` - -```ts -await Promise([ - authRequest.validate(), - authRequest.validate() // waits for first call to resolve -]); -``` - -### Invalidation - -After updating user attributes, for example, call [`AuthRequest.invalidate()`](/reference/lucia/interfaces/authrequest#invalidate) to invalidate internal cache so the next time you call `AuthRequest.validate()`, it returns the latest user data. - -```ts -await auth.updateUserAttributes(userId, { - username: newUsername -}); -authRequest.invalidate(); - -// returns latest user data -const session = await authRequest.validate(); -``` - -## Set session cookies - -You can set session cookies by passing `Session` to [`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession). You can pass `null` to delete session cookies. - -```ts -import { auth } from "./lucia.js"; - -const authRequest = auth.handleRequest(); -authRequest.setSession(session); -authRequest.setSession(null); // delete session cookie -``` - -This is disabled and will throw an error when using [`web()`](/reference/lucia/modules/middleware#web) and some configuration of [`nextjs_future()`](/reference/lucia/modules/middleware#nextjs) middleware. If you're using them, set session cookies manually as described below. - -### Create session cookies - -You can create a new [`Cookie`](/reference/lucia/interfaces#cookie) with [`Auth.createSessionCookie()`](/reference/lucia/interfaces/auth#createsessioncookie), which takes in a `Session`. `Cookie.serialize()` can be used to generate a new `Set-Cookie` response headers value. You can also access the cookie name, value, and attributes (such a `httpOnly` and `maxAge`). - -```ts -import { auth } from "./lucia.js"; - -const sessionCookie = auth.createSessionCookie(session); - -setResponseHeaders("Set-Cookie", sessionCookie.serialize()); -// alternatively -setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); -``` - -You can pass `null` to create an empty session cookie that when set, will delete the current session cookie. - -```ts -import { auth } from "./lucia.js"; - -const sessionCookie = auth.createSessionCookie(null); -setResponseHeaders("Set-Cookie", sessionCookie.serialize()); -``` - -## Using an external backend - -If your backend is hosted on a different subdomain, requests to it will be considered cross-origin and CORS policies will apply. However you've set up your CORS policy, make sure to set the `credentials` option to `"include"` when making `fetch()` request to send _and_ receive cookies. - -```ts -await fetch("https://api.example.com", { - // ... - credentials: "include" -}); -``` - -We discourage hosting the backend on a separate domain since cookies the client receives will be considered "third party cookies," which are blocked by default in Safari. diff --git a/documentation/content/main/contributing.md b/documentation/content/main/contributing.md deleted file mode 100644 index c1227a08b..000000000 --- a/documentation/content/main/contributing.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: "Contributing" -description: "Learn how to contribute to Lucia" ---- - -Thanks for your interests in taking part in the project! Lucia is maintained by [pilcrowOnPaper](https://github.com/pilcrowOnPaper) and feel free to ask questions on Discord! - -## Forking the project - -### Prerequisites - -This repository requires Node.js version 20 and the latest version of [pnpm](https://pnpm.io). - -### Set up - -After forking the project, set up your local fork by running the following command in the root: - -``` -pnpm i -``` - -## Documentation - -Writing the documentation is the most time consuming part of maintaining Lucia, so this is one of the best way to contribute! The documentation website is built with [Astro](https://astro.build). - -The following types of PRs are generally accepted quickly: - -- Typos -- Fixing small parts of the content -- Adding new content (framework) to an existing Guidebook guide - -Please open a feature request for anything related to the website's functionality. - -### Examples - -This is also another great way to contribute to the library. The example should follow existing ones as close as possible. You can find them at [`lucia-auth/examples`](https://github.com/lucia-auth/examples). - -## Source code - -For anything bigger than a bug fix, please open a new feature request or a RFC in the discussions tab on GitHub first. We appreciate your enthusiasm but we don't want to close it immediately and waste hours of your time! - -Please make the pull request as small as possible, and break them into smaller ones if possible. - -### Changesets - -Whenever you make a change to the source code, create a changeset by running the following in the project root: - -``` -pnpm auri add -``` - -This will create an empty changeset file in `.auri` directory. Fill in the blanks, for example: - -```md ---- -package: "@lucia-auth/oauth" -type: "minor" ---- - -Adds X to Y -``` - -- `package`: The _full_ NPM package name (e.g. `oauth` but `@lucia-auth/oauth`) -- `type`: Change type - can be one of 3 values: - - `major`: Breaking change - - `minor`: New backward compatible feature (e.g. new configuration, adapters, oauth providers) - - `patch`: Bug and type fixes -- Content: A small description of the change - do _not_ add PR and issue numbers - -If you've added multiple changes and cannot break it into smaller PRs, create multiple changesets. **Do not manually update `CHANGELOG.md` or `package.json`.** - -### OAuth provider - -We are generally lenient on what providers we accept. However, please open a new feature request or start a new discussion on Discord or GitHub if you're unsure (e.g. worried if the provider is too niche). - -### Database adapters - -Please open a new feature request or start a new discussion on Discord or GitHub before creating an official adapter. Adapters must be tested with the testing package and must pass all tests for it to be accepted. - -Keep in mind that it may be more appropriate to provide the adapter from an existing adapter package instead of creating a new one. - -#### Naming convention - -Regular adapter packages should be named `@lucia-auth/adapter-X`, while session adapters should be named `@lucia-auth/adapter-session-X`. - -## Style guide - -- **Use TypeScript** -- `camelCase` -- Use arrow functions -- Use `const` -- `null` over `undefined` -- `await` over callbacks -- `type` over `interface` -- Explicitly define `public`, `protected`, and `private` for class methods -- Explicitly define return types -- Generics should be prefixed with `_` -- Timestamps should be represented with `Date` or in milliseconds with `number` -- Paths in import statements must have their extensions defined -- Use `import type { X }` instead of `import { type X }` -- No default exports -- No more than 3 parameters in a function - -In addition, unit tests should be written for critical components. - -### Recommendation - -- Prefer using `Array.at()` instead of `Array[]` -- Avoid using optional chaining `a?.b` inside `if` blocks -- Do not over use generics -- Functions that return booleans should be start with `is`, `has`, `includes`, etc - - This means `boolean` variables should be past tense (e.g. `passwordDefined` instead of `isPasswordDefined`) -- Keep dependencies to a minimum diff --git a/documentation/content/main/database-adapters/better-sqlite3.md b/documentation/content/main/database-adapters/better-sqlite3.md deleted file mode 100644 index e11fcff6b..000000000 --- a/documentation/content/main/database-adapters/better-sqlite3.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: "`better-sqlite3` adapter" -description: "Learn how to use better-sqlite3 with Lucia" ---- - -Adapter for [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3) provided by the SQLite adapter package. - -```ts -import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; -``` - -```ts -const betterSqlite3: ( - db: Database, - tableNames: { - user: string; - key: string; - session: string | null; - } -) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| -------------------- | ---------------- | ------------------------------------------------------------------------- | -| `db` | `Database` | `better-sqlite3` database instance | -| `tableNames.user` | `string` | User table name | -| `tableNames.key` | `string` | Key table name | -| `tableNames.session` | `string \| null` | Session table name - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-sqlite -pnpm add @lucia-auth/adapter-sqlite -yarn add @lucia-auth/adapter-sqlite -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; -import sqlite from "better-sqlite3"; - -const db = sqlite("main.db"); - -const auth = lucia({ - adapter: betterSqlite3(db, { - user: "user", - key: "user_key", - session: "user_session" - }) - // ... -}); -``` - -## SQLite3 schema - -You can choose any table names, just make sure to define them in the adapter argument. **The `id` columns are not UUID types with the default configuration.** - -### User table - -You can add additional columns to store user attributes. - -```sql -CREATE TABLE user ( - id TEXT NOT NULL PRIMARY KEY -); -``` - -### Key table - -Make sure to update the `REFERENCES` if you change the user table name. - -```sql -CREATE TABLE user_key ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL, - hashed_password TEXT, - FOREIGN KEY (user_id) REFERENCES user(id) -); -``` - -### Session table - -You can add additional columns to store session attributes. Make sure to update `REFERENCES` if you change the user table name. - -```sql -CREATE TABLE user_session ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL, - active_expires INTEGER NOT NULL, - idle_expires INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES user(id) -); -``` diff --git a/documentation/content/main/database-adapters/cloudflare-d1.md b/documentation/content/main/database-adapters/cloudflare-d1.md deleted file mode 100644 index 98aa9dfb4..000000000 --- a/documentation/content/main/database-adapters/cloudflare-d1.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: "Cloudflare D1 adapter" -description: "Learn how to use Cloudflare D1 with Lucia" ---- - -Adapter for [Cloudflare D1](https://developers.cloudflare.com/d1) provided by the SQLite adapter package. - -```ts -import { d1 } from "@lucia-auth/adapter-sqlite"; -``` - -```ts -const d1: ( - database: D1Database, - tableNames: { - user: string; - key: string; - session: string | null; - } -) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| -------------------- | ---------------- | ------------------------------------------------------------------------- | -| `database` | `D1Database` | Cloudflare D1 binding | -| `tableNames.user` | `string` | User table name | -| `tableNames.key` | `string` | Key table name | -| `tableNames.session` | `string \| null` | Session table name - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-sqlite -pnpm add @lucia-auth/adapter-sqlite -yarn add @lucia-auth/adapter-sqlite -``` - -## Usage - -Since the D1 bindings are only available in runtime, you'll need to create a new `Auth` instance on every request. Make sure to update your `Auth` type. - -```ts -import { lucia } from "lucia"; -import { d1 } from "@lucia-auth/adapter-sqlite"; - -export const initializeLucia = (db: D1Database) => { - const auth = lucia({ - adapter: d1(db, { - user: "user", - key: "user_key", - session: "user_session" - }) - // ... - }); - return auth; -}; - -export type Auth = ReturnType; -``` - -Please see the [documentation for Cloudflare Pages](https://developers.cloudflare.com/pages/framework-guides/) for accessing Cloudflare binding in your framework. - -```ts -type Env = { - DB: D1Database; // install `@cloudflare/workers-types` -}; - -export default { - fetch: async (request: Request, env: Env) => { - const auth = initializeLucia(env.DB); - // ... - } -}; -``` - -## SQLite3 schema - -You can choose any table names, just make sure to define them in the adapter argument. **The `id` columns are not UUID types with the default configuration.** - -### User table - -You can add additional columns to store user attributes. - -```sql -CREATE TABLE user ( - id VARCHAR(15) NOT NULL PRIMARY KEY -); -``` - -### Key table - -Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_key ( - id VARCHAR(255) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - hashed_password VARCHAR(255), - FOREIGN KEY (user_id) REFERENCES user(id) -); -``` - -### Session table - -You can add additional columns to store session attributes. Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_session ( - id VARCHAR(127) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - active_expires BIGINT NOT NULL, - idle_expires BIGINT NOT NULL, - FOREIGN KEY (user_id) REFERENCES user(id) -); -``` diff --git a/documentation/content/main/database-adapters/ioredis.md b/documentation/content/main/database-adapters/ioredis.md deleted file mode 100644 index 4976bf7d4..000000000 --- a/documentation/content/main/database-adapters/ioredis.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: "`ioredis` session adapter" -description: "Learn how to use `ioredis` with Lucia" ---- - -Session adapter for [`ioredis`](https://github.com/redis/ioredis) provided by the Redis session adapter package. This only handles sessions, and not users or keys. - -```ts -import { ioredis } from "@lucia-auth/adapter-session-redis"; -``` - -```ts -const ioredis: ( - client: Redis, - prefixes?: { - session: string; - userSessions: string; - } -) => InitializeAdapter; -``` - -##### Parameters - -| name | type | optional | description | -| ---------- | ------------------------ | :------: | ---------------- | -| `client` | `Redis` | | `ioredis` client | -| `prefixes` | `Record` | ✓ | Key prefixes | - -## Installation - -``` -npm i @lucia-auth/adapter-session-redis -pnpm add @lucia-auth/adapter-session-redis -yarn add @lucia-auth/adapter-session-redis -``` - -### Key prefixes - -Key are defined as a combination of a prefix and an id so everything can be stored in a single Redis instance. By default, sessions are stored as `session:` and user-sessions relationships are stored as `user_sessions:`. - -## Usage - -```ts -import { lucia } from "lucia"; -import { ioredis } from "@lucia-auth/adapter-session-redis"; -import { Redis } from "ioredis"; - -const redisClient = new Redis(/* … */); - -const auth = lucia({ - adapter: { - user: userAdapter, // any normal adapter for storing users/keys - session: ioredis(redisClient) - } - // ... -}); -``` diff --git a/documentation/content/main/database-adapters/libsql.md b/documentation/content/main/database-adapters/libsql.md deleted file mode 100644 index 315d04f6a..000000000 --- a/documentation/content/main/database-adapters/libsql.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: "libSQL adapter" -description: "Learn how to use libSQL with Lucia" ---- - -Adapter for [libSQL](https://github.com/libsql/libsql) provided by the SQLite adapter package. - -```ts -import { libSQL } from "@lucia-auth/adapter-sqlite"; -``` - -```ts -const libSQL: ( - client: Client, - tableNames: { - user: string; - key: string; - session: string | null; - } -) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| -------------------- | ---------------- | ------------------------------------------------------------------------- | -| `client` | `Client` | Database client | -| `tableNames.user` | `string` | User table name | -| `tableNames.key` | `string` | Key table name | -| `tableNames.session` | `string \| null` | Session table name - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-sqlite -pnpm add @lucia-auth/adapter-sqlite -yarn add @lucia-auth/adapter-sqlite -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { libsql } from "@lucia-auth/adapter-sqlite"; -import { createClient } from "@libsql/client"; - -const db = createClient({ - url: "file:test/main.db" -}); - -const auth = lucia({ - adapter: libsql(db, { - user: "user", - key: "user_key", - session: "user_session" - }) - // ... -}); -``` - -## libSQL schema - -You can choose any table names, just make sure to define them in the adapter argument. **The `id` columns are not UUID types with the default configuration.** - -### User table - -You can add additional columns to store user attributes. - -```sql -CREATE TABLE user ( - id VARCHAR(15) NOT NULL PRIMARY KEY -); -``` - -### Key table - -Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_key ( - id VARCHAR(255) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - hashed_password VARCHAR(255), - FOREIGN KEY (user_id) REFERENCES user(id) -); -``` - -### Session table - -You can add additional columns to store session attributes. Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_session ( - id VARCHAR(127) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - active_expires BIGINT NOT NULL, - idle_expires BIGINT NOT NULL, - FOREIGN KEY (user_id) REFERENCES user(id) -); -``` diff --git a/documentation/content/main/database-adapters/mongoose.md b/documentation/content/main/database-adapters/mongoose.md deleted file mode 100644 index 216562279..000000000 --- a/documentation/content/main/database-adapters/mongoose.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: "Mongoose adapter" -description: "Learn how to use Mongoose with Lucia" ---- - -Adapter for [Mongoose](https://github.com/Automattic/mongoose) provided by the Mongoose adapter package. - -```ts -import { mongoose } from "@lucia-auth/adapter-mongoose"; -``` - -```ts -const mongoose: (models: { - User: Model; - Session: Model | null; - Key: Model; -}) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| ---------------- | --------------- | -------------------------------------------------------------------------------------------- | -| `models.User` | `Model` | Mongoose model for user collection | -| `models.Key` | `Model` | Mongoose model for key collection | -| `models.Session` | `Model \| null` | Mongoose model for session collection - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-mongoose -pnpm add @lucia-auth/adapter-mongoose -yarn add @lucia-auth/adapter-mongoose -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { mongoose } from "@lucia-auth/adapter-mongoose"; -import mongodb from "mongoose"; - -// see next section for schema -const User = mongodb.model(); -const Key = mongodb.model(); -const Session = mongodb.model(); - -const auth = lucia({ - adapter: mongoose({ - User, - Key, - Session - }) - // ... -}); - -// handle connection -mongodb.connect(mongoUri, options); -``` - -## Mongoose models - -You can choose any model names. - -### User collection - -You can add additional fields to store user attributes. - -```ts -import mongodb from "mongoose"; - -const User = mongodb.model( - "User", - new mongodb.Schema( - { - _id: { - type: String, - required: true - } - } as const, - { _id: false } - ) -); -``` - -### Key collection - -```ts -import mongodb from "mongoose"; - -const Key = mongodb.model( - "Key", - new mongodb.Schema( - { - _id: { - type: String, - required: true - }, - user_id: { - type: String, - required: true - }, - hashed_password: String - } as const, - { _id: false } - ) -); -``` - -### Session collection - -You can add additional fields to store session attributes. - -```ts -import mongodb from "mongoose"; - -const Session = mongodb.model( - "Session", - new mongodb.Schema( - { - _id: { - type: String, - required: true - }, - user_id: { - type: String, - required: true - }, - active_expires: { - type: Number, - required: true - }, - idle_expires: { - type: Number, - required: true - } - } as const, - { _id: false } - ) -); -``` diff --git a/documentation/content/main/database-adapters/mysql2.md b/documentation/content/main/database-adapters/mysql2.md deleted file mode 100644 index 54b148441..000000000 --- a/documentation/content/main/database-adapters/mysql2.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: "`mysql2` adapter" -description: "Learn how to use mysql2 with Lucia" ---- - -Adapter for [`mysql2`](https://github.com/sidorares/node-mysql2) provided by the MySQL adapter package. - -```ts -import { mysql2 } from "@lucia-auth/adapter-mysql"; -``` - -```ts -const mysql2: ( - pool: Pool, - tableNames: { - user: string; - key: string; - session: string | null; - } -) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| -------------------- | ---------------- | ------------------------------------------------------------------------- | -| `pool` | `Pool` | `mysql2` connection pool | -| `tableNames.user` | `string` | User table name | -| `tableNames.key` | `string` | Key table name | -| `tableNames.session` | `string \| null` | Session table name - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-mysql -pnpm add @lucia-auth/adapter-mysql -yarn add @lucia-auth/adapter-mysql -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { mysql2 } from "@lucia-auth/adapter-mysql"; -import mysql from "mysql2/promise"; - -const connectionPool = mysql.createPool({ - // ... -}); - -const auth = lucia({ - adapter: mysql2(connectionPool, { - user: "auth_user", - key: "user_key", - session: "user_session" - }) - // ... -}); -``` - -## MySQL schema - -You can choose any table names, just make sure to define them in the adapter argument. **The `id` columns are not UUID types with the default configuration.** - -### User table - -You can add additional columns to store user attributes. - -```sql -CREATE TABLE auth_user ( - id VARCHAR(15) NOT NULL PRIMARY KEY -); -``` - -### Key table - -Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_key ( - id VARCHAR(255) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - hashed_password VARCHAR(255), - FOREIGN KEY (user_id) REFERENCES auth_user(id) -); -``` - -### Session table - -You can add additional columns to store session attributes. Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_session ( - id VARCHAR(127) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - active_expires BIGINT UNSIGNED NOT NULL, - idle_expires BIGINT UNSIGNED NOT NULL, - FOREIGN KEY (user_id) REFERENCES auth_user(id) -); -``` diff --git a/documentation/content/main/database-adapters/pg.md b/documentation/content/main/database-adapters/pg.md deleted file mode 100644 index b0cd6c82d..000000000 --- a/documentation/content/main/database-adapters/pg.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: "`pg` adapter" -description: "Learn how to use pg with Lucia" ---- - -Adapter for [`pg`](https://github.com/brianc/node-postgres) provided by the PostgreSQL adapter package. This adapter can be used for `@vercel/postgres` and `@neondatabase/serverless` as well. See guide [Using `@vercel/postgres`](/guidebook/vercel-postgres). - -```ts -import { pg } from "@lucia-auth/adapter-postgresql"; -``` - -```ts -const pg: ( - pool: Pool, - tableNames: { - user: string; - key: string; - session: string | null; - } -) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| -------------------- | ---------------- | ------------------------------------------------------------------------- | -| `pool` | `Pool` | `pg` connection pool | -| `tableNames.user` | `string` | User table name | -| `tableNames.key` | `string` | Key table name | -| `tableNames.session` | `string \| null` | Session table name - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-postgresql -pnpm add @lucia-auth/adapter-postgresql -yarn add @lucia-auth/adapter-postgresql -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { pg } from "@lucia-auth/adapter-postgresql"; -import postgres from "pg"; - -const pool = new postgres.Pool({ - connectionString: CONNECTION_URL -}); - -const auth = lucia({ - adapter: pg(pool, { - user: "auth_user", - key: "user_key", - session: "user_session" - }) - // ... -}); -``` - -## PostgreSQL schema - -You can choose any table names, just make sure to define them in the adapter argument. **The `id` columns are not UUID types with the default configuration.** - -### User table - -You can add additional columns to store user attributes. - -```sql -CREATE TABLE auth_user ( - id TEXT NOT NULL PRIMARY KEY -); -``` - -### Key table - -Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_key ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES auth_user(id), - hashed_password TEXT -); -``` - -### Session table - -You can add additional columns to store session attributes. Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_session ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES auth_user(id), - active_expires BIGINT NOT NULL, - idle_expires BIGINT NOT NULL -); -``` diff --git a/documentation/content/main/database-adapters/planetscale-serverless.md b/documentation/content/main/database-adapters/planetscale-serverless.md deleted file mode 100644 index 2fa631856..000000000 --- a/documentation/content/main/database-adapters/planetscale-serverless.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: "PlanetScale serverless adapter" -description: "Learn how to use the PlanetScale serverless driver with Lucia" ---- - -Adapter for [PlanetScale serverless driver](https://github.com/planetscale/database-js) provided by the MySQL adapter package. - -```ts -import { planetscale } from "@lucia-auth/adapter-mysql"; -``` - -```ts -const planetscale: ( - connection: Connection, - tableNames: { - user: string; - key: string; - session: string | nul; - } -) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| -------------------- | ---------------- | ------------------------------------------------------------------------- | -| `connection` | `Connection` | PlanetScale serverless driver connection | -| `tableNames.user` | `string` | User table name | -| `tableNames.key` | `string` | Key table name | -| `tableNames.session` | `string \| null` | Session table name - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-mysql -pnpm add @lucia-auth/adapter-mysql -yarn add @lucia-auth/adapter-mysql -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { planetscale } from "@lucia-auth/adapter-mysql"; -import { connect } from "@planetscale/database"; - -const connection = connect({ - host: "", - username: "", - password: "" -}); - -const auth = lucia({ - adapter: planetscale(connection, { - user: "auth_user", - key: "user_key", - session: "user_session" - }) - // ... -}); -``` - -## MySQL schema - -You can choose any table names, just make sure to define them in the adapter argument. **The `id` columns are not UUID types with the default configuration.** - -### User table - -You can add additional columns to store user attributes. - -```sql -CREATE TABLE auth_user ( - id VARCHAR(15) NOT NULL PRIMARY KEY, -); -``` - -### Key table - -```sql -CREATE TABLE user_key ( - id VARCHAR(255) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - hashed_password VARCHAR(255), -); -``` - -### Session table - -You can add additional columns to store session attributes. - -```sql -CREATE TABLE user_session ( - id VARCHAR(127) NOT NULL PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - active_expires BIGINT UNSIGNED NOT NULL, - idle_expires BIGINT UNSIGNED NOT NULL, -); -``` diff --git a/documentation/content/main/database-adapters/postgres.md b/documentation/content/main/database-adapters/postgres.md deleted file mode 100644 index 232f37622..000000000 --- a/documentation/content/main/database-adapters/postgres.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: "`postgres` adapter" -description: "Learn how to use postgres with Lucia" ---- - -Adapter for [`postgres`](https://github.com/porsager/postgres) provided by the PostgreSQL adapter package. - -```ts -import { postgres } from "@lucia-auth/adapter-postgresql"; -``` - -```ts -const postgres: ( - sql: Sql, - tableNames: { - user: string; - key: string; - session: string | null; - } -) => InitializeAdapter; -``` - -##### Parameters - -Table names are automatically escaped. - -| name | type | description | -| -------------------- | ---------------- | ------------------------------------------------------------------------- | -| `sql` | `Sql` | `postgres` helper | -| `tableNames.user` | `string` | User table name | -| `tableNames.key` | `string` | Key table name | -| `tableNames.session` | `string \| null` | Session table name - can be `null` when using alongside a session adapter | - -## Installation - -``` -npm i @lucia-auth/adapter-postgresql -pnpm add @lucia-auth/adapter-postgresql -yarn add @lucia-auth/adapter-postgresql -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { postgres as postgresAdapter } from "@lucia-auth/adapter-postgresql"; -import postgres from "postgres"; - -const sql = postgres(CONNECTION_URL); - -const auth = lucia({ - adapter: postgresAdapter(sql, { - user: "auth_user", - key: "user_key", - session: "user_session" - }) - // ... -}); -``` - -## PostgreSQL schema - -You can choose any table names, just make sure to define them in the adapter argument. **The `id` columns are not UUID types with the default configuration.** - -### User table - -You can add additional columns to store user attributes. - -```sql -CREATE TABLE auth_user ( - id TEXT PRIMARY KEY -); -``` - -### Key table - -Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_key ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES auth_user(id), - hashed_password TEXT -); -``` - -### Session table - -You can add additional columns to store session attributes. Make sure to update the foreign key statement if you change the user table name. - -```sql -CREATE TABLE user_session ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES auth_user(id), - active_expires BIGINT NOT NULL, - idle_expires BIGINT NOT NULL -); -``` diff --git a/documentation/content/main/database-adapters/prisma.md b/documentation/content/main/database-adapters/prisma.md deleted file mode 100644 index 812a824c2..000000000 --- a/documentation/content/main/database-adapters/prisma.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: "Prisma adapter" -description: "Learn how to use Prisma with Lucia" ---- - -Adapter for [Prisma](https://www.prisma.io) provided by the Prisma adapter package. There are 2 ways to initialize it. - -```ts -import { prisma } from "@lucia-auth/adapter-prisma"; -``` - -```ts -const prisma: ( - client: PrismaClient, - modelNames?: { - user: string; - key: string; - session: string | null; - } -) => InitializeAdapter; -``` - -##### Parameters - -| name | type | description | optional | -| -------------------- | ---------------- | ---------------------------------------------------- | :------: | -| `client` | `PrismaClient` | The Prisma client | | -| `modelNames` | | | ✓ | -| `modelNames.user` | `string` | | | -| `modelNames.key` | `string` | | | -| `modelNames.session` | `string \| null` | Can be `null` when using alongside a session adapter | | - -The values for the `modelNames` params is the `camelCase` version of your `PascalCase` model names defined in your schema (sounds confusing but the TS auto-complete should help you). When it's undefined, the adapter uses predefined model names (see below). - -## Installation - -``` -npm i @lucia-auth/adapter-prisma -pnpm add @lucia-auth/adapter-prisma -yarn add @lucia-auth/adapter-prisma -``` - -## Usage - -```ts -import { lucia } from "lucia"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - adapter: prisma(client) - // ... -}); - -// default values -const auth = lucia({ - adapter: prisma(client, { - user: "user", // model User {} - key: "key", // model Key {} - session: "session" // model Session {} - }) - // ... -}); -``` - -### In non-Node.js environment - -To use Prisma in an environment that doesn't support Node.js (including Deno, Cloudflare Workers, Vercel Edge), import `PrismaClient` from `@prisma/client/edge` instead of `@prisma/client`. - -```ts -import { PrismaClient } from "@prisma/client/edge"; -``` - -## Prisma schema - -You can add additional columns to the user model to store user attributes, and to the session model to store session attributes. If you change the model names, pass the new names to the adapter config. - -**The `id` fields are not UUID types with the default configuration.** - -```prisma -model User { - id String @id @unique - - auth_session Session[] - key Key[] -} - -model Session { - id String @id @unique - user_id String - active_expires BigInt - idle_expires BigInt - user User @relation(references: [id], fields: [user_id], onDelete: Cascade) - - @@index([user_id]) -} - -model Key { - id String @id @unique - hashed_password String? - user_id String - user User @relation(references: [id], fields: [user_id], onDelete: Cascade) - - @@index([user_id]) -} -``` diff --git a/documentation/content/main/database-adapters/redis.md b/documentation/content/main/database-adapters/redis.md deleted file mode 100644 index 8820a5612..000000000 --- a/documentation/content/main/database-adapters/redis.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Redis session adapter" -description: "Learn how to use Redis with Lucia" ---- - -Session adapter for [Redis](https://redis.io) provided by the Redis session adapter package. This only handles sessions, and not users or keys. - -```ts -import { redis } from "@lucia-auth/adapter-session-redis"; -``` - -```ts -const redis: ( - client: RedisClientType, - prefixes?: { - session: string; - userSessions: string; - } -) => InitializeAdapter; -``` - -##### Parameters - -| name | type | optional | description | -| ---------- | ------------------------ | :------: | ------------ | -| `client` | `RedisClientType` | | Redis client | -| `prefixes` | `Record` | ✓ | Key prefixes | - -## Installation - -``` -npm i @lucia-auth/adapter-session-redis -pnpm add @lucia-auth/adapter-session-redis -yarn add @lucia-auth/adapter-session-redis -``` - -### Key prefixes - -Key are defined as a combination of a prefix and an id so everything can be stored in a single Redis instance. By default, sessions are stored as `session:` and user-sessions relationships are stored as `user_sessions:`. - -## Usage - -```ts -import { lucia } from "lucia"; -import { redis } from "@lucia-auth/adapter-session-redis"; -import { createClient } from "redis"; - -const redisClient = createClient({ - // ... -}); - -const auth = lucia({ - adapter: { - user: userAdapter, // any normal adapter for storing users/keys - session: redis(redisClient) - } - // ... -}); -``` diff --git a/documentation/content/main/database-adapters/unstorage.md b/documentation/content/main/database-adapters/unstorage.md deleted file mode 100644 index e7e7a331f..000000000 --- a/documentation/content/main/database-adapters/unstorage.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: "Unstorage session adapter" -description: "Learn how to use Unstorage with Lucia" ---- - -Session adapter for [Unstorage](https://github.com/unjs/unstorage). This only handles sessions, and not users or keys. Supports many key-value databases, including Azure, Cloudflare KV, MongoDB, Planetscale, Redis, and Vercel KV, as well as in-memory. - -```ts -import { unstorage } from "@lucia-auth/adapter-session-unstorage"; -``` - -```ts -const unstorage: ( - storage: Storage, - prefixes?: { - session: string; - userSession: string; - } -) => InitializeAdapter; -``` - -##### Parameters - -| name | type | optional | description | -| ---------- | ------------------------ | :------: | ------------ | -| `storage` | `Storage` | | | -| `prefixes` | `Record` | ✓ | Key prefixes | - -## Installation - -``` -npm i @lucia-auth/adapter-session-unstorage -pnpm add @lucia-auth/adapter-session-unstorage -yarn add @lucia-auth/adapter-session-unstorage -``` - -### Key prefixes - -Key are defined as a combination of a prefix and an id so everything can be stored in a single storage instance. By default, sessions are stored as `session:` and user-sessions relationships are stored as `user_sessions:`. - -## Usage - -```ts -import { lucia } from "lucia"; -import { unstorage } from "@lucia-auth/adapter-session-unstorage"; -import { createStorage } from "unstorage"; - -const storage = createStorage(); - -const auth = lucia({ - adapter: { - user: userAdapter, // any normal adapter for storing users/keys - session: unstorage(storage) - } - // ... -}); -``` diff --git a/documentation/content/main/database-adapters/upstash-redis.md b/documentation/content/main/database-adapters/upstash-redis.md deleted file mode 100644 index 298caf875..000000000 --- a/documentation/content/main/database-adapters/upstash-redis.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Upstash Redis adapter" -description: "Learn how to use Upstash Redis with Lucia" ---- - -Session adapter for [Upstash Redis](https://upstash.com) provided by the Redis session adapter package. This only handles sessions, and not users or keys. - -```ts -import { upstash } from "@lucia-auth/adapter-session-redis"; -``` - -```ts -const upstash: ( - upstashClient: Redis, - prefixes?: { - session: string; - userSessions: string; - } -) => InitializeAdapter; -``` - -##### Parameters - -| name | type | optional | description | -| --------------- | ------------------------ | :------: | ------------------------------------ | -| `upstashClient` | `Redis` | | Serverless redis client for upstash. | -| `prefixes` | `Record` | ✓ | Key prefixes | - -### Key prefixes - -Key are defined as a combination of a prefix and an id so everything can be stored in a single Redis instance. By default, sessions are stored as `session:` and user-sessions relationships are stored as `user_sessions:`. - -## Usage - -```ts -import { lucia } from "lucia"; -import { upstash } from "@lucia-auth/adapter-session-redis"; -import { Redis } from "@upstash/redis"; - -const upstashClient = new Redis({ - // ... -}); - -const auth = lucia({ - adapter: { - user: userAdapter, // any normal adapter for storing users/keys - session: upstash(upstashClient) - } - // ... -}); -``` diff --git a/documentation/content/main/getting-started/$astro.md b/documentation/content/main/getting-started/$astro.md deleted file mode 100644 index 2db6cf0a4..000000000 --- a/documentation/content/main/getting-started/$astro.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -title: "Getting started in Astro" -description: "Learn how to set up Lucia in your Astro project" ---- - -We recommend using Astro v2.6+. Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own module (file). Export `auth` and its type as `Auth`. Make sure to pass the `astro()` middleware. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that in the next section. - -```ts -// src/lib/lucia.ts -import { lucia } from "lucia"; -import { astro } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: import.meta.env.DEV ? "DEV" : "PROD", - middleware: astro() -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -import { lucia } from "lucia"; -import { astro } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: import.meta.env.DEV ? "DEV" : "PROD", - middleware: astro(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -In your `src/env.d.ts` file, declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// src/env.d.ts -/// -declare namespace Lucia { - type Auth = import("./lib/lucia").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Set up middleware - -This is optional but highly recommended. Create a new middleware that stores [`Auth`](/reference/lucia/interfaces/authrequest) to `locals.auth`. - -```ts -// src/middleware.ts -import { auth } from "$lib/server/lucia"; - -import type { MiddlewareResponseHandler } from "astro"; - -export const onRequest: MiddlewareResponseHandler = async (context, next) => { - context.locals.auth = auth.handleRequest(context); - return await next(); -}; -``` - -Make sure to type `Locals` as well: - -```ts -// src/env.d.ts -/// -declare namespace Lucia { - // ... -} - -/// -declare namespace App { - interface Locals { - auth: import("lucia").AuthRequest; - } -} -``` - -This allows us to share and access the same `AuthRequest` instance across multiple load times, which [results in better load times when validating requests](/basics/using-cookies#caching). - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! - -## Limitations - -### Cloudflare - -Please note that password hashing will not work on Free Bundled Workers; **the allocated 10ms CPU time is not sufficient for this**. Consider using unbound workers or paid bundled workers for hashing operations. This is not an issue when using OAuth. diff --git a/documentation/content/main/getting-started/$elysia.md b/documentation/content/main/getting-started/$elysia.md deleted file mode 100644 index 56248f874..000000000 --- a/documentation/content/main/getting-started/$elysia.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: "Getting started in Elysia" -description: "Learn how to set up Lucia in your Elysia project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it. Export `auth` and its type as `Auth`. Make sure to pass the `elysia()` middleware We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that later. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { elysia } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: elysia() -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -import { lucia } from "lucia"; -import { elysia } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: elysia(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a `.d.ts` file in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! - -## Limitations - -### Cloudflare - -Please note that password hashing will not work on Free Bundled Workers; **the allocated 10ms CPU time is not sufficient for this**. Consider using unbound workers or paid bundled workers for hashing operations. This is not an issue when using OAuth. diff --git a/documentation/content/main/getting-started/$express.md b/documentation/content/main/getting-started/$express.md deleted file mode 100644 index e7cbf312d..000000000 --- a/documentation/content/main/getting-started/$express.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -title: "Getting started in Express" -description: "Learn how to set up Lucia in your Express project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -### ESM - -Lucia can only be used in ESM projects. Configure your `package.json` and `tsconfig.json` accordingly. - -```json -// package.json -{ - "type": "module" - // ... -} -``` - -```json -// tsconfig.json -{ - "compilerOptions": { - "module": "ESNext", // "ES2022" etc - "moduleResolution": "NodeNext" // "Node", "Node16" - // ... - } - // ... -} -``` - -Using ESM also requires you to adjust your start scripts. - -#### Node.js (`ts-node`) - -Requires [`ts-node`](https://github.com/TypeStrong/ts-node). - -``` -node --loader ts-node/esm index.ts -``` - -#### `nodemon` - -Requires [`ts-node`](https://github.com/TypeStrong/ts-node). - -``` -nodemon --watch '**/*.ts' --exec 'node --loader ts-node/esm' index.ts -``` - -#### `tsx` - -[`tsx`](https://github.com/esbuild-kit/tsx) supports ESM out of the box. - -``` -tsx index.ts -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it. Export `auth` and its type as `Auth`. Make sure to pass the `express()` middleware We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that later. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { express } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: express() -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -import { lucia } from "lucia"; -import { express } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: express(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a `.d.ts` file in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `node`. - -``` -node --experimental-global-webcrypto index.js -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! diff --git a/documentation/content/main/getting-started/$fastify.md b/documentation/content/main/getting-started/$fastify.md deleted file mode 100644 index fcf5eb973..000000000 --- a/documentation/content/main/getting-started/$fastify.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -title: "Getting started in Fastify" -description: "Learn how to set up Lucia in your Fastify project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -### ESM - -Lucia can only be used in ESM projects. Configure your `package.json` and `tsconfig.json` accordingly. - -```json -// package.json -{ - "type": "module" - // ... -} -``` - -```json -// tsconfig.json -{ - "compilerOptions": { - "module": "ESNext", // "ES2022" etc - "moduleResolution": "NodeNext" // "Node", "Node16" - // ... - } - // ... -} -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it. Export `auth` and its type as `Auth`. Make sure to pass the `fastify()` middleware We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that later. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { fastify } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: fastify() -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -import { lucia } from "lucia"; -import { fastify } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: fastify(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a `.d.ts` file in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). - -```ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `fastify`. - -```json -{ - "scripts": { - "start": "NODE_OPTIONS=--experimental-global-webcrypto fastify start -l info app.js", - "dev": "NODE_OPTIONS=--experimental-global-webcrypto npm run build && fastify start -w -l info -P app.js" - // ... - } - // ... -} -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! diff --git a/documentation/content/main/getting-started/$hono.md b/documentation/content/main/getting-started/$hono.md deleted file mode 100644 index c036682d8..000000000 --- a/documentation/content/main/getting-started/$hono.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: "Getting started in Hono" -description: "Learn how to set up Lucia in your Hono project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it. Export `auth` and its type as `Auth`. Make sure to pass the `hono()` middleware We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that later. - -```ts -// lucia.ts -import { lucia } from "lucia"; -import { hono } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: hono() -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -import { lucia } from "lucia"; -import { hono } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: hono(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a `.d.ts` file in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `node`. - -``` -node --experimental-global-webcrypto index.js -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! - -## Limitations - -### Cloudflare - -Please note that password hashing will not work on Free Bundled Workers; **the allocated 10ms CPU time is not sufficient for this**. Consider using unbound workers or paid bundled workers for hashing operations. This is not an issue when using OAuth. diff --git a/documentation/content/main/getting-started/$nextjs-app.md b/documentation/content/main/getting-started/$nextjs-app.md deleted file mode 100644 index b2df9a53e..000000000 --- a/documentation/content/main/getting-started/$nextjs-app.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: "Getting started in Next.js App Router" -description: "Learn how to set up Lucia in your Next.js App Router project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own module (file). Export `auth` and its type as `Auth`. **Make sure to pass the `nextjs_future()` middleware, and NOT `nextjs()` (will be removed in the future)**. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that in the next section. - -Make sure to set [`sessionCookie.expires`](/basics/configuration#sessioncookie) to `false`. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: nextjs_future(), // NOT nextjs() - sessionCookie: { - expires: false - } -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: nextjs_future(), - sessionCookie: { - expires: false - }, - - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a TS declaration file (`app.d.ts`) in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./auth/lucia").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `next`. - -```json -// package.json -{ - // ... - "scripts": { - "dev": "NODE_OPTIONS=--experimental-global-webcrypto next dev", - "start": "NODE_OPTIONS=--experimental-global-webcrypto next start" - // ... - } - // ... -} -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! diff --git a/documentation/content/main/getting-started/$nextjs-pages.md b/documentation/content/main/getting-started/$nextjs-pages.md deleted file mode 100644 index 6ea711810..000000000 --- a/documentation/content/main/getting-started/$nextjs-pages.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Getting started in Next.js Pages Router" -description: "Learn how to set up Lucia in your Next.js Pages Router project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own module (file). Export `auth` and its type as `Auth`. **Make sure to pass the `nextjs_future()` middleware, and NOT `nextjs()` (will be removed in the future)**. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that in the next section. - -If you're deploying your project the edge runtime, set [`sessionCookie.expires`](/basics/configuration#sessioncookie) to `false`. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: nextjs_future(), // NOT nextjs() - sessionCookie: { - expires: false // only for projects deployed to the edge - } -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import { nextjs_future } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: nextjs_future(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a TS declaration file (`app.d.ts`) in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./auth/lucia").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -// auth/lucia.ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `next`. - -```json -// package.json -{ - // ... - "scripts": { - "dev": "NODE_OPTIONS=--experimental-global-webcrypto next dev", - "start": "NODE_OPTIONS=--experimental-global-webcrypto next start" - // ... - } - // ... -} -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! diff --git a/documentation/content/main/getting-started/$nuxt.md b/documentation/content/main/getting-started/$nuxt.md deleted file mode 100644 index 5f4669b0c..000000000 --- a/documentation/content/main/getting-started/$nuxt.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: "Getting started in Nuxt" -description: "Learn how to set up Lucia in your Nuxt project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own api module (file). Export `auth` and its type as `Auth`. Make sure to pass the `h3()` middleware. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that in the next section. - -```ts -// server/utils/lucia.ts -import { lucia } from "lucia"; -import { h3 } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: process.dev ? "DEV" : "PROD", - middleware: h3() -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -// server/utils/lucia.ts -import { lucia } from "lucia"; -import { h3 } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: h3(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a TS declaration file (`app.d.ts`) in the `server` dir and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// server/app.d.ts -/// -declare namespace Lucia { - type Auth = import("./utils/auth.js").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -This is a side-effect import which is excluded by Nuxt by default. Update your config to escape from the default behavior: - -```ts -// nuxt.config.ts -export default defineNuxtConfig({ - // ... - nitro: { - moduleSideEffects: ["lucia/polyfill/node"] - } -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `nuxt`. - -```json -// package.json -{ - // ... - "scripts": { - "build": "NODE_OPTIONS=--experimental-global-webcrypto nuxt build", - "dev": "NODE_OPTIONS=--experimental-global-webcrypto nuxt dev", - "generate": "NODE_OPTIONS=--experimental-global-webcrypto nuxt generate", - "preview": "NODE_OPTIONS=--experimental-global-webcrypto nuxt preview", - "postinstall": "NODE_OPTIONS=--experimental-global-webcrypto nuxt prepare" - } - // ... -} -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! - -## Limitations - -### Cloudflare - -Please note that password hashing will not work on Free Bundled Workers; **the allocated 10ms CPU time is not sufficient for this**. Consider using unbound workers or paid bundled workers for hashing operations. This is not an issue when using OAuth. diff --git a/documentation/content/main/getting-started/$remix.md b/documentation/content/main/getting-started/$remix.md deleted file mode 100644 index 5a299ebcd..000000000 --- a/documentation/content/main/getting-started/$remix.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: "Getting started in Remix" -description: "Learn how to set up Lucia in your Remix project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Configure Remix project - -Lucia is an ESM package and you must define all modules in `serverDependenciesToBundle`: - -```ts -// remix.config.js - -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - // ... - serverDependenciesToBundle: [ - "lucia", - "lucia/middleware", - "lucia/polyfill/node", - "@lucia-auth/adapter-prisma" // adapter you're using - ] -}; -``` - -Make sure to add your adapter package as well. - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own api module (file). Export `auth` and its type as `Auth`. Make sure to pass the `web()` middleware. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that in the next section. - -Make sure to set [`sessionCookie.expires`](/basics/configuration#sessioncookie) to `false`. - -```ts -// auth/lucia.server.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: process.dev ? "DEV" : "PROD", - middleware: web(), - sessionCookie: { - expires: false - } -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -// auth/lucia.server.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: web(), - sessionCookie: { - expires: false - }, - - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a TS declaration file (`app.d.ts`) in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./auth/lucia.server.js").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `next`. - -```json -// package.json -{ - // ... - "scripts": { - "dev": "NODE_OPTIONS=--experimental-global-webcrypto remix dev", - "start": "NODE_OPTIONS=--experimental-global-webcrypto remix start", - "start": "NODE_OPTIONS=--experimental-global-webcrypto remix-serve build" - // ... - } - // ... -} -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! - -## Limitations - -### Cloudflare - -Please note that password hashing will not work on Free Bundled Workers; **the allocated 10ms CPU time is not sufficient for this**. Consider using unbound workers or paid bundled workers for hashing operations. This is not an issue when using OAuth. diff --git a/documentation/content/main/getting-started/$solidstart.md b/documentation/content/main/getting-started/$solidstart.md deleted file mode 100644 index 40ba8e1e0..000000000 --- a/documentation/content/main/getting-started/$solidstart.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: "Getting started in SolidStart" -description: "Learn how to set up Lucia in your SolidStart project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own module (file). Export `auth` and its type as `Auth`. Make sure to pass the `web()` middleware. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that in the next section. - -Make sure to set [`sessionCookie.expires`](/basics/configuration#sessioncookie) to `false`. - -```ts -// src/auth/lucia.ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -// expect error (see next section) -export const auth = lucia({ - env: process.env.NODE_ENV === "production" ? "PROD" : "DEV", - middleware: web(), - sessionCookie: { - expires: false - } -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: process.env.NODE_ENV === "production" ? "PROD" : "DEV", - middleware: web(), - sessionCookie: { - expires: false - }, - - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -In your `src/app.d.ts` file, declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// src/app.d.ts -/// -declare namespace Lucia { - type Auth = import("./auth/lucia").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -// src/auth/lucia.ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `next`. - -```json -// package.json -{ - // ... - "scripts": { - "dev": "NODE_OPTIONS=--experimental-global-webcrypto solid-start dev", - "build": "NODE_OPTIONS=--experimental-global-webcrypto solid-start build", - "start": "NODE_OPTIONS=--experimental-global-webcrypto solid-start start" - // ... - } - // ... -} -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! diff --git a/documentation/content/main/getting-started/$sveltekit.md b/documentation/content/main/getting-started/$sveltekit.md deleted file mode 100644 index 8f96879b7..000000000 --- a/documentation/content/main/getting-started/$sveltekit.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: "Getting started in SvelteKit" -description: "Learn how to set up Lucia in your SvelteKit project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own module (file). Export `auth` and its type as `Auth`. Make sure to pass the `sveltekit()` middleware. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that in the next section. - -```ts -// src/lib/server/lucia.ts -import { lucia } from "lucia"; -import { sveltekit } from "lucia/middleware"; -import { dev } from "$app/environment"; - -// expect error (see next section) -export const auth = lucia({ - env: dev ? "DEV" : "PROD", - middleware: sveltekit() -}); - -export type Auth = typeof auth; -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -// src/lib/server/lucia.ts -import { lucia } from "lucia"; -import { sveltekit } from "lucia/middleware"; -import { dev } from "$app/environment"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -export const auth = lucia({ - env: dev ? "DEV" : "PROD", - middleware: sveltekit(), - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -In your `src/app.d.ts` file, declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// src/app.d.ts -/// -declare global { - namespace Lucia { - type Auth = import("$lib/server/lucia").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; - } -} - -// THIS IS IMPORTANT!!! -export {}; -``` - -## Set up hooks - -This is optional but highly recommended. Create a new `handle()` hook that stores [`AuthRequest`](/reference/lucia/interfaces/authrequest) to `locals.auth`. - -```ts -// src/hooks.server.ts -import { auth } from "$lib/server/lucia"; -import type { Handle } from "@sveltejs/kit"; - -export const handle: Handle = async ({ event, resolve }) => { - // we can pass `event` because we used the SvelteKit middleware - event.locals.auth = auth.handleRequest(event); - return await resolve(event); -}; -``` - -Make sure to type `Locals` as well: - -```ts -// src/app.d.ts -declare global { - namespace App { - interface Locals { - auth: import("lucia").AuthRequest; - } - } -} -``` - -This allows us to share and access the same `AuthRequest` instance across multiple load times, which [results in better load times when validating requests](/basics/using-cookies#caching). - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! - -## Limitations - -### Cloudflare - -Please note that password hashing will not work on Free Bundled Workers; **the allocated 10ms CPU time is not sufficient for this**. Consider using unbound workers or paid bundled workers for hashing operations. This is not an issue when using OAuth. diff --git a/documentation/content/main/getting-started/index.md b/documentation/content/main/getting-started/index.md deleted file mode 100644 index 4b6b1af88..000000000 --- a/documentation/content/main/getting-started/index.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: "Getting started" -description: "Learn how to set up Lucia in your project" ---- - -Install Lucia using your package manager of your choice. - -``` -npm i lucia -pnpm add lucia -yarn add lucia -``` - -## Initialize Lucia - -Import [`lucia()`](/reference/lucia/modules/main#lucia) from `lucia` and initialize it in its own module (file). Export `auth` and its type as `Auth`. We also need to provide an `adapter` but since it'll be specific to the database you're using, we'll cover that later. - -```ts -// lucia.ts -import { lucia } from "lucia"; - -// expect error (see next section) -export const auth = lucia({ - env: "DEV" // "PROD" if deployed to HTTPS -}); - -export type Auth = typeof auth; -``` - -### Middleware - -[Middleware](/basics/handle-requests) allows Lucia to read the request and response since these are different across frameworks and runtime. See [a full list of middleware](/basics/handle-requests#list-of-middleware). - -#### Node.js - -Use the Node.js middleware if you're using Node.js' `IncomingMessage` and `OutgoingMessage`. - -```ts -import { lucia } from "lucia"; -import { node } from "lucia/middleware"; - -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: node() -}); -``` - -#### Web standard - -Use the web standard middleware if you're using the standard `Request` and `Response`. - -```ts -import { lucia } from "lucia"; -import { web } from "lucia/middleware"; - -export const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - middleware: web(), - sessionCookie: { - expires: false - } -}); -``` - -## Setup your database - -Lucia uses adapters to connect to your database. We provide official adapters for a wide range of database options, but you can always [create your own](/reference/database-adapter). The schema and usage are described in each adapter's documentation. The example below is for the Prisma adapter. - -```ts -import { lucia } from "lucia"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -const auth = lucia({ - env: "DEV", // "PROD" if deployed to HTTPS - adapter: prisma(client) -}); -``` - -### Adapters for database drivers and ORMs - -- [`better-sqlite3`](/database-adapters/better-sqlite3): SQLite -- [libSQL](/database-adapters/libsql): libSQL (Turso) -- [Mongoose](/database-adapters/mongoose): MongoDB -- [`mysql2`](/database-adapters/mysql2): MySQL -- [`pg`](/database-adapters/pg): PostgreSQL (including `@neondatabase/serverless`, `@vercel/postgres`) -- [`postgres`](/database-adapters/postgres): PostgreSQL -- [Prisma](/database-adapters/prisma): MongoDB, MySQL, PostgreSQL, SQLite -- [Redis](/database-adapters/redis): Redis -- [Unstorage](/database-adapters/unstorage): Azure, Cloudflare KV, Memory, MongoDB, Planetscale, Redis, Vercel KV - -### Provider specific adapters - -- [Cloudflare D1](/database-adapters/cloudflare-d1) -- [PlanetScale serverless](/database-adapters/planetscale-serverless) -- [Upstash Redis](/database-adapters/upstash-redis) - -### Using query builders - -- [Drizzle ORM](/guidebook/drizzle-orm) -- [Kysely](/guidebook/kysely) - -## Set up types - -Create a `.d.ts` file in your project root and declare a `Lucia` namespace. The import path for `Auth` is where you initialized `lucia()`. - -```ts -// app.d.ts -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} -``` - -## Polyfill - -If you're using Node.js version 18 or below, you need to polyfill the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). This is not required if you're using runtimes other than Node.js (Deno, Bun, Cloudflare Workers, etc) or using Node.js v20 and above. - -```ts -import { lucia } from "lucia"; -import "lucia/polyfill/node"; - -export const auth = lucia({ - // ... -}); -``` - -Optionally, instead of doing a side-effect import, add the `--experimental-global-webcrypto` flag when running `node`. - -``` -node --experimental-global-webcrypto index.js -``` - -## Next steps - -You can learn all the concepts and general APIs of Lucia by reading the [Basics](/basics/database) section in the docs. If you prefer writing code immediately, check out the [Starter guides](/starter-guides) page or the [examples repository](https://github.com/lucia-auth/examples). - -Remember to check out the [Guidebook](/guidebook) for tutorials and guides! If you have any questions, join our [Discord server](/discord)! - -## Limitations - -### Cloudflare - -Please note that password hashing will not work on Free Bundled Workers; **the allocated 10ms CPU time is not sufficient for this**. Consider using unbound workers or paid bundled workers for hashing operations. This is not an issue when using OAuth. diff --git a/documentation/content/main/migrate/v2/index.md b/documentation/content/main/migrate/v2/index.md deleted file mode 100644 index 1c4746722..000000000 --- a/documentation/content/main/migrate/v2/index.md +++ /dev/null @@ -1,406 +0,0 @@ ---- -title: "Migrate to v2" -description: "Learn how to migrate Lucia version 1 to version 2" ---- - -### Breaking changes - -- **`lucia-auth` is published under `lucia`** (all other packages remain the same) -- **`@lucia-auth/tokens` is not compatible with version 2** (See [Implementing 2FA without the tokens integration (v1/v2)](https://github.com/lucia-auth/lucia/discussions/728)) -- **Removed single use and primary keys** -- **Update `nextjs()` and `web()` middleware** -- **`generateRandomString()` (user and session ids) only uses lowercase letters and numbers by default (no uppercase)** -- Replace session renewal with session resets -- Database tables cannot use default values -- Official adapters no longer enforce table names -- Some items previously exported from `lucia-auth` are now exported from `lucia/utils` -- Updated adapter API to be more simple and future-proof - -### New features - -- Custom session attributes! -- Bearer token support - -## Installation - -Remove `lucia-auth` from your package.json. Install the new version of `lucia`: - -``` -npm i lucia@latest -pnpm add lucia@latest -yarn add lucia@latest -``` - -If you're using the OAuth integration, install the new version of it as well: - -``` -npm i @lucia-auth/oauth@latest -pnpm add @lucia-auth/oauth@latest -yarn add @lucia-auth/oauth@latest -``` - -## Database and adapters - -See each database adapter package's migration guide: - -- [`@lucia-auth/adapter-mongoose`](/migrate/v2/mongoose) -- [`@lucia-auth/adapter-mysql`](/migrate/v2/mysql) -- [`@lucia-auth/adapter-postgresql`](/migrate/v2/postgresql) -- [`@lucia-auth/adapter-prisma`](/migrate/v2/prisma) -- [`@lucia-auth/adapter-session-redis`](/migrate/v2/redis) -- [`@lucia-auth/adapter-sqlite`](/migrate/v2/sqlite) - -## `Lucia` namespace - -```ts -/// -declare namespace Lucia { - type Auth = import("./lucia.js").Auth; // no change - type DatabaseUserAttributes = {}; // formerly `UserAttributes` - type DatabaseSessionAttributes = {}; // new -} -``` - -## Imports - -Lucia core and adapters no longer use default exports. - -```ts -// v1 -import lucia from "lucia-auth"; - -// v2 -import { lucia } from "lucia"; -``` - -You should find and replace all instances of "lucia-auth" (or 'lucia-auth') with "lucia". - -## Initialize Lucia - -The configuration for `lucia()` has been overhauled. See [Configuration](/basics/configuration) for details. - -```ts -// v1 -const auth = lucia({ - adapter: adapter(), - env, - middleware: framework(), - - transformDatabaseUser = (data) => { - return { - userId: data.id, - username: data.username - }; - }, - - autoDatabaseCleanup: false, - csrfProtection: true, - generateCustomUserId: () => generateRandomString(16), - hash, - origin: ["https://foo.example.com"], - sessionCookie: { - sameSite: "strict" - }, - sessionExpiresIn -}); -``` - -```ts -// v2 -const auth = lucia({ - adapter: adapter(), // no change - env, // no change - middleware: framework(), // no change - - // previously `transformDatabaseUser` - getUserAttributes: (data) => { - return { - // IMPORTANT!!!! - // `userId` included by default!! - username: data.username - }; - }, - - // autoDatabaseCleanup: false, <= removed for now - csrfProtection: { - allowedSubdomains: ["foo"] // allow https://foo.example.com - } // can be boolean - // generateCustomUserId, <= removed, see `csrfProtection` - passwordHash, // previously `hash` - // origin, <= removed - sessionCookie: { - name: "user_session", // session cookie name - attributes: { - // moved previous `sessionCookie` value here - sameSite: "strict" - } - }, - sessionExpiresIn // no change -}); -``` - -### Use custom user id - -While `generateCustomUserId()` configuration has been removed, you can now pass a custom user id to [`Auth.createUser()`](/reference/lucia/interfaces/auth#createuser). - -```ts -await auth.createUser({ - userId: generateCustomUserId(), - attributes: {} -}); -``` - -## `generateRandomString()` - -`generateRandomString()` only uses lowercase letters and numbers by default (no uppercase). This applies to user and session ids as well. To use the old id generation, pass a custom alphabet when using `generateRandomString()`: - -```ts -import { generateRandomString } from "lucia/utils"; - -const alphabet = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - -await auth.createUser({ - userId: generateRandomString(15, alphabet) - // ... -}); -``` - -## Creating sessions and keys - -`Auth.createSession()` and `Auth.createKey()` now takes a single parameter. - -```ts -// v1 -await auth.createSession(userId); -await auth.createKey(userId, { - // ... -}); - -// v2 -await auth.createSession({ - userId, - attributes: {} // must be defined! -}); -await auth.createKey({ - userId - // ... -}); -``` - -## Middleware - -With v2, Lucia no longer needs to set new session cookies when validating sessions if `sessionCookie.expires` configuration is set to `false`. - -```ts -lucia({ - sessionCookie: { - expires: false - } -}); -``` - -This should only be enabled when necessary: - -- If you're using `web()` middleware -- Next.js project using the app directory, or deployed to the edge - -### `nextjs()` - -`Auth.handleRequest()` no longer accepts `Response` and `Headers` when using the Next.js middleware. Passing only `IncomingMessage` or `Request` will disable `AuthRequest.setSession()`. We recommend setting cookies manually when creating a new session. - -```ts -// removed -auth.handleRequest({ - req: req as IncomingMessage, - headers: headers as Headers -}); -auth.handleRequest({ - req: req as IncomingMessage, - response: response as Response -}); -auth.handleRequest({ - request: request as Request -}); - -// new - `AuthRequest.setSession()` disabled -auth.handleRequest(req as IncomingMessage); -auth.handleRequest(request as Request); -``` - -`request` must be defined as well: - -```ts -// v1 -auth.handleRequest({ - cookies: cookies as Cookies, - request: request as Request -}); - -// v2 -auth.handleRequest({ - cookies: cookies as Cookies, - request: request as Request | null -}); -``` - -### `web()` - -`Auth.handleRequest()` no longer accepts `Response` and `Headers` when using the web standard middleware. This means `AuthRequest.setSession()` is disabled, and we recommend setting cookies manually. - -```ts -// v1 -auth.handleRequest(request as Request, response as Response); -auth.handleRequest(request as Request, headers as Headers); - -// v2 -auth.handleRequest(request as Request); -``` - -## Validating sessions - -`Auth.validateSessionUser()` and `AuthRequest.validateUser()` has been removed. The User object can now be accessed via `Session.user`. - -```ts -const authRequest = auth.handleRequest(); -const session = await auth.validateSession(); -const session = await authRequest.validate(); - -const user = session.user; -``` - -### Session renewal - -`Auth.renewSession()` has been removed. - -### Reading cookies manually - -`Auth.parseRequestHeaders()` has been removed and replaced with [`Auth.validateRequestOrigin()`](/reference/lucia/interfaces/auth#validaterequestorigin) and [`Auth.readSessionCookie()`](/reference/lucia/interfaces/auth#readsessioncookie). - -```ts -auth.validateRequestOrigin(request as LuciaRequest); // csrf check -const sessionCookie = auth.readSessionCookie(request.headers.cookie); // does NOT handle csrf check - -type LuciaRequest = { - method: string; - url: string; - headers: { - origin: string | null; - cookie: string | null; - authorization: string | null; - }; - storedSessionCookie?: string | null; -}; -``` - -## Default database values - -Lucia no longer supports database default values for database tables. - -```ts -// v1 -await auth.createUser({ - attributes: { - // (admin = false) set by database - } -}); - -// v2 -await auth.createUser({ - attributes: { - admin: false // must manually pass value - } -}); -``` - -This means `Lucia.DatabaseUserAttributes` (formerly `UserAttributes`) cannot have optional properties. - -## Primary keys - -Primary keys have been removed. We recommend storing the provider id of the primary key as a user attributes if you rely on it. - -```ts -// v1 -await auth.createUser({ - primaryKey: { - // ... - } -}); - -// v2 -await auth.createUser({ - key: { - // ... - } -}); -``` - -## Single use keys - -Single use keys have been removed. We recommend implementing your tokens as they're more secure. Make sure to update `Auth.createKey()` even if you weren't using single use keys. - -```ts -// v1 -await auth.createKey(userId, { - type: "persistent", - providerId, - providerUserId, - password -}); - -// v2 -await auth.createKey({ - userId, - providerId, - providerUserId, - password -}); -``` - -## `lucia/utils` - -Added new `/utils` export, which exports `generateRandomString()` among other utilities. - -```ts -import { - generateRandomString, - serializeCookie, - isWithinExpiration -} from "lucia/utils"; -``` - -## OAuth - -The OAuth package also had some changes as well. - -### Removed `provider()` - -We now provide [`providerUserAuth()`](/reference/oauth/interfaces#provideruserauth) which is a lower level API for implementing your own provider. - -### Renamed `providerUser` and `tokens` - -`providerUser` and `tokens` of the `validateCallback()` return value is now renamed to `githubUser` and `githubTokens`, etc. - -```ts -const { githubUser, githubTokens } = await githubAuth.validateCallback(code); -``` - -### Removed `LuciaOAuthRequestError` - -`LuciaOAuthRequestError` is replaced with [`OAuthRequestError`](/reference/oauth/interfaces#oauthrequesterror). - -### Update `ProviderUserAuth.validateCallback()` - -User attributes should be provided as its own property. - -```ts -const { createUser } = await githubAuth.validateCallback(code); - -// v1 -await createUser(attributes); - -// v2 -await createUser({ - attributes -}); -``` diff --git a/documentation/content/main/migrate/v2/mongoose.md b/documentation/content/main/migrate/v2/mongoose.md deleted file mode 100644 index 106370f7a..000000000 --- a/documentation/content/main/migrate/v2/mongoose.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: "Update your Mongoose client to Lucia v2" ---- - -Install the latest version of the Mongoose adapter. - -``` -npm i @lucia-auth/adapter-mongoose@latest -pnpm add @lucia-auth/adapter-mongoose@latest -yarn add @lucia-auth/adapter-mongoose@latest -``` - -## Remove single use keys - -```ts -// db. -db.authKey.deleteMany({ - expires: { $ne: null } -}); -``` - -## Update `Key` model - -Remove `expires` and `primary_key` fields. - -```ts -const Key = mongoose.model( - "auth_key", - new mongoose.Schema( - { - _id: { - type: String - }, - user_id: { - type: String, - required: true - }, - hashed_password: String - }, - { _id: false } - ) -); -``` - -## Initialize adapter - -`mongoose()` is now a named export instead of a default export. The adapter now takes models for users, keys, and session, instead of the Mongoose client. - -```ts -import { lucia } from "lucia"; -import { mongoose } from "@lucia-auth/adapter-mongoose"; -import mongodb from "mongoose"; - -const User = mongoose.model(); -const Key = mongoose.model(); -const Session = mongoose.model(); - -const auth = lucia({ - adapter: mongoose({ - User, - Key, - Session - }) - // ... -}); - -// handle connection -mongodb.connect(mongoUri, options); -``` diff --git a/documentation/content/main/migrate/v2/mysql.md b/documentation/content/main/migrate/v2/mysql.md deleted file mode 100644 index e42f94253..000000000 --- a/documentation/content/main/migrate/v2/mysql.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: "Update your MySQL database to Lucia v2" ---- - -Install the latest version of the MySQL adapter package. - -``` -npm i @lucia-auth/adapter-mysql@latest -pnpm add @lucia-auth/adapter-mysql@latest -yarn add @lucia-auth/adapter-mysql@latest -``` - -## Update database - -### Remove single use keys - -```sql -DELETE FROM auth_key -WHERE expires != null; -``` - -### Update `auth_key` schema - -Remove columns `auth_key(primary_key)` and `auth_key(expires)`. - -```sql -ALTER TABLE auth_key -DROP COLUMN primary_key; - -ALTER TABLE auth_key -DROP COLUMN expires; -``` - -## Initialize - -Both the `mysql2` and PlanetScale serverless adapter now require you to define the table names. The example below is for the v1 schema, but you can of course rename your tables if you'd like. - -```ts -import { lucia } from "lucia"; -import { mysql2 } from "@lucia-auth/adapter-mysql"; -import mysql from "mysql2/promise"; - -const connectionPool = mysql.createPool({ - // ... -}); - -lucia({ - adapter: mysql2(connectionPool, { - user: "auth_user", - key: "auth_key", - session: "auth_session" - }) - // ... -}); -``` - -```ts -import { lucia } from "lucia"; -import { planetscale } from "@lucia-auth/adapter-mysql"; -import { connect } from "@planetscale/database"; - -const connection = connect({ - host: "", - username: "", - password: "" -}); - -const auth = lucia({ - adapter: planetscale(connection, { - user: "auth_user", - key: "auth_key", - session: "auth_session" - }) - // ... -}); -``` diff --git a/documentation/content/main/migrate/v2/postgresql.md b/documentation/content/main/migrate/v2/postgresql.md deleted file mode 100644 index b6adfe138..000000000 --- a/documentation/content/main/migrate/v2/postgresql.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: "Update your PostgreSQL database to Lucia v2" ---- - -Install the latest version of the PostgreSQL adapter package. - -``` -npm i @lucia-auth/adapter-postgresql@latest -pnpm add @lucia-auth/adapter-postgresql@latest -yarn add @lucia-auth/adapter-postgresql@latest -``` - -## Update database - -### Remove single use keys - -```sql -DELETE FROM auth_key -WHERE expires != null; -``` - -### Update `auth_key` schema - -Remove columns `auth_key(primary_key)` and `auth_key(expires)`. - -```sql -ALTER TABLE auth_key -DROP COLUMN primary_key; - -ALTER TABLE auth_key -DROP COLUMN expires; -``` - -## Initialize - -The `pg` adapter now requires you to define the table names. The example below is for the v1 schema, but you can of course rename your tables if you'd like. - -```ts -import { lucia } from "lucia"; -import { pg } from "@lucia-auth/adapter-postgresql"; -import postgres from "pg"; - -const pool = new postgres.Pool({ - connectionString: CONNECTION_URL -}); - -lucia({ - adapter: pg(pool, { - user: "auth_user", - key: "auth_key", - session: "auth_session" - }) - // ... -}); -``` diff --git a/documentation/content/main/migrate/v2/prisma.md b/documentation/content/main/migrate/v2/prisma.md deleted file mode 100644 index 5d8e77e69..000000000 --- a/documentation/content/main/migrate/v2/prisma.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Update your Prisma client to Lucia v2" ---- - -Install the latest version of the Prisma adapter. - -``` -npm i @lucia-auth/adapter-prisma@latest -pnpm add @lucia-auth/adapter-prisma@latest -yarn add @lucia-auth/adapter-prisma@latest -``` - -## Remove single use keys - -### SQL - -Same for SQLite, PostgreSQL, and MySQL. - -```sql -DELETE FROM auth_key -WHERE expires != null; -``` - -### MongoDB - -```ts -// db. -db.authKey.deleteMany({ - expires: { $ne: null } -}); -``` - -## Update `AuthKey` model - -Remove `Key(primary_key)` and `Key(expires)` from the schema. - -```prisma -model AuthKey { - id String @id @unique - hashed_password String? - user_id String - auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) - - @@index([user_id]) - @@map("auth_key") -} -``` - -## Initialize adapter - -`prisma()` is now a named export instead of a default export. - -```ts -import { lucia } from "lucia"; -import { prisma } from "@lucia-auth/adapter-prisma"; -import { PrismaClient } from "@prisma/client"; - -const client = new PrismaClient(); - -// default values -const auth = lucia({ - adapter: prisma(client, { - user: "authUser", - key: "authKey", - session: "authSession" - }) - // ... -}); -``` - -You can now rename the models as well. Without the second `options` params, the adapter expects the [default schema](/database-adapters/prisma#prisma-schema), which is different from the one required in v1. diff --git a/documentation/content/main/migrate/v2/redis.md b/documentation/content/main/migrate/v2/redis.md deleted file mode 100644 index 7200778fd..000000000 --- a/documentation/content/main/migrate/v2/redis.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: "Update your Redis instance to Lucia v2" ---- - -Install the latest version of the Redis adapter. - -``` -npm i @lucia-auth/adapter-session-redis@latest -pnpm add @lucia-auth/adapter-session-redis@latest -yarn add @lucia-auth/adapter-session-redis@latest -``` - -The Redis adapter now uses a single Redis instance of 2, and as such previous data must be deleted. - -```ts -import { lucia } from "lucia"; -import { redis } from "@lucia-auth/adapter-session-redis"; -import { createClient } from "redis"; - -const redisClient = createClient({ - // ... -}); - -const auth = lucia({ - adapter: { - session: redis(redisClient) - } - // ... -}); -``` diff --git a/documentation/content/main/migrate/v2/sqlite.md b/documentation/content/main/migrate/v2/sqlite.md deleted file mode 100644 index d0cb489c8..000000000 --- a/documentation/content/main/migrate/v2/sqlite.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "Update your SQLite database to Lucia v2" ---- - -Install the latest version of the SQLite adapter package. - -``` -npm i @lucia-auth/adapter-sqlite@latest -pnpm add @lucia-auth/adapter-sqlite@latest -yarn add @lucia-auth/adapter-sqlite@latest -``` - -## Update database - -### Remove single use keys - -```sql -DELETE FROM auth_key -WHERE expires != null; -``` - -### Update `auth_key` schema - -Remove columns `auth_key(primary_key)` and `auth_key(expires)`. - -```sql -ALTER TABLE auth_key -DROP COLUMN primary_key; - -ALTER TABLE auth_key -DROP COLUMN expires; -``` - -## Initialize - -Both the `better-sqlite3` and Cloudflare D1 adapter now require you to define the table names. The example below is for the v1 schema, but you can of course rename your tables if you'd like. - -```ts -import { lucia } from "lucia"; -import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; -import sqlite from "better-sqlite3"; - -const db = sqlite("main.db"); - -lucia({ - adapter: betterSqlite3(db, { - user: "auth_user", - key: "auth_key", - session: "auth_session" - }) - // ... -}); -``` - -```ts -import { lucia } from "lucia"; -import { d1 } from "@lucia-auth/adapter-sqlite"; - -lucia({ - adapter: d1(db, { - user: "auth_user", - key: "auth_key", - session: "auth_session" - }) - // ... -}); -// -``` diff --git a/documentation/content/main/starter-guides.md b/documentation/content/main/starter-guides.md deleted file mode 100644 index 73eaa468c..000000000 --- a/documentation/content/main/starter-guides.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "Starter guides" -description: "Guides and examples to learn the basics of Lucia using your framework of your choice." ---- - -Guides and examples to learn the basics of Lucia using your framework of your choice. - -## Username and password authentication - -Learn everything you need to know about Lucia with a basic username and password authentication. - -- [No framework](/guidebook/sign-in-with-username-and-password) -- [Astro](/guidebook/sign-in-with-username-and-password/astro) -- [Express](/guidebook/sign-in-with-username-and-password/express) -- [Next.js App Router](/guidebook/sign-in-with-username-and-password/nextjs-app) -- [Next.js Pages Router](/guidebook/sign-in-with-username-and-password/nextjs-pages) -- [Nuxt](/guidebook/sign-in-with-username-and-password/nuxt) -- [SolidStart](/guidebook/sign-in-with-username-and-password/solidstart) -- [SvelteKit](/guidebook/sign-in-with-username-and-password/sveltekit) - -## GitHub OAuth - -Learn how to implement OAuth with Lucia and the [OAuth integration](/oauth). - -- [No framework](/guidebook/github-oauth) -- [Astro](/guidebook/github-oauth/astro) -- [Express](/guidebook/github-oauth/express) -- [Next.js App Router](/guidebook/github-oauth/nextjs-app) -- [Next.js Pages Router](/guidebook/github-oauth/nextjs-pages) -- [Nuxt](/guidebook/github-oauth/nuxt) -- [SolidStart](/guidebook/github-oauth/solidstart) -- [SvelteKit](/guidebook/github-oauth/sveltekit) diff --git a/documentation/content/oauth/basics/handle-users.md b/documentation/content/oauth/basics/handle-users.md deleted file mode 100644 index 2dd8aa2bb..000000000 --- a/documentation/content/oauth/basics/handle-users.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: "Handle users with OAuth" -description: "Learn how to use handle users with OAuth" ---- - -After authenticating the user with OAuth, you can get an existing or create a new Lucia user using [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). If you're using one of the built in providers, [`OAuth2ProviderAuth.validateCallback()`](reference/oauth/interfaces/oauth2providerauth#validatecallback) and [`OAuth2ProviderAuthWithPKCE.validateCallback()`](reference/oauth/interfaces/oauth2providerauthwithpkce#validatecallback) will return a provider-extended instance of it. - -```ts -import { github } from "@lucia-auth/oauth/providers"; - -const githubAuth = github(); -const githubUserAuth = githubAuth.validateCallback(); -``` - -Alternatively, if you're using one of the OAuth helpers, you can use [`providerUserAuth()`](/reference/oauth/modules/main#provideruserauth) to manually create a new instance of it. It takes your Lucia `Auth` instance, the provider id (e.g. `"github"`), and the provider user id (e.g. GitHub user id). - -```ts -const githubUserAuth = providerUserAuth(auth, "github", githubUserId); -``` - -## Basic usage - -[`ProviderUserAuth.getExistingUser()`](/reference/oauth/interfaces/provideruserauth/#getexistinguser) will return a `User` if a Lucia user already exists for the authenticated provider account. This is based on the provider user id (e.g. GitHub user id) and not shared identifiers like email. - -If not, you can create a new Lucia user linked to the provider with [`ProviderUserAuth.createUser()`](/reference/oauth/interfaces/provideruserauth#createuser). You can get the provider user data with `githubUser` for GitHub, etc. - -```ts -const getUser = async () => { - const existingUser = await githubUserAuth.getExistingUser(); - if (existingUser) return existingUser; - // create a new user if the user does not exist - return await githubUserAuth.createUser({ - attributes: { - githubUsername: githubUser.login - } - }); -}; -const user = await getUser(); - -// login user -const session = await auth.createSession({ - userId: user.userId, - attributes: {} -}); -const authRequest = auth.handleRequest(); -authRequest.setSession(session); // store session cookie -``` - -## Add a new key to an existing user - -Alternatively, you may want to add a new authentication method to an existing user. Calling [`ProviderUserAuth.createKey()`](/reference/oauth/interfaces/provideruserauth#createkey) will create a new key linked to the provided user id. - -```ts -const existingUser = githubUserAuth.getExistingUser(); -if (existingUser) { - await createKey(currentUser.userId); -} -``` - -See [OAuth account linking](/guidebook/oauth-account-linking) guide for details. - -## Extension - -If you're using one of the built in providers, `OAuth2ProviderAuth.validateCallback()` and `OAuth2ProviderAuthWithPKCE.validateCallback()` will return a provider-extended instance of `ProviderUserAuth`. This means in addition to the methods of `ProviderUserAuth`, it includes a few other properties and methods. While this isn't strictly standardized, all providers include the provider user (e.g. github user) and an access token (refresh token if available). - -### Get provider user - -```ts -const githubUserAuth = await githubAuth.validateCallback(code); -const githubUsername = githubUserAuth.githubUser.login; -``` - -### Get API tokens - -```ts -const githubUserAuth = await githubAuth.validateCallback(code); -const githubAccessToken = githubUserAuth.githubTokens.accessToken; -``` diff --git a/documentation/content/oauth/basics/oauth2-pkce.md b/documentation/content/oauth/basics/oauth2-pkce.md deleted file mode 100644 index bfe5356ed..000000000 --- a/documentation/content/oauth/basics/oauth2-pkce.md +++ /dev/null @@ -1,190 +0,0 @@ ---- -title: "OAuth 2.0 with PKCE" -description: "Learn how to implement OAuth 2.0 with PKCE" ---- - -This page covers OAuth 2.0 authorization code grant type with PKCE. For OAuth 2.0 providers without PKCE, see [OAuth 2.0 without PKCE](/oauth/basics/oauth2). - -Examples shown here uses Twitter OAuth but the API and overall process is nearly across providers. See each provider's documentation (from the sidebar) for specifics. - -## Built-in providers - -Initialize the handler using the Lucia `Auth` instance and provider-specific config. This creates a new [`OAuth2ProviderAuthWithPKCE`](/reference/oauth/interfaces/oauth2providerauthwithpkce) extended instance (e.g. `TwitterAuth`). - -```ts -import { lucia } from "lucia"; -import { twitter } from "@lucia-auth/oauth/providers"; - -export const auth = lucia(); - -export const twitterAuth = twitter(auth, config); -``` - -### Get authorization url - -You can get a new authorization url with `getAuthorizationUrl()`. It will return the url, code verifier, and state. The code verifier should be stored as a cookie. The state is usually defined but it may be undefined if the provider does not support it. If defined, stored as a cookie. - -```ts -import { auth, twitterAuth } from "$lib/lucia.js"; - -// get url to redirect the user to, with the state -const [url, codeVerifier, state] = await twitterAuth.getAuthorizationUrl(); - -setCookie("twitter_code_verifier", codeVerifier, { - path: "/", - httpOnly: true, // only readable in the server - secure: false, // set to `true` in production (HTTPS) - maxAge: 60 * 60 // a reasonable expiration date -}); -setCookie("twitter_oauth_state", state, { - path: "/", - httpOnly: true, // only readable in the server - secure: false, // set to `true` in production (HTTPS) - maxAge: 60 * 60 // a reasonable expiration date -}); - -// redirect to authorization url -redirect(url); -``` - -You can set additional query params to the authorization url can be done by using `URL.searchParams.set()` on the returned `URL` instance. - -```ts -url.searchParams.set("response_mode", "query"); -``` - -### Validate callback - -Upon authentication, the provider will redirect the user back to your application. The url includes a code, and a state if the provider supports it. If a state is used, make sure to check if the state in the query params is the same as the one stored as a cookie. - -Validate the code and code verifier, which is stored as a cookie, using `validateCallback()`. If the code and the code verifier are valid, this will return a new [`ProviderUserAuth`](/reference/oauth/interfaces#provideruserauth) among provider specific items (such as provider user data and access tokens). - -```ts -import { auth, twitterAuth } from "$lib/lucia.js"; - -const code = requestUrl.searchParams.get("code"); -const state = requestUrl.searchParams.get("state"); - -// get state cookie we set when we got the authorization url -const stateCookie = getCookie("twitter_oauth_state"); - -// validate state -if (!state || !storedState || state !== storedState) throw new Error(); // invalid state - -const codeVerifier = getCookie("twitter_code_verifier"); - -if (!codeVerifier) throw new Error(); // invalid code verifier - -try { - await twitterAuth.validateCallback(code, codeVerifier); -} catch { - // invalid code or code verifier -} -``` - -## OAuth helpers - -If your provider isn't support by the integration, you can use the included OAuth helpers. The basic process is basically the same except for `OAuth2ProviderAuth.getAuthorizationUrl()` and `OAuth2ProviderAuth.validateCallback()`. - -## Create authorization URL - -You can create a new authorization url with a state with [`createOAuth2AuthorizationUrlWithPKCE()`](/reference/oauth/modules/main#createoauth2authorizationurlwithpkce). This take the base authorization url, and returns the full url as the first item and an OAuth state as the second. - -The state should be stored as a http-only cookie if your provider supports it. - -```ts -import { createAuthorizationUrlWithPKCE } from "@lucia-auth/oauth"; - -// get url to redirect the user to, with the state -const [url, codeVerifier, state] = await createAuthorizationUrlWithPKCE( - "https://twitter.com/i/oauth2/authorize", - { - clientId, - scope: ["tweet.read", "users.read"], // empty array if none - redirectUri - } -); -``` - -### Additional configuration - -You can set additional query params to the authorization url can be done by using `URL.searchParams.set()` on the returned `URL` instance. - -```ts -url.searchParams.set("response_mode", "query"); -``` - -## Validate authorization code - -Extract the authorization code from the query string and verify it using [`validateOAuth2AuthorizationCode()`](/reference/oauth/modules/main#validateoauth2authorizationcode). The code verifier stored as a cookie should be passed to the `codeVerifier` option. This sends a request to the provided url and returns the JSON-parsed response body, which includes the access token. You can define the return type by passing a generic. This will throw a [`OAuthRequestError`](/reference/oauth/interfaces#oauthrequesterror) if the request fails. - -```ts -import { validateOAuth2AuthorizationCode } from "@lucia-auth/oauth"; - -type AccessTokenResult = { - access_token: string; -}; - -const tokens = await validateOAuth2AuthorizationCode( - code, - "https://api.twitter.com/2/oauth2/token", - { - clientId, - codeVerifier, - clientPassword: { - clientSecret, - authenticateWith: "http_basic_auth" - }, - redirectUri - } -); -const accessToken = tokens.access_token; -``` - -### Client password - -If your provider takes a client password, there are 2 ways to verify the code. You can either sending the client secret in the body, or using the HTTP basic authentication scheme. This depends on the provider. - -#### Send client secret in the body - -Set `clientPassword.authenticateWith` to `"client_secret"` to send the client secret in the request body. - -```ts -const tokens = await validateOAuth2AuthorizationCode(code, url, { - clientId, - clientPassword: { - clientSecret, - authenticateWith: "client_secret" - } -}); -``` - -#### Use HTTP basic authentication - -You can send the base64 encoded client id and secret by setting `clientPassword.authenticateWith` to `"http_basic_auth"`. - -```ts -const tokens = await validateOAuth2AuthorizationCode(code, url, { - clientId, - clientPassword: { - clientSecret, - authenticateWith: "http_basic_auth" - } -}); -``` - -## Errors - -Request errors are thrown as [`OAuthRequestError`](/reference/oauth/interfaces/oauthrequesterror), which includes a request and response object. - -```ts -import { OAuthRequestError } from "@lucia-auth/oauth"; - -try { - await githubAuth.validateCallback(code); -} catch (e) { - if (e instanceof OAuthRequestError) { - const { request, response } = e; - } -} -``` diff --git a/documentation/content/oauth/basics/oauth2.md b/documentation/content/oauth/basics/oauth2.md deleted file mode 100644 index a791a7130..000000000 --- a/documentation/content/oauth/basics/oauth2.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -title: "OAuth 2.0" -description: "Learn how to implement OAuth 2.0" ---- - -This page covers OAuth 2.0 authorization code grant type. For OAuth 2.0 providers with PKCE, see [OAuth 2.0 with PKCE](/oauth/basics/oauth2-pkce). - -Examples shown here uses GitHub OAuth but the API and overall process is nearly across providers. See each provider's documentation (from the sidebar) for specifics. - -## Built-in providers - -Initialize the handler using the Lucia `Auth` instance and provider-specific config. This creates a new [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth) extended instance (e.g. `GithubAuth`). - -```ts -import { lucia } from "lucia"; -import { github } from "@lucia-auth/oauth/providers"; - -export const auth = lucia(); - -export const githubAuth = github(auth, config); -``` - -### Get authorization url - -You can get a new authorization url with `getAuthorizationUrl()`. It will return a url and a state. The state is usually defined but it may be undefined if the provider does not support it. If defined, store it in a cookie. - -```ts -import { auth, githubAuth } from "$lib/lucia.js"; - -// get url to redirect the user to, with the state -const [url, state] = await githubAuth.getAuthorizationUrl(); - -setCookie("github_oauth_state", state, { - path: "/", - httpOnly: true, // only readable in the server - secure: false, // set to `true` in production (HTTPS) - maxAge: 60 * 60 // a reasonable expiration date -}); - -// redirect to authorization url -redirect(url); -``` - -You can set additional query params to the authorization url can be done by using `URL.searchParams.set()` on the returned `URL` instance. - -```ts -url.searchParams.set("response_mode", "query"); -``` - -### Validate callback - -Upon authentication, the provider will redirect the user back to your application (GET request). The url includes a code, and a state if the provider supports it. If a state is used, make sure to check if the state in the query params is the same as the one stored as a cookie. - -Validate the code using `validateCallback()`. If the code is valid, this will return a new [`ProviderUserAuth`](/reference/oauth/interfaces#provideruserauth) among provider specific items (such as provider user data and access tokens). See [Handle users with OAuth](/oauth/basics/handle-users) for how to use it. - -```ts -import { auth, githubAuth } from "$lib/lucia.js"; - -const code = requestUrl.searchParams.get("code"); -const state = requestUrl.searchParams.get("state"); - -// get state cookie we set when we got the authorization url -const stateCookie = getCookie("github_oauth_state"); - -// validate state -if (!state || !storedState || state !== storedState) throw new Error(); // invalid state - -try { - await githubAuth.validateCallback(code); -} catch { - // invalid code -} -``` - -## OAuth helpers - -If your provider isn't support by the integration, you can use the included OAuth helpers. The basic process is basically the same except for `OAuth2ProviderAuth.getAuthorizationUrl()` and `OAuth2ProviderAuth.validateCallback()`. - -## Create authorization URL - -You can create a new authorization url with a state with [`createOAuth2AuthorizationUrl()`](/reference/oauth/modules/main#createoauth2authorizationurl). This take the base authorization url, and returns the full url as the first item and an OAuth state as the second. - -The state should be stored as a http-only cookie if your provider supports it. - -```ts -import { createAuthorizationUrl } from "@lucia-auth/oauth"; - -// get url to redirect the user to, with the state -const [url, state] = await createAuthorizationUrl( - "https://github.com/login/oauth/authorize", - { - clientId, - scope: ["user:email"], // empty array if none - redirectUri - } -); -``` - -### Additional configuration - -You can set additional query params to the authorization url can be done by using `URL.searchParams.set()` on the returned `URL` instance. - -```ts -url.searchParams.set("response_mode", "query"); -``` - -## Validate authorization code - -Extract the authorization code from the query string and verify it using [`validateOAuth2AuthorizationCode()`](/reference/oauth/modules/main#validateoauth2authorizationcode). This sends a request to the provided url and returns the JSON-parsed response body, which includes the access token. You can define the return type by passing a generic. This will throw a [`OAuthRequestError`](/reference/oauth/interfaces#oauthrequesterror) if the request fails. - -```ts -import { validateOAuth2AuthorizationCode } from "@lucia-auth/oauth"; - -type AccessTokenResult = { - access_token: string; -}; - -const tokens = await validateOAuth2AuthorizationCode( - code, - "https://github.com/login/oauth/access_token", - { - clientId, - clientPassword: { - clientSecret, - authenticateWith: "client_secret" - }, - redirectUri // optional - } -); -const accessToken = tokens.access_token; -``` - -### Client password - -If your provider takes a client password, there are 2 ways to verify the code. You can either sending the client secret in the body, or using the HTTP basic authentication scheme. This depends on the provider. - -#### Send client secret in the body - -Set `clientPassword.authenticateWith` to `"client_secret"` to send the client secret in the request body. - -```ts -const tokens = await validateOAuth2AuthorizationCode(code, url, { - clientId, - clientPassword: { - clientSecret, - authenticateWith: "client_secret" - } -}); -``` - -#### Use HTTP basic authentication - -You can send the base64 encoded client id and secret by setting `clientPassword.authenticateWith` to `"http_basic_auth"`. - -```ts -const tokens = await validateOAuth2AuthorizationCode(code, url, { - clientId, - clientPassword: { - clientSecret, - authenticateWith: "http_basic_auth" - } -}); -``` - -## Errors - -Request errors are thrown as [`OAuthRequestError`](/reference/oauth/interfaces/oauthrequesterror), which includes a request and response object. - -```ts -import { OAuthRequestError } from "@lucia-auth/oauth"; - -try { - await githubAuth.validateCallback(code); -} catch (e) { - if (e instanceof OAuthRequestError) { - const { request, response } = e; - } -} -``` diff --git a/documentation/content/oauth/basics/oidc.md b/documentation/content/oauth/basics/oidc.md deleted file mode 100644 index ea52990d0..000000000 --- a/documentation/content/oauth/basics/oidc.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: "Open ID Connect" -description: "Learn how to implement" ---- - -## Handle ID Token - -You can use [`decodeIdToken()`](/reference/oauth/modules/main#decodeidtoken) to decode ID Tokens. **This does not validate them**, though validation is rarely necessary assuming the ID Token is used immediately. - -```ts -import { decodeIdToken } from "@lucia-auth/oauth"; - -const user = decodeIdToken(idToken); -const { sub, email } = user; - -type Claims = { - email: string; -}; -``` diff --git a/documentation/content/oauth/index.md b/documentation/content/oauth/index.md deleted file mode 100644 index 90d593d1b..000000000 --- a/documentation/content/oauth/index.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: "OAuth integration" -description: "Learn about the OAuth integration for Lucia" ---- - -Lucia provides an external, server-side library that makes implementing authentication flow with OAuth and Open ID Connect easy. - -It mainly handles 2 parts of the authentication process. First, it generates an authorization URL for your users to be redirected to. Once the user authenticates with the provider, they will be redirected back to your application with a code. You can then pass this code to the integration to be validated. - -We support a handful of popular providers out of the box (full list below), but we also provide helpers that support most OAuth 2.0 and Open ID Connect implementations. - -``` -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` - -Get started with [OAuth 2.0 authorization code grant type](/oauth/basics/oauth2) or [OAuth 2.0 with PKCE](/oauth/basics/oauth2-pkce). - -## Step-by-step guides - -We also have framework specific guides. - -- [No framework](/guidebook/github-oauth) -- [Astro](/guidebook/github-oauth/astro) -- [Express](/guidebook/github-oauth/express) -- [Next.js App Router](/guidebook/github-oauth/nextjs-app) -- [Next.js Pages Router](/guidebook/github-oauth/nextjs-pages) -- [Nuxt](/guidebook/github-oauth/nuxt) -- [SvelteKit](/guidebook/github-oauth/sveltekit) - -## Built-in providers - -- Apple -- Atlassian -- Auth0 -- Azure Active Directory -- Bitbucket -- Box -- Amazon Cognito -- Discord -- Dropbox -- Facebook -- GitHub -- GitLab -- Google -- Kakao -- Keycloak -- Lichess -- Line -- LinkedIn -- osu! -- Patreon -- Reddit -- Salesforce -- Slack -- Spotify -- Strava -- Twitch -- Twitter diff --git a/documentation/content/oauth/providers/apple.md b/documentation/content/oauth/providers/apple.md deleted file mode 100644 index a263e5a3b..000000000 --- a/documentation/content/oauth/providers/apple.md +++ /dev/null @@ -1,180 +0,0 @@ ---- -title: "Apple OAuth provider" -description: "Learn how to use the Apple OAuth provider" ---- - -**Before starting make sure you have an paid apple dev account.** - -OAuth integration for Apple. Refer to Apple Docs: - -- [Creating App ID](https://developer.apple.com/help/account/manage-identifiers/register-an-app-id/) -- [Creating Service ID](https://developer.apple.com/help/account/manage-identifiers/register-a-services-id) -- [Enable "Sign In with Apple" Capability](https://developer.apple.com/help/account/manage-identifiers/enable-app-capabilities) -- [Creating Private Key](https://developer.apple.com/help/account/manage-keys/create-a-private-key) -- [Locate the keyId](https://developer.apple.com/help/account/manage-keys/get-a-key-identifier) -- [How to locate your teamId](https://developer.apple.com/help/account/manage-your-team/locate-your-team-id) -- [Requesting Access Token](https://developer.apple.com/documentation/sign_in_with_apple/request_an_authorization_to_the_sign_in_with_apple_server) -- [How to validate tokens](https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens) - -Provider id is `apple`. - -```ts -import { apple } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const appleAuth = apple(auth, configs); -``` - -## `apple()` - -```ts -const apple: ( - auth: Auth, - config: { - clientId: string; - redirectUri: string; - teamId: string; - keyId: string; - certificate: string; - scope?: string[]; - responseMode?: "query" | "form_post"; - } -) => AppleProvider; -``` - -##### Parameters - -| name | type | description | default | -| --------------------- | ------------------------------------------ | --------------------------------------------------------------------- | --------- | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Apple service identifier | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.teamId` | `string` | Apple teamId | | -| `config.keyId ` | `string` | Apple private keyId | | -| `config.certificate` | `string` | p8 certificate as string [See how](#how-to-import-certificate) | | -| `config.scope` | `string[]` | an array of scopes | `[]` | -| `config.responseMode` | `"query" \| "form_post"` | OIDC response mode - **must be `"form_post"` when requesting scopes** | `"query"` | - -##### Returns - -| type | description | -| --------------------------------- | -------------- | -| [`AppleProvider`](#appleprovider) | Apple provider | - -### Import certificate - -Example using Node.js: - -```ts -import fs from "fs"; -import path from "path"; - -const certificatePath = path.join( - process.cwd(), - process.env.APPLE_CERT_PATH ?? "" -); - -const certificate = fs.readFileSync(certificatePath, "utf-8"); - -export const appleAuth = apple(auth, { - teamId: process.env.APPLE_TEAM_ID ?? "", - keyId: process.env.APPLE_KEY_ID ?? "", - certificate: certificate, - redirectUri: process.env.APPLE_REDIRECT_URI ?? "", - clientId: process.env.APPLE_CLIENT_ID ?? "" -}); -``` - -## Requesting scopes - -When requesting scopes (`email` and `name`), the `options.responseMode` must be set to `"form_post"`. Unlike the default `"query"` response mode, \*\*Apple will send an `application/x-www-form-urlencoded` POST request. You can retrieve the code by parsing the search queries or the form data. - -```ts -post("/login/apple/callback", async (request) => { - const url = new URL(request.url) - const code = url.searchParams.get("code"); - if (!isValidState(request, code)) { - // ... - } - const appleUserAuth = await - // ... -}) -``` - -Apple will also include a `user` field **only in the first response**, where you can access the user's name. - -```ts -const url = new URL(request.url); -const userJSON = url.searchParams.get("user"); -if (userJSON) { - const user = JSON.parse(userJSON); - const { firstName, lastName, email } = user; -} -``` - -## Interfaces - -### `AppleAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface AppleAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| --------------------------------- | -| [`AppleUserAuth`](#appleuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `AppleTokens` - -```ts -type AppleTokens = { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresIn: number; - idToken: string; -}; -``` - -### `AppleUser` - -```ts -type AppleUser = { - email?: string; - email_verified?: boolean; - sub: string; -}; -``` - -### `AppleUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface AppleUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - appleUser: AppleUser; - appleTokens: AppleTokens; -} -``` - -| properties | type | description | -| ------------- | ----------------------------- | ----------------- | -| `appleUser` | [`AppleUser`](#appleuser) | Apple user | -| `appleTokens` | [`AppleTokens`](#appletokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/atlassian.md b/documentation/content/oauth/providers/atlassian.md deleted file mode 100644 index f4dbe66fd..000000000 --- a/documentation/content/oauth/providers/atlassian.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "Atlassian OAuth provider" -description: "Learn how to use the Atlassian OAuth provider" ---- - -OAuth 2.0 (Authorization code) integration for Atlassian. Provider id is `atlassian`. - -```ts -import { atlassian } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const atlassianAuth = atlassian(auth, configs); -``` - -## `atlassian()` - -Scopes `read:me` is always included. - -```ts -const atlassian: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => AtlassianProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | --------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Atlassian OAuth app client id | | -| `config.clientSecret` | `string` | Atlassian OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ----------------------------------------- | ------------------ | -| [`AtlassianProvider`](#atlassianprovider) | Atlassian provider | - -## Interfaces - -### `AtlassianAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface AtlassianAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------------- | -| [`AtlassianUserAuth`](#atlassianuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `AtlassianTokens` - -Add scope `offline_access` to get refresh tokens. - -```ts -type AtlassianTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string | null; -}; -``` - -### `AtlassianUser` - -```ts -type AtlassianUser = { - account_type: string; - account_id: string; - email: string; - name: string; - picture: string; - account_status: string; - nickname: string; - zoneinfo: string; - locale: string; - extended_profile?: Record; -}; -``` - -### `AtlassianUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface AtlassianUserAuth<_Auth extends Auth> - extends ProviderUserAuth<_Auth> { - atlassianUser: AtlassianUser; - atlassianTokens: AtlassianTokens; -} -``` - -| properties | type | description | -| ----------------- | ------------------------------------- | ----------------- | -| `atlassianUser` | [`AtlassianUser`](#atlassianuser) | Atlassian user | -| `atlassianTokens` | [`AtlassianTokens`](#atlassiantokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/auth0.md b/documentation/content/oauth/providers/auth0.md deleted file mode 100644 index 4ca721686..000000000 --- a/documentation/content/oauth/providers/auth0.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: "Auth0 OAuth provider" -description: "Learn how to use the Auth0 OAuth provider" ---- - -OAuth integration for Auth0. Refer to [Auth0 OAuth documentation](https://auth0.com/docs/get-started/authentication-and-authorization-flow/add-login-auth-code-flow) for getting the required credentials. Provider id is `auth0`. - -```ts -import { auth0 } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const auth0Auth = auth0(auth, config); -``` - -## `auth0()` - -```ts -const auth0: ( - auth: Auth, - config: { - appDomain: string; - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => Auth0Provider; -``` - -##### Parameters - -Scopes `openid` and `profile` are always included - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ----------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.appDomain` | `string` | Auth0 OAuth app domain | | -| `config.clientId` | `string` | Auth0 OAuth app client id | | -| `config.clientSecret` | `string` | Auth0 OAuth app client secret | | -| `config.redirectUri` | `string` | Auth0 OAuth app redirect uri | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| --------------------------------- | -------------- | -| [`Auth0Provider`](#auth0provider) | Auth0 provider | - -## Interfaces - -### `Auth0Auth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface Auth0Auth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| --------------------------------- | -| [`Auth0UserAuth`](#auth0userauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `Auth0Tokens` - -```ts -type Auth0Tokens = { - accessToken: string; - refreshToken: string; - idToken: string; - tokenType: string; -}; -``` - -### `Auth0User` - -```ts -type Auth0User = { - id: string; - sub: string; - name: string; - picture: string; - locale: string; - updated_at: string; - given_name?: string; - family_name?: string; - middle_name?: string; - nickname?: string; - preferred_username?: string; - profile?: string; - email?: string; - email_verified?: boolean; - gender?: string; - birthdate?: string; - zoneinfo?: string; - phone_number?: string; - phone_number_verified?: boolean; -}; -``` - -### `Auth0UserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface Auth0UserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - auth0User: Auth0User; - auth0Tokens: Auth0Tokens; -} -``` - -| properties | type | description | -| ------------- | ----------------------------- | ----------------- | -| `auth0User` | [`Auth0User`](#auth0user) | Auth0 user | -| `auth0Tokens` | [`Auth0Tokens`](#auth0tokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/azure-ad.md b/documentation/content/oauth/providers/azure-ad.md deleted file mode 100644 index e940508c6..000000000 --- a/documentation/content/oauth/providers/azure-ad.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: "Azure Active Directory OAuth provider" -description: "Learn how to use the Azure Active Directory OAuth provider" ---- - -OAuth integration for Azure Active Directory with PKCE. Provider id is `azure_ad`. - -```ts -import { azureAD } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const AzureADAuth = azureAD(auth, config); -``` - -## `azureAd()` - -The `oidc` and `profile` scope are always included. - -```ts -const azureAd: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - tenant: string; - redirectUri: string; - scope?: string[]; - } -) => AzureADProvider; -``` - -##### Parameter - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ------------------ | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | client id | | -| `config.clientSecret` | `string` | client secret | | -| `config.tenant` | `string` | tenant identifier | | -| `config.redirectUri` | `string` | redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`AzureADProvider`](#azureadprovider) | AzureAD provider | - -## Interfaces - -### `AzureADAuth` - -See [`OAuth2ProviderAuthWithPKCE`](/reference/oauth/interfaces/oauth2providerauthwithpkce). - -```ts -// implements OAuth2ProviderAuthWithPKCE> -interface AzureADAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise< - readonly [url: URL, codeVerifier: string, state: string] - >; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------------- | -| [`AzureADUserAuth`](#azureaduserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `AzureADTokens` - -```ts -type AzureADTokens = { - idToken: string; - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string | null; -}; -``` - -### `AzureADUser` - -```ts -type AzureADUser = { - sub: string; - name: string; - family_name: string; - given_name: string; - picture: string; - email?: string; // requires `email` scope -}; -``` - -### `AzureADUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface AzureADUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - azureADUser: AzureADUser; - azureADTokens: AzureADTokens; -} -``` - -| properties | type | description | -| --------------- | --------------------------------- | ----------------- | -| `azureADUser` | [`AzureADUser`](#azureaduser) | AzureAD user | -| `azureADTokens` | [`AzureADTokens`](#azureadtokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/bitbucket.md b/documentation/content/oauth/providers/bitbucket.md deleted file mode 100644 index de22a2e78..000000000 --- a/documentation/content/oauth/providers/bitbucket.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: "Bitbucket OAuth provider" -description: "Learn how to use the Bitbucket OAuth provider" ---- - -OAuth integration for Bitbucket. Provider id is `bitbucket`. - -**Make sure you enable scope `account` in your Bitbucket app settings.** - -```ts -import { bitbucket } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const bitbucketAuth = bitbucket(auth, configs); -``` - -## `bitbucket()` - -Scopes can only be configured from your Bitbucket app settings. - -```ts -const bitbucket: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - } -) => BitbucketProvider; -``` - -##### Parameters - -| name | type | description | -| --------------------- | ------------------------------------------ | --------------------------------- | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | -| `config.clientId` | `string` | Bitbucket OAuth app client id | -| `config.clientSecret` | `string` | Bitbucket OAuth app client secret | -| `config.redirectUri` | `string` | an authorized redirect URI | - -##### Returns - -| type | description | -| ----------------------------------------- | ------------------ | -| [`BitbucketProvider`](#bitbucketprovider) | Bitbucket provider | - -## Interfaces - -### `BitbucketAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface BitbucketAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------------- | -| [`BitbucketUserAuth`](#bitbucketuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `BitbucketTokens` - -```ts -type BitbucketTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; -}; -``` - -### `BitbucketUser` - -```ts -type BitbucketUser = { - type: string; - links: { - avatar: - | {} - | { - href: string; - name: string; - }; - }; - created_on: string; - display_name: string; - username: string; - uuid: string; -}; -``` - -### `BitbucketUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface BitbucketUserAuth<_Auth extends Auth> - extends ProviderUserAuth<_Auth> { - bitbucketUser: BitbucketUser; - bitbucketTokens: BitbucketTokens; -} -``` - -| properties | type | description | -| ----------------- | ------------------------------------- | ----------------- | -| `bitbucketUser` | [`BitbucketUser`](#bitbucketuser) | Bitbucket user | -| `bitbucketTokens` | [`BitbucketTokens`](#bitbuckettokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/box.md b/documentation/content/oauth/providers/box.md deleted file mode 100644 index 7a3c8bae7..000000000 --- a/documentation/content/oauth/providers/box.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: "Box OAuth provider" -description: "Learn how to use the Box OAuth provider" ---- - -OAuth integration for Box. Provider id is `box`. - -```ts -import { box } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const boxAuth = box(auth, configs); -``` - -## `box()` - -```ts -const box: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - } -) => BoxProvider; -``` - -##### Parameters - -| name | type | description | -| --------------------- | ------------------------------------------ | --------------------------- | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | -| `config.clientId` | `string` | Box OAuth app client id | -| `config.clientSecret` | `string` | Box OAuth app client secret | -| `config.redirectUri` | `string` | an authorized redirect URI | - -##### Returns - -| type | description | -| ----------------------------- | ------------ | -| [`BoxProvider`](#boxprovider) | Box provider | - -## Interfaces - -### `BoxAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface BoxAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------- | -| [`BoxUserAuth`](#boxuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `BoxTokens` - -```ts -type BoxTokens = { - accessToken: string; -}; -``` - -### `BoxUser` - -```ts -type BoxUser = { - id: string; - type: "user"; - address: string; - avatar_url: string; - can_see_managed_users: boolean; - created_at: string; - enterprise: { - id: string; - type: string; - name: string; - }; - external_app_user_id: string; - hostname: string; - is_exempt_from_device_limits: boolean; - is_exempt_from_login_verification: boolean; - is_external_collab_restricted: boolean; - is_platform_access_only: boolean; - is_sync_enabled: boolean; - job_title: string; - language: string; - login: string; - max_upload_size: number; - modified_at: string; - my_tags: [string]; - name: string; - notification_email: { - email: string; - is_confirmed: boolean; - }; - phone: string; - role: string; - space_amount: number; - space_used: number; - status: - | "active" - | "inactive" - | "cannot_delete_edit" - | "cannot_delete_edit_upload"; - timezone: string; - tracking_codes: { - type: string; - name: string; - value: string; - }[]; -}; -``` - -### `BoxUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface BoxUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - boxUser: BoxUser; - boxTokens: BoxTokens; -} -``` - -| properties | type | description | -| ----------- | ------------------------- | ----------------- | -| `boxUser` | [`BoxUser`](#boxuser) | Box user | -| `boxTokens` | [`BoxTokens`](#boxtokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/cognito.md b/documentation/content/oauth/providers/cognito.md deleted file mode 100644 index f0bb86e8a..000000000 --- a/documentation/content/oauth/providers/cognito.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: "Amazon Cognito OAuth provider" -description: "Learn about using the Amazon Cognito provider" ---- - -OAuth integration for Amazon Cognito's hosted UI. Refer to the Cognito docs: - -- [Amazon Cognito hosted UI](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-app-integration.html) -- [Authorization endpoint documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/authorization-endpoint.html) -- [Token endpoint documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html) - -Provider id is `cognito`. - -```ts -import { cognito } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const cognitoAuth = cognito(auth, configs); -``` - -## `cognito()` - -```ts -const cognito: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - userPoolDomain: string; - } -) => CognitoProvider; -``` - -##### Parameters - -| name | type | description | optional | -| ----------------------- | ------------------------------------------ | ------------------------------------------------ | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Cognito app client id | | -| `config.clientSecret` | `string` | Cognito app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.scope` | `string[]` | an array of scopes - `openid` is always included | ✓ | -| `config.userPoolDomain` | `string` | Amazon Cognito's user pool domain | | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`CognitoProvider`](#cognitoprovider) | Cognito provider | - -## Interfaces - -### `CognitoAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface CognitoAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------------- | -| [`CognitoUserAuth`](#cognitouserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `CognitoTokens` - -```ts -type CognitoTokens = { - accessToken: string; - refreshToken: string; - idToken: string; - accessTokenExpiresIn: number; - tokenType: string; -}; -``` - -### `CognitoUser` - -```ts -type CognitoUser = { - sub: string; - "cognito:username": string; - "cognito:groups": string[]; - address?: { - formatted?: string; - }; - birthdate?: string; - email?: string; - email_verified?: boolean; - family_name?: string; - gender?: string; - given_name?: string; - locale?: string; - middle_name?: string; - name?: string; - nickname?: string; - phone_number?: string; - phone_number_verified?: boolean; - picture?: string; - preferred_username?: string; - profile?: string; - website?: string; - zoneinfo?: string; - updated_at?: number; -}; -``` - -### `CognitoUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface CognitoUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - cognitoUser: CognitoUser; - cognitoTokens: CognitoTokens; -} -``` - -| properties | type | description | -| --------------- | --------------------------------- | ----------------- | -| `cognitoUser` | [`CognitoUser`](#cognitouser) | Cognito user | -| `cognitoTokens` | [`CognitoTokens`](#cognitotokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/discord.md b/documentation/content/oauth/providers/discord.md deleted file mode 100644 index 3dc643d7c..000000000 --- a/documentation/content/oauth/providers/discord.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: "Discord OAuth provider" -description: "Learn how to use the Discord OAuth provider" ---- - -OAuth integration for Discord. Refer to [Discord API documentation](https://discord.com/developers/docs/getting-started) for getting the required credentials. Provider id is `discord`. - -```ts -import { discord } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const discordAuth = discord(auth, config); -``` - -The `identify` scope is always included regardless of provided `scope` config. - -## `discord()` - -```ts -const discord: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => DiscordProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | -------------------------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Discord OAuth app client id | | -| `config.clientSecret` | `string` | Discord OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.scope` | `string[]` | an array of scopes - `identify` is always included | ✓ | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`DiscordProvider`](#discordprovider) | Discord provider | - -## Interfaces - -### `DiscordAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface DiscordAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------------- | -| [`DiscordUserAuth`](#discorduserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `DiscordTokens` - -```ts -type DiscordTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; -``` - -### `DiscordUser` - -```ts -type DiscordUser = { - id: string; - username: string; - discriminator: string; - global_name: string | null; - avatar: string | null; - bot?: boolean; - system?: boolean; - mfa_enabled?: boolean; - verified?: boolean; - email?: string | null; - flags?: number; - banner?: string | null; - accent_color?: number | null; - premium_type?: number; - public_flags?: number; - locale?: string; - avatar_decoration?: string | null; -}; -``` - -### `DiscordUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface DiscordUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - discordUser: DiscordUser; - discordTokens: DiscordTokens; -} -``` - -| properties | type | description | -| --------------- | --------------------------------- | ----------------- | -| `discordUser` | [`DiscordUser`](#discorduser) | Discord user | -| `discordTokens` | [`DiscordTokens`](#discordtokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/dropbox.md b/documentation/content/oauth/providers/dropbox.md deleted file mode 100644 index 1e53a07fc..000000000 --- a/documentation/content/oauth/providers/dropbox.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -title: "Dropbox OAuth provider" -description: "Learn how to use the Dropbox OAuth provider" ---- - -OAuth integration for Dropbox. Provider id is `dropbox`. - -```ts -import { dropbox } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const dropboxAuth = dropbox(auth, configs); -``` - -## `dropbox()` - -Scope `account_info.read` is always included. - -```ts -const dropbox: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - tokenAccessType?: "online" | "offline"; - } -) => DropboxProvider; -``` - -##### Parameters - -| name | type | description | optional | default | -| ------------------------ | ------------------------------------------ | ---------------------------------------- | :------: | ---------- | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | | -| `config.clientId` | `string` | Dropbox OAuth app client id | | | -| `config.clientSecret` | `string` | Dropbox OAuth app client secret | | | -| `config.redirectUri` | `string` | an authorized redirect URI | | | -| `config.scope` | `string[]` | an array of scopes | ✓ | | -| `config.tokenAccessType` | `"online" \| "offline"` | set to `"offline"` to get refresh tokens | ✓ | `"online"` | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`DropboxProvider`](#dropboxprovider) | Dropbox provider | - -## Interfaces - -### `DropboxAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface DropboxAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------------- | -| [`DropboxUserAuth`](#dropboxuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `DropboxTokens` - -```ts -type DropboxTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string | null; -}; -``` - -### `DropboxUser` - -```ts -type DropboxUser = PairedDropBoxUser | UnpairedDropboxUser; -``` - -```ts -type PairedDropBoxUser = BaseDropboxUser & { - is_paired: true; - team: { - id: string; - name: string; - office_addin_policy: Record; - sharing_policies: Record>; - }; -}; - -type UnpairedDropboxUser = BaseDropboxUser & { - is_paired: false; -}; - -type BaseDropboxUser = { - account_id: string; - country: string; - disabled: boolean; - email: string; - email_verified: boolean; - locale: string; - name: { - abbreviated_name: string; - display_name: string; - familiar_name: string; - given_name: string; - surname: string; - }; - profile_photo_url: string; -}; -``` - -### `DropboxUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface DropboxUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - dropboxUser: DropboxUser; - dropboxTokens: DropboxTokens; -} -``` - -| properties | type | description | -| --------------- | --------------------------------- | ----------------- | -| `dropboxUser` | [`DropboxUser`](#dropboxuser) | Dropbox user | -| `dropboxTokens` | [`DropboxTokens`](#dropboxtokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/facebook.md b/documentation/content/oauth/providers/facebook.md deleted file mode 100644 index 9737e3b0b..000000000 --- a/documentation/content/oauth/providers/facebook.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: "Facebook OAuth provider" -description: "Learn how to use the Facebook OAuth provider" ---- - -OAuth integration for Facebook. Refer to step 1 of [Facebook Login documentation](https://developers.facebook.com/docs/facebook-login/web) for getting the required credentials. Provider id is `facebook`. - -```ts -import { facebook } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const facebookAuth = facebook(auth, config); -``` - -The `identity` scope is always included regardless of provided `scope` config. - -## `facebook()` - -```ts -const facebook: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => FacebookProvider; -``` - -##### Parameters - -Scope `identity` is always included. - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | -------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Facebook OAuth app client id | | -| `config.clientSecret` | `string` | Facebook OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| --------------------------------------- | ----------------- | -| [`FacebookProvider`](#facebookprovider) | Facebook provider | - -## Interfaces - -### `FacebookAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface FacebookAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| --------------------------------------- | -| [`FacebookUserAuth`](#facebookuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `FacebookTokens` - -```ts -type FacebookTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; -``` - -### `FacebookUser` - -`email` is only included if `email` scope if provided. - -```ts -type FacebookUser = { - id: string; - name: string; - email?: string; - picture: { - data: { - height: number; - is_silhouette: boolean; - url: string; - width: number; - }; - }; -}; -``` - -### `FacebookUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface FacebookUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - facebookUser: FacebookUser; - facebookTokens: FacebookTokens; -} -``` - -| properties | type | description | -| ---------------- | ----------------------------------- | ----------------- | -| `facebookUser` | [`FacebookUser`](#facebookuser) | Facebook user | -| `facebookTokens` | [`FacebookTokens`](#facebooktokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/github.md b/documentation/content/oauth/providers/github.md deleted file mode 100644 index be15ebf3f..000000000 --- a/documentation/content/oauth/providers/github.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -title: "GitHub OAuth provider" -description: "Learn how to use the GitHub OAuth provider" ---- - -OAuth integration for GitHub. Refer to [Create a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) for getting the required credentials. Provider id is `github`. - -```ts -import { github } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const githubAuth = github(auth, config); -``` - -## `github()` - -```ts -const github: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri?: string; - } -) => GithubProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ------------------------------ | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | GitHub OAuth app client id | | -| `config.clientSecret` | `string` | GitHub OAuth app client secret | | -| `config.scope` | `string[]` | an array of scopes | ✓ | -| `config.redirectUri` | `string` | an authorized redirect URI | ✓ | - -##### Returns - -| type | description | -| ----------------------------------- | --------------- | -| [`GithubProvider`](#githubprovider) | GitHub provider | - -## Interfaces - -### `GithubAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface GithubAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------- | -| [`GithubUserAuth`](#githubuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `GithubTokens` - -```ts -type GithubTokens = - | { - accessToken: string; - accessTokenExpiresIn: null; - } - | { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; - refreshTokenExpiresIn: number; - }; -``` - -### `GithubUser` - -```ts -type GithubUser = PublicGithubUser | PrivateGithubUser; - -type PublicGithubUser = { - avatar_url: string; - bio: string | null; - blog: string | null; - company: string | null; - created_at: string; - email: string | null; - events_url: string; - followers: number; - followers_url: string; - following: number; - following_url: string; - gists_url: string; - gravatar_id: string | null; - hireable: boolean | null; - html_url: string; - id: number; - location: string | null; - login: string; - name: string | null; - node_id: string; - organizations_url: string; - public_gists: number; - public_repos: number; - received_events_url: string; - repos_url: string; - site_admin: boolean; - starred_url: string; - subscriptions_url: string; - type: string; - updated_at: string; - url: string; - - twitter_username?: string | null; - plan?: { - name: string; - space: number; - private_repos: number; - collaborators: number; - }; - suspended_at?: string | null; -}; - -type PrivateGithubUser = PublicGithubUser & { - collaborators: number; - disk_usage: number; - owned_private_repos: number; - private_gists: number; - total_private_repos: number; - two_factor_authentication: boolean; - - business_plus?: boolean; - ldap_dn?: string; -}; -``` - -### `GithubUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface GithubUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - githubUser: GithubUser; - githubTokens: GithubTokens; -} -``` - -| properties | type | description | -| -------------- | ------------------------------- | ----------------- | -| `githubUser` | [`GithubUser`](#githubuser) | GitHub user | -| `githubTokens` | [`GithubTokens`](#githubtokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/gitlab.md b/documentation/content/oauth/providers/gitlab.md deleted file mode 100644 index 1d4639559..000000000 --- a/documentation/content/oauth/providers/gitlab.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -title: "GitLab OAuth provider" -description: "Learn how to use the GitLab OAuth provider" ---- - -OAuth integration for GitLab. Provider id is `gitlab`. - -```ts -import { gitlab } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const gitlabAuth = gitlab(auth, configs); -``` - -## `gitlab()` - -Scope `read_user` is always included. - -```ts -const gitlab: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - serverUrl?: string; - } -) => GitlabProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | --------------------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | GitLab OAuth app client id | | -| `config.clientSecret` | `string` | GitLab OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | -| `config.serverUrl` | `string` | URL of GitLab, to use a self-managed instance | ✓ | - -##### Returns - -| type | description | -| ----------------------------------- | --------------- | -| [`GitlabProvider`](#gitlabprovider) | GitLab provider | - -## Interfaces - -### `GitlabAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface GitlabAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------- | -| [`GitlabUserAuth`](#gitlabuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `GitlabTokens` - -```ts -type GitlabTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; -}; -``` - -### `GitlabUser` - -```ts -type GitlabUser = { - id: number; - username: string; - email: string; - name: string; - state: string; - avatar_url: string; - web_url: string; - created_at: string; - bio: string; - public_email: string; - skype: string; - linkedin: string; - twitter: string; - discord: string; - website_url: string; - organization: string; - job_title: string; - pronouns: string; - bot: boolean; - work_information: string | null; - followers: number; - following: number; - local_time: string; - last_sign_in_at: string; - confirmed_at: string; - theme_id: number; - last_activity_on: string; - color_scheme_id: number; - projects_limit: number; - current_sign_in_at: string; - identities: { provider: string; extern_uid: string }[]; - can_create_group: boolean; - can_create_project: boolean; - two_factor_enabled: boolean; - external: boolean; - private_profile: boolean; - commit_email: string; -}; -``` - -### `GitlabUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface GitlabUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - gitlabUser: GitlabUser; - gitlabTokens: GitlabTokens; -} -``` - -| properties | type | description | -| -------------- | ------------------------------- | ----------------- | -| `gitlabUser` | [`GitlabUser`](#gitlabuser) | GitLab user | -| `gitlabTokens` | [`GitlabTokens`](#gitlabtokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/google.md b/documentation/content/oauth/providers/google.md deleted file mode 100644 index 9f9485d29..000000000 --- a/documentation/content/oauth/providers/google.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: "Google OAuth provider" -description: "Learn how to use the Google OAuth provider" ---- - -OAuth integration for Google. Refer to [Google OAuth documentation](https://developers.google.com/identity/protocols/oauth2/web-server#httprests) for getting the required credentials. Provider id is `google`. - -```ts -import { google } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const googleAuth = google(auth, configs); -``` - -## `google()` - -```ts -const google: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - accessType?: "online" | "offline"; - } -) => GoogleProvider; -``` - -##### Parameters - -| name | type | description | optional | default | -| --------------------- | ------------------------------------------ | ---------------------------------------- | :------: | ---------- | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | | -| `config.clientId` | `string` | Google OAuth app client id | | | -| `config.clientSecret` | `string` | Google OAuth app client secret | | | -| `config.redirectUri` | `string` | an authorized redirect URI | | | -| `config.scope` | `string[]` | an array of scopes | ✓ | | -| `config.accessType` | `"online" \| "offline"` | set to `"offline"` to get refresh tokens | ✓ | `"online"` | - -##### Returns - -| type | description | -| ----------------------------------- | --------------- | -| [`GoogleProvider`](#googleprovider) | Google provider | - -## Interfaces - -### `GoogleAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface GoogleAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------- | -| [`GoogleUserAuth`](#googleuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `GoogleTokens` - -```ts -type GoogleTokens = { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresIn: number; -}; -``` - -### `GoogleUser` - -```ts -type GoogleUser = { - sub: string; - name: string; - given_name: string; - family_name: string; - picture: string; - locale: string; - email?: string; - email_verified?: boolean; - hd?: string; -}; -``` - -### `GoogleUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface GoogleUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - googleUser: GoogleUser; - googleTokens: GoogleTokens; -} -``` - -| properties | type | description | -| -------------- | ------------------------------- | ----------------- | -| `googleUser` | [`GoogleUser`](#googleuser) | Google user | -| `googleTokens` | [`GoogleTokens`](#googletokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/kakao.md b/documentation/content/oauth/providers/kakao.md deleted file mode 100644 index 9343a2204..000000000 --- a/documentation/content/oauth/providers/kakao.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -title: "Kakao OAuth provider" -description: "Learn how to use the Kakao OAuth provider" ---- - -OAuth integration for Kakao. Refer to [Prerequisites](https://developers.kakao.com/docs/latest/en/kakaologin/prerequisite) and [REST API docs for Kakao login](https://developers.kakao.com/docs/latest/en/kakaologin/rest-api) for getting the required credentials. Provider id is `kakao`. - -```ts -import { kakao } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const kakaoAuth = kakao(auth, config); -``` - -## `kakao()` - -```ts -const kakao: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => KakaoProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ----------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Kakao OAuth app client id | | -| `config.clientSecret` | `string` | Kakao OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | ✓ | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| --------------------------------- | -------------- | -| [`KakaoProvider`](#kakaoprovider) | Kakao provider | - -## Interfaces - -### `KakaoAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface KakaoAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| --------------------------------- | -| [`KakaoUserAuth`](#kakaouserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `KakaoTokens` - -```ts -type KakaoTokens = { - accessToken: string; - expiresIn: number; - refreshToken: string; - refreshTokenExpiresIn: number; -}; -``` - -### `KakaoUser` - -```ts -type KakaoUser = { - id: number; - has_signed_up?: boolean; - connected_at?: string; - synced_at?: string; - properties?: Record; - kakao_account?: KakaoAccount; - for_partner?: Partner; -}; - -type KakaoAccount = { - profile_needs_agreement?: boolean; - profile_nickname_needs_agreement?: boolean; - profile_image_needs_agreement?: boolean; - profile?: Profile; - email_needs_agreement?: boolean; - is_email_valid?: boolean; - is_email_verified?: boolean; - email?: string; - name_needs_agreement?: boolean; - name?: string; - age_range_needs_agreement?: boolean; - // "1~9, 10~14, 15~19, 20~29, 30~39, 40~49, 50~59, 60~69, 70~79, 80~89, 90~"; - ag_range?: - | "1~9" - | "10~14" - | "15~19" - | "20~29" - | "30~39" - | "40~49" - | "50~59" - | "60~69" - | "70~79" - | "80~89" - | "90~"; - birthyear_needs_agreement?: boolean; - birthyear?: string; // "YYYY"; - birthday_needs_agreement?: boolean; - birthday?: string; // "MMDD"; - birthday_type?: "SOLAR" | "LUNAR"; - gender_needs_agreement?: boolean; - gender?: "female" | "male"; - phone_number_needs_agreement?: boolean; - phone_number?: string; - ci_needs_agreement?: boolean; - ci?: string; - ci_authenticated_at?: string; -}; - -type Profile = { - nickname?: string; - thumbnail_image_url?: string; - profile_image_url?: string; - is_default_image?: boolean; -}; - -type Partner = { - uuid?: string; -}; -``` - -### `KakaoUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface KakaoUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - kakaoUser: KakaoUser; - kakaoTokens: KakaoTokens; -} -``` - -| properties | type | description | -| ------------- | ----------------------------- | ----------------- | -| `kakaoUser` | [`KakaoUser`](#kakaouser) | Kakao user | -| `kakaoTokens` | [`KakaoTokens`](#kakaotokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/keycloak.md b/documentation/content/oauth/providers/keycloak.md deleted file mode 100644 index 2310a7e89..000000000 --- a/documentation/content/oauth/providers/keycloak.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -title: "Keycloak OAuth provider" -description: "Learn how to use the Keycloak OAuth provider" ---- - -OAuth integration for Keycloak. Refer to [Keycloak Documentation](https://www.keycloak.org/docs/latest/authorization_services/index.html) for getting the required credentials. Provider id is `keycloak`. - -```ts -import { keycloak } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const keycloakAuth = keycloak(auth, config); -``` - -## `keycloak()` - -```ts -const keycloak: ( - auth: Auth, - config: { - domain: string; - realm: string; - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri?: string; - } -) => KeycloakProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | --------------------------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.domain` | `string` | Keycloak OAuth app client id (e.g. 'my.domain.com') | | -| `config.realm` | `string` | Keycloak Realm of client | | -| `config.clientId` | `string` | Keycloak OAuth app client id | | -| `config.clientSecret` | `string` | Keycloak OAuth app client secret | | -| `config.scope` | `string[]` | an array of scopes | ✓ | -| `config.redirectUri` | `string` | an authorized redirect URI | ✓ | - -##### Returns - -| type | description | -| --------------------------------------- | ----------------- | -| [`KeycloakProvider`](#keycloakprovider) | Keycloak provider | - -## Interfaces - -### `KeycloakAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> - -interface KeycloakAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| --------------------------------------- | -| [`KeycloakUserAuth`](#keycloakuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `KeycloakTokens` - -```ts -type KeycloakTokens = { - accessToken: string; - accessTokenExpiresIn: number; - authTime: number; - issuedAtTime: number; - expirationTime: number; - refreshToken: string | null; - refreshTokenExpiresIn: number | null; -}; -``` - -### `KeycloakUser` - -```ts -type KeycloakUser = { - exp: number; - iat: number; - auth_time: number; - jti: string; - iss: string; - aud: string; - sub: string; - typ: string; - azp: string; - session_state: string; - at_hash: string; - acr: string; - sid: string; - email_verified: boolean; - name: string; - preferred_username: string; - given_name: string; - locale: string; - family_name: string; - email: string; - picture: string; - user: any; -}; -``` - -### `KeycloakRole` - -```ts -type KeycloakUser = PublicKeycloakUser | PrivateKeycloakUser; - -type KeycloakRole = { - role_type: "realm" | "resource"; - - client: null | string; // null if realm_access - - role: string; -}; -``` - -### `KeycloakUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface KeycloakUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - keycloakUser: KeycloakUser; - keycloakTokens: KeycloakTokens; - keycloakRoles: KeycloakRoles; -} -``` - -| properties | type | description | -| ---------------- | ----------------------------------- | ---------------------------------------- | -| `keycloakUser` | [`KeycloakUser`](#keycloakuser) | Keycloak user | -| `keycloakTokens` | [`KeycloakTokens`](#keycloaktokens) | Access tokens etc | -| `keycloakRoles` | [`KeycloakRoles`](#keycloakroles) | Keycloak roles retrieved from OIDC Token | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/lichess.md b/documentation/content/oauth/providers/lichess.md deleted file mode 100644 index e59f1f8ff..000000000 --- a/documentation/content/oauth/providers/lichess.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: "Lichess OAuth provider" -description: "Learn how to use the Lichess OAuth provider" ---- - -OAuth integration for Lichess. Provider id is `lichess`. - -```ts -import { lichess } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const lichessAuth = lichess(auth, config); -``` - -## `lichess()` - -```ts -const lichess: ( - auth: Auth, - config: { - clientId: string; - redirectUri: string; - scope?: string[]; - } -) => LichessProvider; -``` - -##### Parameter - -| name | type | description | optional | -| -------------------- | ------------------------------------------ | --------------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | client id - choose any unique client id | | -| `config.redirectUri` | `string` | redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`LichessProvider`](#lichessprovider) | Lichess provider | - -## Interfaces - -### `LichessAuth` - -See [`OAuth2ProviderAuthWithPKCE`](/reference/oauth/interfaces/oauth2providerauthwithpkce). - -```ts -// implements OAuth2ProviderAuthWithPKCE> -interface LichessAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise< - readonly [url: URL, codeVerifier: string, state: string] - >; - validateCallback: ( - code: string, - codeVerifier: string - ) => Promise>; -} -``` - -| type | -| ------------------------------------- | -| [`LichessUserAuth`](#lichessuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `LichessTokens` - -```ts -type LichessTokens = { - accessToken: string; - accessTokenExpiresIn: number; -}; -``` - -### `LichessUser` - -```ts -type LichessUser = { - id: string; - username: string; -}; -``` - -### `LichessUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface LichessUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - lichessUser: LichessUser; - lichessTokens: LichessTokens; -} -``` - -| properties | type | description | -| --------------- | --------------------------------- | ----------------- | -| `lichessUser` | [`LichessUser`](#lichessuser) | Lichess user | -| `lichessTokens` | [`LichessTokens`](#lichesstokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/line.md b/documentation/content/oauth/providers/line.md deleted file mode 100644 index e4f7763c6..000000000 --- a/documentation/content/oauth/providers/line.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -title: "Line OAuth provider" -description: "Learn how to use the Line OAuth provider" ---- - -OAuth 2.0 integration for Line (v2.1). Provider id is `line`. - -```ts -import { line } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const lineAuth = line(auth, configs); -``` - -## `line()` - -Scopes `oidc` are `profile` are always included. - -```ts -const line: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => LineProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ---------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Line OAuth app client id | | -| `config.clientSecret` | `string` | Line OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ------------------------------- | ------------- | -| [`LineProvider`](#lineprovider) | Line provider | - -## Interfaces - -### `LineAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface LineAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------- | -| [`LineUserAuth`](#lineuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `LineTokens` - -```ts -type LineTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; - idToken: string; -}; -``` - -### `LineUser` - -Add `email` scope to get `LineUser.email`. - -```ts -type LineUser = { - userId: string; - displayName: string; - pictureUrl: string; - statusMessage: string; - email: string | null; -}; -``` - -### `LineUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface LineUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - lineUser: LineUser; - lineTokens: LineTokens; -} -``` - -| properties | type | description | -| ------------ | --------------------------- | ----------------- | -| `lineUser` | [`LineUser`](#lineuser) | Line user | -| `lineTokens` | [`LineTokens`](#linetokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/linkedin.md b/documentation/content/oauth/providers/linkedin.md deleted file mode 100644 index 76ced564d..000000000 --- a/documentation/content/oauth/providers/linkedin.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -title: "LinkedIn OAuth provider" -description: "Learn how to use the LinkedIn OAuth provider" ---- - -OAuth integration for LinkedIn. Refer to [LinkedIn OAuth documentation](https:/.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?tabs=HTTPS1) for getting the required credentials. Provider id is `linkedin`. - -```ts -import { linkedIn } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const linkedInAuth = linkedIn(auth, config); -``` - -## `linkedIn()` - -```ts -const linkedIn: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => LinkedInProvider; -``` - -##### Parameters - -Scope `profile` and `openid` are always included. - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | -------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | LinkedIn OAuth app client id | | -| `config.clientSecret` | `string` | LinkedIn OAuth app client secret | | -| `config.redirectUri` | `string` | LinkedIn OAuth app redirect uri | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| --------------------------------------- | ----------------- | -| [`LinkedInProvider`](#linkedinprovider) | LinkedIn provider | - -## Interfaces - -### `LinkedInAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface LinkedInAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| --------------------------------------- | -| [`LinkedInUserAuth`](#linkedinuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `LinkedInTokens` - -```ts -type LinkedInTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; - refreshTokenExpiresIn: number; -}; -``` - -### `LinkedInUser` - -```ts -type LinkedInUser = { - sub: string; - name: string; - email: string; - email_verified: boolean; - given_name: string; - family_name: string; - locale: { - country: string; - language: string; - }; - picture: string; -}; -``` - -### `LinkedInUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface LinkedInUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - linkedInUser: LinkedInUser; - linkedInTokens: LinkedInTokens; -} -``` - -| properties | type | description | -| ---------------- | ----------------------------------- | ----------------- | -| `linkedInUser` | [`LinkedInUser`](#linkedinuser) | LinkedIn user | -| `linkedInTokens` | [`LinkedInTokens`](#linkedintokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/osu.md b/documentation/content/oauth/providers/osu.md deleted file mode 100644 index d9cb2b4e6..000000000 --- a/documentation/content/oauth/providers/osu.md +++ /dev/null @@ -1,254 +0,0 @@ ---- -title: "osu! OAuth provider" -description: "Learn how to use the osu! OAuth provider" ---- - -OAuth integration for osu!. Refer to [osu! OAuth documentation](https://osu.ppy.sh/docs/index.html#authentication) for getting the required credentials. Provider id is `osu`. - -```ts -import { osu } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const osuAuth = osu(auth, config); -``` - -## `osu()` - -```ts -const osu: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => OsuProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ----------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | osu! OAuth app client id | | -| `config.clientSecret` | `string` | osu! OAuth app client secret | | -| `config.redirectUri` | `string` | one of the authorized redirect URIs | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ----------------------------- | ------------- | -| [`OsuProvider`](#osuprovider) | osu! provider | - -## Interfaces - -### `OsuAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface OsuAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------- | -| [`OsuUserAuth`](#osuuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `OsuTokens` - -```ts -type OsuTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; -``` - -### `OsuUser` - -```ts -type OsuUser = { - avatar_url: string; - country_code: string; - default_group: string; - id: number; - is_active: boolean; - is_bot: boolean; - is_deleted: boolean; - is_online: boolean; - is_supporter: boolean; - last_visit: string; - pm_friends_only: boolean; - profile_colour: string | null; - username: string; - country: { - code: string; - name: string; - }; - cover: { - custom_url: string | null; - url: string; - id: string | null; - }; - discord: string | null; - has_supported: boolean; - interests: string | null; - join_date: string; - kudosu: { - available: number; - total: number; - }; - location: string | null; - max_blocks: number; - max_friends: number; - occupation: string | null; - playmode: OsuGameMode; - playstyle: ("mouse" | "keyboard" | "tablet" | "touch")[]; - post_count: number; - profile_order: ( - | "me" - | "recent_activity" - | "beatmaps" - | "historical" - | "kudosu" - | "top_ranks" - | "medals" - )[]; - title: string | null; - title_url: string | null; - twitter: string | null; - website: string | null; - is_restricted: boolean; - account_history: { - description: string | null; - id: number; - length: number; - permanent: boolean; - timestamp: string; - type: "note" | "restriction" | "silence"; - }[]; - active_tournament_banner: { - id: number; - tournament_id: number; - image: string; - } | null; - badges: { - awarded_at: string; - description: string; - image_url: string; - url: string; - }[]; - beatmap_playcounts_count: number; - favourite_beatmapset_count: number; - follower_count: number; - graveyard_beatmapset_count: number; - groups: { - colour: string | null; - has_listing: boolean; - has_playmodes: boolean; - id: number; - identifier: string; - is_probationary: boolean; - name: string; - short_name: string; - playmodes: OsuGameMode[] | null; - }[]; - loved_beatmapset_count: number; - mapping_follower_count: number; - monthly_playcounts: { - start_date: string; - count: number; - }[]; - page: { - html: string; - raw: string; - }; - pending_beatmapset_count: number; - previous_usernames: string[]; - rank_highest: { - rank: number; - updated_at: string; - } | null; - rank_history: { - mode: OsuGameMode; - data: number[]; - }; - ranked_beatmapset_count: number; - replays_watched_counts: { - start_date: string; - count: number; - }[]; - scores_best_count: number; - scores_first_count: number; - scores_recent_count: number; - statistics: OsuUserStatistics; - statistics_rulesets: Record; - support_level: number; - user_achievements: { - achieved_at: string; - achievement_id: number; - }[]; -}; - -type OsuUserStatistics = { - grade_counts: { - a: number; - s: number; - sh: number; - ss: number; - ssh: number; - }; - hit_accuracy: number; - is_ranked: boolean; - level: { - current: number; - progress: number; - }; - maximum_combo: number; - play_count: number; - play_time: number; - pp: number; - global_rank: number; - ranked_score: number; - replays_watched_by_others: number; - total_hits: number; - total_score: number; - country_rank: number; -}; - -type OsuGameMode = "fruits" | "mania" | "osu" | "taiko"; -``` - -### `OsuUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface OsuUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - osuUser: OsuUser; - osuTokens: OsuTokens; -} -``` - -| properties | type | description | -| ----------- | ------------------------- | ----------------- | -| `osuUser` | [`OsuUser`](#osuuser) | Osu user | -| `osuTokens` | [`OsuTokens`](#osutokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/patreon.md b/documentation/content/oauth/providers/patreon.md deleted file mode 100644 index 65ffe130d..000000000 --- a/documentation/content/oauth/providers/patreon.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "Patreon OAuth provider" -description: "Learn how to use the Patreon OAuth provider" ---- - -OAuth integration for Patreon. Refer to [Patreon OAuth documentation](https://docs.patreon.com/#clients-and-api-keys) for getting the required credentials. Provider id is `patreon`. - -```ts -import { patreon } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const patreonAuth = patreon(auth, configs); -``` - -The `identity` scope is always included regardless of provided `scope` config. - -## `patreon()` - -```ts -const patreon: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => PatreonProvider; -``` - -##### Parameters - -Scope `identity` is always included. - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ----------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Patreon OAuth app client id | | -| `config.clientSecret` | `string` | Patreon OAuth app client secret | | -| `config.redirectUri` | `string` | one of the authorized redirect URIs | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`PatreonProvider`](#patreonprovider) | Patreon provider | - -## Interfaces - -### `PatreonAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface PatreonAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------------- | -| [`PatreonUserAuth`](#patreonuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `PatreonTokens` - -```ts -type PatreonTokens = { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresIn: number; -}; -``` - -### `PatreonUser` - -```ts -type PatreonUser = { - id: string; - attributes: { - about: string | null; - created: string; - email?: string; - full_name: string; - hide_pledges: boolean | null; - image_url: string; - is_email_verified: boolean; - url: string; - }; -}; -``` - -### `PatreonUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface PatreonUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - patreonUser: PatreonUser; - patreonTokens: PatreonTokens; -} -``` - -| properties | type | description | -| --------------- | --------------------------------- | ----------------- | -| `patreonUser` | [`PatreonUser`](#patreonuser) | Patreon user | -| `patreonTokens` | [`PatreonTokens`](#patreontokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/reddit.md b/documentation/content/oauth/providers/reddit.md deleted file mode 100644 index 2a7392bdc..000000000 --- a/documentation/content/oauth/providers/reddit.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -title: "Reddit OAuth provider" -description: "Learn how to use the Reddit OAuth provider" ---- - -OAuth integration for Reddit. Refer to [Reddit OAuth documentation archive](https://github.com/reddit-archive/reddit/wiki/OAuth2) for getting the required credentials. Provider id is `reddit`. - -```ts -import { reddit } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const redditAuth = reddit(auth, configs); -``` - -## `reddit()` - -Scope `identity` is always selected. - -```ts -const reddit: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - tokenDuration?: "permanent" | "temporary"; - } -) => RedditProvider; -``` - -##### Parameters - -| name | type | description | optional | default | -| ---------------------- | ------------------------------------------ | ------------------------------ | :------: | ------------- | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | | -| `config.clientId` | `string` | Reddit OAuth app client id | | | -| `config.clientSecret` | `string` | Reddit OAuth app client secret | | | -| `config.redirectUri` | `string` | Reddit OAuth app redirect Uri | | | -| `config.scope` | `string[]` | an array of scopes | ✓ | | -| `config.tokenDuration` | `"permanent" \| "temporary"` | access token duration | ✓ | `"permanent"` | - -##### Returns - -| type | description | -| ----------------------------------- | --------------- | -| [`RedditProvider`](#redditprovider) | Reddit provider | - -## Interfaces - -### `RedditAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface RedditAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------- | -| [`RedditUserAuth`](#reddituserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `RedditTokens` - -```ts -type RedditTokens = { - accessToken: string; -}; -``` - -### `RedditUser` - -```ts -type RedditUser = { - is_employee: boolean; - seen_layout_switch: boolean; - has_visited_new_profile: boolean; - pref_no_profanity: boolean; - has_external_account: boolean; - pref_geopopular: string; - seen_redesign_modal: boolean; - pref_show_trending: boolean; - subreddit: { - default_set: boolean; - user_is_contributor: boolean; - banner_img: string; - restrict_posting: boolean; - user_is_banned: boolean; - free_form_reports: boolean; - community_icon: string; - show_media: boolean; - icon_color: string; - user_is_muted: boolean; - display_name: string; - header_img: string; - title: string; - coins: number; - previous_names: string[]; - over_18: boolean; - icon_size: [number, number]; - primary_color: string; - icon_img: string; - description: string; - allowed_media_in_comments: any[]; - submit_link_label: string; - header_size: any; - restrict_commenting: boolean; - subscribers: number; - submit_text_label: string; - is_default_icon: boolean; - link_flair_position: string; - display_name_prefixed: string; - key_color: string; - name: string; - is_default_banner: boolean; - url: string; - quarantine: boolean; - banner_size: [number, number]; - user_is_moderator: boolean; - accept_followers: boolean; - public_description: string; - link_flair_enabled: boolean; - disable_contributor_requests: boolean; - subreddit_type: string; - user_is_subscriber: boolean; - }; - pref_show_presence: boolean; - snoovatar_img: string; - snoovatar_size: [number, number]; - gold_expiration: any; - has_gold_subscription: boolean; - is_sponsor: boolean; - num_friends: number; - features: { - mod_service_mute_writes: boolean; - promoted_trend_blanks: boolean; - show_amp_link: boolean; - chat: boolean; - is_email_permission_required: true; - mod_awards: boolean; - expensive_coins_package: boolean; - mweb_xpromo_revamp_v2: { - owner: string; - variant: string; - experiment_id: number; - }; - awards_on_streams: boolean; - mweb_xpromo_modal_listing_click_daily_dismissible_ios: true; - chat_subreddit: boolean; - cookie_consent_banner: boolean; - modlog_copyright_removal: boolean; - do_not_track: boolean; - images_in_comments: boolean; - mod_service_mute_reads: boolean; - chat_user_settings: boolean; - use_pref_account_deployment: boolean; - mweb_xpromo_interstitial_comments_ios: boolean; - mweb_xpromo_modal_listing_click_daily_dismissible_android: boolean; - premium_subscriptions_table: boolean; - mweb_xpromo_interstitial_comments_android: true; - crowd_control_for_post: boolean; - mweb_nsfw_xpromo: { owner: string; variant: string; experiment_id: number }; - noreferrer_to_noopener: boolean; - chat_group_rollout: boolean; - resized_styles_images: boolean; - spez_modal: boolean; - mweb_sharing_clipboard: { - owner: string; - variant: string; - experiment_id: number; - }; - }; - can_edit_name: boolean; - verified: boolean; - pref_autoplay: boolean; - coins: number; - has_paypal_subscription: boolean; - has_subscribed_to_premium: boolean; - id: string; - has_stripe_subscription: boolean; - oauth_client_id: string; - can_create_subreddit: boolean; - over_18: boolean; - is_gold: boolean; - is_mod: boolean; - awarder_karma: number; - suspension_expiration_utc: any; - has_verified_email: boolean; - is_suspended: boolean; - pref_video_autoplay: boolean; - has_android_subscription: boolean; - in_redesign_beta: boolean; - icon_img: string; - pref_nightmode: boolean; - awardee_karma: number; - hide_from_robots: boolean; - password_set: boolean; - link_karma: number; - force_password_reset: boolean; - total_karma: number; - seen_give_award_tooltip: boolean; - inbox_count: number; - seen_premium_adblock_modal: boolean; - pref_top_karma_subreddits: boolean; - pref_show_snoovatar: boolean; - name: string; - pref_clickgadget: number; - created: number; - gold_creddits: number; - created_utc: number; - has_ios_subscription: boolean; - pref_show_twitter: boolean; - in_beta: boolean; - comment_karma: number; - accept_followers: boolean; - has_subscribed: boolean; - linked_identities: any[]; - seen_subreddit_chat_ftux: boolean; -}; -``` - -### `RedditUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface RedditUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - redditUser: RedditUser; - redditTokens: RedditTokens; -} -``` - -| properties | type | description | -| -------------- | ------------------------------- | ----------------- | -| `redditUser` | [`RedditUser`](#reddituser) | Reddit user | -| `redditTokens` | [`RedditTokens`](#reddittokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/salesforce.md b/documentation/content/oauth/providers/salesforce.md deleted file mode 100644 index 638d6d1ae..000000000 --- a/documentation/content/oauth/providers/salesforce.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: "Salesforce OAuth provider" -description: "Learn how to use the Salesforce OAuth provider" ---- - -OAuth 2.0 (Authorization code) integration for Salesforce. Provider id is `salesforce`. - -```ts -import { salesforce } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const salesforceAuth = salesforce(auth, configs); -``` - -## `salesforce()` - -Scopes `oidc`, `profile`, and `id` are always included. - -```ts -const salesforce: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => SalesforceProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ---------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Salesforce OAuth app client id | | -| `config.clientSecret` | `string` | Salesforce OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ------------------------------------------- | ------------------- | -| [`SalesforceProvider`](#salesforceprovider) | Salesforce provider | - -## Interfaces - -### `SalesforceAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface SalesforceAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------------------- | -| [`SalesforceUserAuth`](#salesforceuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `SalesforceTokens` - -```ts -type SalesforceTokens = { - accessToken: string; - idToken: string; - refreshToken: string | null; -}; -``` - -### `SalesforceUser` - -```ts -type SalesforceUser = { - sub: string; // URL - user_id: string; - organization_id: string; - name: string; - email?: string; - email_verified: boolean; - given_name: string; - family_name: string; - zoneinfo: string; - photos: { - picture: string; - thumbnail: string; - }; - profile: string; - picture: string; - address?: Record; - urls: Record; - active: boolean; - user_type: string; - language: string; - locale: string; - utcOffset: number; - updated_at: string; -}; -``` - -### `SalesforceUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface SalesforceUserAuth<_Auth extends Auth> - extends ProviderUserAuth<_Auth> { - salesforceUser: SalesforceUser; - salesforceTokens: SalesforceTokens; -} -``` - -| properties | type | description | -| ------------------ | --------------------------------------- | ----------------- | -| `salesforceUser` | [`SalesforceUser`](#salesforceuser) | Salesforce user | -| `salesforceTokens` | [`SalesforceTokens`](#salesforcetokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/slack.md b/documentation/content/oauth/providers/slack.md deleted file mode 100644 index d1ba63d09..000000000 --- a/documentation/content/oauth/providers/slack.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: "Slack OAuth provider" -description: "Learn how to use the Salck OAuth provider" ---- - -OAuth integration for Slack. Provider id is `slack`. - -```ts -import { slack } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const slackAuth = slack(auth, configs); -``` - -## `slack()` - -Scopes `openid` and `profile` are always included. - -```ts -const slack: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => SlackProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ------------------------------------------ | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Slack OAuth app client id | | -| `config.clientSecret` | `string` | Slack OAuth app client secret | | -| `config.redirectUri` | `string` | an authorized redirect URI (must be HTTPS) | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| --------------------------------- | -------------- | -| [`SlackProvider`](#slackprovider) | Slack provider | - -## Interfaces - -### `SlackAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface SlackAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| --------------------------------- | -| [`SlackUserAuth`](#slackuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `SlackTokens` - -```ts -type SlackTokens = { - accessToken: string; - idToken: string; -}; -``` - -### `SlackUser` - -```ts -type SlackUser = { - sub: string; - "https://slack.com/user_id": string; - "https://slack.com/team_id": string; - email?: string; - email_verified: boolean; - date_email_verified: number; - name: string; - picture: string; - given_name: string; - family_name: string; - locale: string; - "https://slack.com/team_name": string; - "https://slack.com/team_domain": string; - "https://slack.com/user_image_24": string; - "https://slack.com/user_image_32": string; - "https://slack.com/user_image_48": string; - "https://slack.com/user_image_72": string; - "https://slack.com/user_image_192": string; - "https://slack.com/user_image_512": string; - "https://slack.com/team_image_34": string; - "https://slack.com/team_image_44": string; - "https://slack.com/team_image_68": string; - "https://slack.com/team_image_88": string; - "https://slack.com/team_image_102": string; - "https://slack.com/team_image_132": string; - "https://slack.com/team_image_230": string; - "https://slack.com/team_image_default": true; -}; -``` - -### `SlackUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface SlackUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - slackUser: SlackUser; - slackTokens: SlackTokens; -} -``` - -| properties | type | description | -| ------------- | ----------------------------- | ----------------- | -| `slackUser` | [`SlackUser`](#slackuser) | Slack user | -| `slackTokens` | [`SlackTokens`](#slacktokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/spotify.md b/documentation/content/oauth/providers/spotify.md deleted file mode 100644 index 67a3b502f..000000000 --- a/documentation/content/oauth/providers/spotify.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: "Spotify OAuth provider" -description: "Learn how to use the Spotify OAuth provider" ---- - -OAuth integration for Spotify. Refer to [Spotify OAuth documentation](https://developer.spotify.com/documentation/web-api/concepts/apps) for getting the required credentials. Provider id is `spotify`. - -```ts -import { spotify } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const spotifyAuth = spotify(auth, config); -``` - -## `spotify()` - -```ts -const spotify: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => SpotifyProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ----------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Spotify OAuth app client id | | -| `config.clientSecret` | `string` | Spotify OAuth app client secret | | -| `config.redirectUri` | `string` | one of the authorized redirect URIs | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`SpotifyProvider`](#spotifyprovider) | Spotify provider | - -## Interfaces - -### `SpotifyAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface SpotifyAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------- | -| [`SpotifyUserAuth`](#appleuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `SpotifyTokens` - -```ts -type SpotifyTokens = { - accessToken: string; - tokenType: string; - scope: string; - accessTokenExpiresIn: number; - refreshToken: string; -}; -``` - -### `SpotifyUser` - -```ts -type SpotifyUser = { - country?: string; - display_name: string | null; - email?: string; - explicit_content: { - filter_enabled?: boolean; - filter_locked?: boolean; - }; - external_urls: { - spotify: string; - }; - followers: { - href: string | null; - total: number; - }; - href: string; - id: string; - images: [ - { - url: string; - height: number | null; - width: number | null; - } - ]; - product?: string; - type: string; - uri: string; -}; -``` - -### `SpotifyUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface SpotifyUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - appleUser: SpotifyUser; - appleTokens: SpotifyTokens; -} -``` - -| properties | type | description | -| ------------- | ------------------------------- | ----------------- | -| `appleUser` | [`SpotifyUser`](#appleuser) | Spotify user | -| `appleTokens` | [`SpotifyTokens`](#appletokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/strava.md b/documentation/content/oauth/providers/strava.md deleted file mode 100644 index 9d4191f97..000000000 --- a/documentation/content/oauth/providers/strava.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -title: "Strava OAuth provider" -description: "Learn how to use the Strava OAuth provider" ---- - -OAuth integration for Strava. Refer to [How To Create An Application](https://developers.strava.com/docs/getting-started/#account) for getting the required credentials. Provider id is `strava`. - -```ts -import { strava } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const stravaAuth = strava(auth, config); -``` - -## `strava()` - -```ts -const strava: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri?: string; - } -) => GithubProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ------------------------------ | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Strava OAuth app client id | | -| `config.clientSecret` | `string` | Strava OAuth app client secret | | -| `config.scope` | `string[]` | an array of scopes | ✓ | -| `config.redirectUri` | `string` | an authorized redirect URI | ✓ | - -##### Returns - -| type | description | -| ----------------------------------- | --------------- | -| [`StravaProvider`](#stravaprovider) | Strava provider | - -## Interfaces - -### `StravaAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface StravaAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------- | -| [`StravaUserAuth`](#stravauserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `StravaTokens` - -```ts -type = - | { - accessToken: string; - user: StravaUser; - } - | { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; - user: StravaUser; - }; -``` - -### `StravaUser` - -```ts -export type StravaUser = { - id: number; - username: string; - resource_state: number; - firstname: string; - lastname: string; - bio: string; - city: string; - country: string; - sex: string; - premium: boolean; - summit: boolean; - created_at: string; - updated_at: string; - badge_type_id: number; - weight: number; - profile_medium: string; - profile: string; -}; -``` - -### `StravaUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface StravaUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - stravaUser: StravaUser; - stravaTokens: StravaTokens; -} -``` - -| properties | type | description | -| -------------- | ------------------------------- | ----------------- | -| `stravaUser` | [`StravaUser`](#stravauser) | Strava user | -| `stravaTokens` | [`StravaTokens`](#stravatokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/twitch.md b/documentation/content/oauth/providers/twitch.md deleted file mode 100644 index a8b164f8a..000000000 --- a/documentation/content/oauth/providers/twitch.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: "Twitch OAuth provider" -description: "Learn how to use the Twitch OAuth provider" ---- - -OAuth integration for Twitch. Refer to [Twitch OAuth documentation](https://dev.twitch.tv/docs/authentication) for getting the required credentials. Provider id is `twitch`. - -```ts -import { twitch } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const twitchAuth = twitch(auth, configs); -``` - -## `twitch()` - -```ts -const twitch: ( - auth: Auth, - configs: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => TwitchProvider; -``` - -##### Parameters - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ----------------------------------- | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | Twitch OAuth app client id | | -| `config.clientSecret` | `string` | Twitch OAuth app client secret | | -| `config.redirectUri` | `string` | one of the authorized redirect URIs | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ----------------------------------- | --------------- | -| [`TwitchProvider`](#twitchprovider) | Twitch provider | - -## Interfaces - -### `TwitchAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -```ts -// implements OAuth2ProviderAuth> -interface TwitchAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ----------------------------------- | -| [`TwitchUserAuth`](#twitchuserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `TwitchTokens` - -```ts -type TwitchTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; -``` - -### `TwitchUser` - -```ts -type TwitchUser = { - id: string; - login: string; - display_name: string; - type: "" | "admin" | "staff" | "global_mod"; - broadcaster_type: "" | "affiliate" | "partner"; - description: string; - profile_image_url: string; - offline_image_url: string; - view_count: number; - email?: string; - created_at: string; -}; -``` - -### `TwitchUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface TwitchUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - twitchUser: TwitchUser; - twitchTokens: TwitchTokens; -} -``` - -| properties | type | description | -| -------------- | ------------------------------- | ----------------- | -| `twitchUser` | [`TwitchUser`](#twitchuser) | Twitch user | -| `twitchTokens` | [`TwitchTokens`](#twitchtokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/oauth/providers/twitter.md b/documentation/content/oauth/providers/twitter.md deleted file mode 100644 index 77bf5baeb..000000000 --- a/documentation/content/oauth/providers/twitter.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: "Twitter OAuth provider" -description: "Learn how to use the Twitter OAuth provider" ---- - -OAuth integration for Twitter OAuth 2.0 with PKCE. The access token can only be used for Twitter API v2. Provider id is `twitter`. - -```ts -import { twitter } from "@lucia-auth/oauth/providers"; -import { auth } from "./lucia.js"; - -const twitterAuth = twitter(auth, config); -``` - -## `twitter()` - -```ts -const twitter: ( - auth: Auth, - config: { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - } -) => TwitterProvider; -``` - -##### Parameter - -| name | type | description | optional | -| --------------------- | ------------------------------------------ | ------------------ | :------: | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | | -| `config.clientId` | `string` | client id | | -| `config.clientSecret` | `string` | client id | | -| `config.redirectUri` | `string` | redirect URI | | -| `config.scope` | `string[]` | an array of scopes | ✓ | - -##### Returns - -| type | description | -| ------------------------------------- | ---------------- | -| [`TwitterProvider`](#twitterprovider) | Twitter provider | - -## Interfaces - -### `TwitterAuth` - -See [`OAuth2ProviderAuthWithPKCE`](/reference/oauth/interfaces/oauth2providerauthwithpkce). - -```ts -// implements OAuth2ProviderAuthWithPKCE> -interface TwitterAuth<_Auth extends Auth> { - getAuthorizationUrl: () => Promise< - readonly [url: URL, codeVerifier: string, state: string] - >; - validateCallback: (code: string) => Promise>; -} -``` - -| type | -| ------------------------------------- | -| [`TwitterUserAuth`](#twitteruserauth) | - -##### Generics - -| name | extends | default | -| ------- | ------------------------------------------ | ------- | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | `Auth` | - -### `TwitterTokens` - -```ts -type TwitterTokens = { - accessToken: string; - refreshToken: string | null; -}; -``` - -### `TwitterUser` - -```ts -type TwitterUser = { - id: string; - name: string; - username: string; -}; -``` - -### `TwitterUserAuth` - -Extends [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). - -```ts -interface TwitterUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - twitterUser: TwitterUser; - twitterTokens: TwitterTokens; -} -``` - -| properties | type | description | -| --------------- | --------------------------------- | ----------------- | -| `twitterUser` | [`TwitterUser`](#twitteruser) | Twitter user | -| `twitterTokens` | [`TwitterTokens`](#twittertokens) | Access tokens etc | - -##### Generics - -| name | extends | -| ------- | ------------------------------------------ | -| `_Auth` | [`Auth`](/reference/lucia/interfaces/auth) | diff --git a/documentation/content/reference/database-adapter.md b/documentation/content/reference/database-adapter.md deleted file mode 100644 index 6a4c8333a..000000000 --- a/documentation/content/reference/database-adapter.md +++ /dev/null @@ -1,408 +0,0 @@ ---- -title: "Database adapters API" -description: "Learn how to build your own database adapters" ---- - -### Errors - -Errors defined in the specification, such as `AUTH_INVALID_USER_ID` must be thrown as a [`LuciaError`](/reference/lucia/modules/main#luciaerror). - -```ts -throw new LuciaError("AUTH_INVALID_USER_ID"); -``` - -## `InitializeAdapter` - -`adapter` configuration takes a `InitializeAdapter` function, which in turn returns the actual adapter instance. This function takes a `LuciaError`, and all errors thrown by the adapter must use this class instead of the one imported from `lucia`. - -```ts -const customAdapter = (config: any) => { - return (luciaError: typeof LuciaError) => ({ - // adapter - }); -}; - -lucia({ - adapter: customAdapter(options) -}); -``` - -## `Adapter` - -```ts -type Adapter = { - getSessionAndUser?: ( - sessionId: string - ) => Promise<[SessionSchema, UserSchema] | [null, null]>; -} & UserAdapter & - SessionAdapter; -``` - -| type | -| --------------------------------------------------------------- | -| [`UserAdapter`](/reference/database-adapter/#useradapter) | -| [`SessionAdapter`](/reference/database-adapter/#sessionadapter) | - -### `getSessionAndUser()` - -- An _optional_ method of `Adapter` -- Must select `session` where `session(id)` equals parameter `sessionId` -- Must select `user` where `user(id)` equals `session(id)` of selected `session` -- Must return the session and user in a tuple if both exists, or `null, null` if not - -```ts -const getSessionAndUser: ( - sessionId: string -) => Promise< - [session: SessionSchema, user: UserSchema] | [session: null, user: null] ->; -``` - -##### Parameters - -| name | type | description | -| ----------- | -------- | --------------------------- | -| `sessionId` | `string` | Unique target `session(id)` | - -##### Returns - -| name | type | description | -| --------- | ----------------------------------------------------------- | ---------------------- | -| `session` | [`SessionSchema`](/basics/database#session-table)` \| null` | Target session | -| `user` | [`UserSchema`](/basics/database#user-table)` \| null` | User of target session | - -## `UserAdapter` - -```ts -type UserAdapter = Readonly<{ - getUser: (userId: string) => Promise; - setUser: (user: UserSchema, key: KeySchema | null) => Promise; - updateUser: ( - userId: string, - partialUser: Partial - ) => Promise; - deleteUser: (userId: string) => Promise; - - getKey: (keyId: string) => Promise; - getKeysByUserId: (userId: string) => Promise; - setKey: (key: KeySchema) => Promise; - updateKey: (keyId: string, partialKey: Partial) => Promise; - deleteKey: (keyId: string) => Promise; - deleteKeysByUserId: (userId: string) => Promise; -}>; -``` - -### `deleteKey()` - -- Must delete unique `key` where `key(id)` equals parameter `keyId` -- Must return `void` -- Parameter `keyId` may be invalid and must not throw errors if invalid -- Invalid `keyId` must be ignored and no errors should be thrown - -```ts -const deleteKey: (keyId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------- | -------- | ----------------------- | -| `keyId` | `string` | Unique target `key(id)` | - -### `deleteKeysByUserId()` - -- Must delete multiple `key` where `key(user_id)` equals parameter `userId` -- Must return `void` -- Parameter `userId` may be invalid and must not throw errors if invalid -- Invalid `userId` must be ignored and no errors should be thrown - -```ts -const deleteKeysByUserId: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | --------------------- | -| `userId` | `string` | Target `key(user_id)` | - -### `deleteUser()` - -- Must delete unique `user` where `user(id)` equals parameter `userId` -- Must return `void` -- Parameter `userId` may be invalid and must not throw errors if invalid -- Invalid `userId` must be ignored and no errors should be thrown - -```ts -const deleteUser: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ------------------------ | -| `userId` | `string` | Unique target `user(id)` | - -### `getKey()` - -- Must select unique `key` where `key(id)` equals parameter `keyId` -- Must return target `key` or `null` if there are no matches - -```ts -const getKey: (keyId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------- | -------- | ---------------------- | -| `keyId` | `string` | Unique arget `key(id)` | - -##### Returns - -| type | description | -| ----------------------------------------- | ------------ | -| [`KeySchema`](/basics/database#key-table) | Target `key` | - -### `getKeysByUserId()` - -- Must select multiple `key` where `key(user_id)` equals parameter `userId` -- Must return an array of `key` or an empty array if there are no matches - -```ts -const getKeysByUserId: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | --------------------- | -| `userId` | `string` | Target `key(user_id)` | - -##### Returns - -| type | description | -| --------------------------------------------- | ---------------------- | -| [`KeySchema`](/basics/database#key-table)`[]` | Array of matched `key` | - -### `getUser()` - -- Must select unique `user` where `user(id)` equals parameter `userId` -- Must return target `user` or `null` if there are no matches - -```ts -const getUser: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ------------------------ | -| `userId` | `string` | Unique target `user(id)` | - -##### Returns - -| type | description | -| ------------------------------------------- | ------------- | -| [`UserSchema`](/basics/database#user-table) | Target `user` | - -### `setKey()` - -- Must create new `key` -- Must throw error `AUTH_DUPLICATE_KEY_ID` if `key(id)` violates unique constraint -- May throw `AUTH_INVALID_USER_ID` if `key(user_id)` violates foreign key constraint - -```ts -const setKey: (key: KeySchema) => Promise; -``` - -##### Parameters - -| name | type | description | -| ----- | ----------------------------------------- | --------------- | -| `key` | [`KeySchema`](/basics/database#key-table) | `key` to create | - -### `setUser()` - -- Must create new `user` -- Must create new `key` if parameter `key` is defined -- Must throw error `AUTH_DUPLICATE_KEY_ID` if `key(id)` violates unique constraint, if parameter `key` is defined -- `key` must not be created if `user` creation errors - -```ts -const setUser: (user: UserSchema, key: KeySchema | null) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------ | -------------------------------------------------- | --------------------------- | -| `user` | [`UserSchema`](/basics/database#user-table) | `user` to create | -| `key` | [`KeySchema`](/basics/database#key-table)`\| null` | `key` to create, if defined | - -### `updateKey()` - -- Must update fields, defined in parameter `partialKey`, of unique `key` where `key(id)` equals parameter `keyId` -- Must return `void` -- May throw error `AUTH_INVALID_KEY_ID` if target `key` does not exist - -```ts -const updateKey: ( - keyId: string, - partialKey: Partial -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------------ | ------------------------------------------------------ | ----------------------- | -| `keyId` | `string` | Unique target `key(id)` | -| `partialKey` | `Partial<`[`KeySchema`](/basics/database#key-table)`>` | `key` fields to update | - -### `updateUser()` - -- Must update fields, defined in parameter `partialKey`, of unique `user` where `user(id)` equals parameter `userId` -- Must return `void` -- May throw error `AUTH_INVALID_USER_ID` if target `key` does not exist - -```ts -const updateUser: ( - userId: string, - partialUser: Partial -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------------- | -------------------------------------------------------- | ------------------------ | -| `userId` | `string` | Unique target `user(id)` | -| `partialUser` | `Partial<`[`UserSchema`](/basics/database#user-table)`>` | `user` fields to update | - -## `SessionAdapter` - -```ts -type SessionAdapter = Readonly<{ - getSession: (sessionId: string) => Promise; - getSessionsByUserId: (userId: string) => Promise; - setSession: (session: SessionSchema) => Promise; - updateSession: ( - sessionId: string, - partialSession: Partial - ) => Promise; - deleteSession: (sessionId: string) => Promise; - deleteSessionsByUserId: (userId: string) => Promise; -}>; -``` - -### `deleteSession()` - -- Must delete unique `session` where `session(id)` equals parameter `sessionId` -- Returns `void` -- Parameter `sessionId` may be invalid and must not throw errors if invalid -- Invalid `sessionId` must be ignored and no errors should be thrown - -```ts -const deleteSession: (sessionId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ----------- | -------- | --------------------------- | -| `sessionId` | `string` | Unique target `session(id)` | - -### `deleteSessionsByUserId()` - -- Must delete multiple `session` where `session(user_id)` equals parameter `userId` -- Must return `void` -- Parameter `userId` may be invalid and must not throw errors if invalid -- Invalid `userId` must be ignored and no errors should be thrown - -```ts -const deleteSessionsByUserId: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ------------------------- | -| `userId` | `string` | Target `session(user_id)` | - -### `getSession()` - -- Must select unique `session` where `session(id)` equals parameter `sessionId` -- Must return target `session` or `null` if there are no matches - -```ts -const getSession: (sessionId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ----------- | -------- | --------------------------- | -| `sessionId` | `string` | Unique target `session(id)` | - -##### Returns - -| type | description | -| ------------------------------------------------- | ---------------- | -| [`SessionSchema`](/basics/database#session-table) | Target `session` | - -### `getSessionsByUserId()` - -- Must select multiple `session` where `session(user_id)` equals parameter `userId` -- Must return an array of `session` or an empty array if there are no matches - -```ts -const getSessionsByUserId: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ------------------------- | -| `userId` | `string` | Target `session(user_id)` | - -##### Returns - -| type | description | -| ----------------------------------------------------- | -------------------------- | -| [`SessionSchema`](/basics/database#session-table)`[]` | Array of matched `session` | - -### `setSession()` - -- Must create new `session` -- May throw `AUTH_INVALID_USER_ID` if `session(user_id)` violates foreign key constraint - -```ts -const setSession: (session: SessionSchema) => Promise; -``` - -##### Parameters - -| name | type | description | -| --------- | ------------------------------------------------- | ------------------- | -| `session` | [`SessionSchema`](/basics/database#session-table) | `session` to create | - -### `updateSession()` - -- Must update fields, defined in parameter `partialSession`, of unique `session` where `session(id)` equals parameter `keyId` -- Must return `void` -- May throw error `AUTH_INVALID_SESSION_ID` if target `session` does not exist - -```ts -const updateSession: ( - keyId: string, - partialKey: Partial -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ---------------- | -------------------------------------------------------------- | --------------------------- | -| `sessionId` | `string` | Unique target `session(id)` | -| `partialSession` | `Partial<`[`SessionSchema`](/basics/database#session-table)`>` | `session` fields to update | diff --git a/documentation/content/reference/index.md b/documentation/content/reference/index.md deleted file mode 100644 index 4e2e31a65..000000000 --- a/documentation/content/reference/index.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: "Reference overview" ---- - -## `lucia` - -- [`lucia`](/reference/lucia/modules/main) -- [`lucia/middleware`](/reference/lucia/modules/middleware) -- [`lucia/polyfill/node`](/reference/lucia/modules/polyfill/node) -- [`lucia/utils`](/reference/lucia/modules/utils) - -## `@lucia-auth/oauth` - -- [`@lucia-auth/oauth`](/reference/oauth/modules/main) -- [`@lucia-auth/oauth/providers`](/reference/oauth/modules/providers) - -## Adapters - -### `@lucia-auth/adapter-mongoose` - -- [`mongoose()`](/database-adapters/mongoose) - -### `@lucia-auth/adapter-mysql` - -- [`mysql2()`](/database-adapters/mysql2) -- [`planetscale()`](/database-adapters/planetscale-serverless) - -### `@lucia-auth/adapter-postgresql` - -- [`pg()`](/database-adapters/pg) -- [`postgres()`](/database-adapters/postgres) - -### `@lucia-auth/adapter-prisma` - -- [`prisma()`](/database-adapters/prisma) - -### `@lucia-auth/adapter-sqlite` - -- [`betterSqlite3()`](/database-adapters/better-sqlite3) -- [`d1()`](/database-adapters/cloudflare-d1) -- [`libsql()`](/database-adapters/libsql) - -### `@lucia-auth/adapter-session-redis` - -- [`redis()`](/database-adapters/redis) -- [`upstash()`](/database-adapters/upstash-redis) - -### `@lucia-auth/adapter-session-unstorage` - -- [`unstorage()`](/database-adapters/unstorage) diff --git a/documentation/content/reference/lucia/interfaces/auth.md b/documentation/content/reference/lucia/interfaces/auth.md deleted file mode 100644 index a04668550..000000000 --- a/documentation/content/reference/lucia/interfaces/auth.md +++ /dev/null @@ -1,917 +0,0 @@ ---- -title: "`Auth`" ---- - -## `createKey()` - -Creates a new key for a user. - -```ts -const createKey: (options: { - userId: string; - providerId: string; - providerUserId: string; - password: string | null; -}) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------------------------ | ---------------- | -------------------------------- | -| `options.userId` | `string` | The user id of the key to create | -| `options.providerId` | `string` | The provider id of the key | -| `options.providerUserId` | `string` | The provider user id of the key | -| `options.password` | `string \| null` | The password for the key | - -##### Returns - -| type | description | -| ---------------------------------------- | ----------- | -| [`Key`](/reference/lucia/interfaces#key) | A new key | - -##### Errors - -| name | -| ----------------------- | -| `AUTH_INVALID_USER_ID` | -| `AUTH_DUPLICATE_KEY_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const key = await auth.createKey({ - userId, - providerId: "email", - providerUserId: "user@example.com", - password: "123456" -}); - -const key = await auth.createKey({ - userId, - providerId: "github", - providerUserId: githubUserId, - password: null -}); -``` - -## `createSession()` - -Creates a new session for a user. - -```ts -const createSession: (options: { - userId: string; - attributes: Lucia.DatabaseSessionAttributes; -}) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------------------- | --------------------------------- | ------------------------------------ | -| `options.userId` | `string` | The user id of the session to create | -| `options.attributes` | `Lucia.DatabaseSessionAttributes` | Database session attributes | - -##### Returns - -| type | description | -| ------------------------------------------------ | ------------- | -| [`Session`](/reference/lucia/interfaces#session) | A new session | - -##### Errors - -| name | -| ---------------------- | -| `AUTH_INVALID_USER_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const session = await auth.createSession({ - userId, - attributes: { - created_at: new Date() - } -}); -``` - -## `createSessionCookie()` - -Creates a session in the form of [`Cookie`](/reference/lucia/interfaces#cookie). Returns a blank session cookie that will override the existing cookie and clears them if `null` is provided as parameter `session`. - -```ts -const createSessionCookie: (session: Session | null) => Cookie; -``` - -##### Parameters - -| name | type | description | -| --------- | ---------------------------------------------------------- | ------------------------------ | -| `session` | [`Session`](/reference/lucia/interfaces#session)` \| null` | Session to create a cookie for | - -##### Returns - -| type | description | -| ---------------------------------------------- | -------------- | -| [`Cookie`](/reference/lucia/interfaces#cookie) | Session cookie | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const sessionCookie = auth.createSessionCookie(session); -const response = new Response(null, { - headers: { - "Set-Cookie": sessionCookie.serialize() - } -}); -``` - -## `createUser()` - -Creates a new user, with an option to create a key alongside the user. - -```ts -const createUser: (options: { - key: { - providerId: string; - providerUserId: string; - password: string | null; - } | null; - attributes: Lucia.UserAttributes; - userId?: string; -}) => Promise; -``` - -##### Parameters - -| name | type | optional | description | -| ----------------------------- | ------------------------------- | :------: | ------------------------------------------------------ | -| `options.key` | `null` \| `Record` | | If defined, the key will be created alongside the user | -| `options.key?.providerId` | `string` | | Key provider id | -| `options.key?.providerUserId` | `string` | | Key provider user id | -| `options.key?.password` | `string` | | Key password | -| `options.attributes` | `Lucia.DatabaseUserAttributes` | | Database user attributes | -| `userId` | `string` | ✓ | Custom user id | - -###### Returns - -| type | description | -| ------------------------------------------ | ----------- | -| [`User`](/reference/lucia/interfaces#user) | A new user | - -##### Errors - -| name | -| ----------------------- | -| `AUTH_DUPLICATE_KEY_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const user = await auth.createUser({ - key: { - providerId: "email", - providerUserId: "user@example.com", - password: "123456" - }, - attributes: { - username: "user123", - admin: true - } -}); -``` - -## `deleteDeadUserSessions()` - -Deletes all sessions that are expired and their idle period has passed (dead sessions). Will succeed regardless of the validity of the user id. - -```ts -const deleteDeadUserSessions: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ----------- | -| `userId` | `string` | User id | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.deleteExpiredUserSession(userId); -``` - -## `deleteKey()` - -Deletes a key. Will succeed regardless of the validity of the key id. - -```ts -const deleteKey: (providerId: string, providerUserId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ---------------- | -------- | ------------------------------- | -| `providerId` | `string` | The provider id of the key | -| `providerUserId` | `string` | The provider user id of the key | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.deleteKey("username", "user@example.com"); -``` - -## `deleteUser()` - -Deletes a user. Will succeed regardless of the validity of the user id. - -```ts -const deleteUser: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ------------------- | -| `userId` | `string` | A user id to delete | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.deleteUser(userId); -``` - -## `getAllUserKeys()` - -Validates the user id and returns all keys of a user. - -```ts -const getAllUserKeys: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ------------- | -| `userId` | `string` | The A user id | - -##### Returns - -| type | -| -------------------------------------------- | -| [`Key`](/reference/lucia/interfaces#key)`[]` | - -##### Errors - -| name | -| ---------------------- | -| `AUTH_INVALID_USER_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const keys = await auth.getAllUserKeys(userId); -``` - -## `getAllUserSessions()` - -Validate the user id and get all valid sessions of a user. Includes active and idle sessions, but not dead sessions. - -```ts -const getAllUserSessions: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ------------- | -| `userId` | `string` | The A user id | - -##### Returns - -| type | -| ---------------------------------------------------- | -| [`Session`](/reference/lucia/interfaces#session)`[]` | - -##### Errors - -| name | -| ---------------------- | -| `AUTH_INVALID_USER_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -try { - const sessions = await auth.getAllUserSessions(userId); -} catch { - // invalid user id -} -``` - -## `getKey()` - -Gets a key. Use [`Auth.useKey()](/reference/lucia/interfaces/auth#usekey) method for validating key passwords. - -```ts -const getKey: (providerId: string, providerUserId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ---------------- | -------- | ------------------------------- | -| `providerId` | `string` | The provider id of the key | -| `providerUserId` | `string` | The provider user id of the key | - -##### Returns - -| type | -| ---------------------------------------- | -| [`Key`](/reference/lucia/interfaces#key) | - -##### Errors - -| name | -| --------------------- | -| `AUTH_INVALID_KEY_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const key = await auth.getKey("email", "user@example.com"); -``` - -## `getSession()` - -Gets a session. Returns both active and idle sessions. - -```ts -const getSessionUser: (sessionId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ----------- | -------- | ---------------------------- | -| `sessionId` | `string` | An active or idle session id | - -##### Returns - -| type | -| ------------------------------------------------ | -| [`Session`](/reference/lucia/interfaces#session) | - -##### Errors - -| name | -| ------------------------- | -| `AUTH_INVALID_SESSION_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const session = await auth.getSession(sessionId); -if (session.state === "active") { - // valid session -} -if (session.state === "idle") { - // should be reset -} -``` - -## `getUser()` - -Gets a user. - -```ts -const getUser: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ----------- | -| `userId` | `string` | A user id | - -##### Returns - -| type | -| ------------------------------------------ | -| [`User`](/reference/lucia/interfaces#user) | - -##### Errors - -| name | -| ---------------------- | -| `AUTH_INVALID_USER_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const user = await auth.getUser(userId); -``` - -## `handleRequest()` - -Creates a new [`Auth`](/reference/lucia/interfaces/authrequest) instance. - -```ts -const handleRequest: (...args: any[]) => AuthRequest; -``` - -##### Parameters - -| type | description | -| ------- | --------------------------------------- | -| `any[]` | Refer to the middleware's documentation | - -##### Returns - -| type | -| -------------------------------------------------------- | -| [`AuthRequest`](/reference/lucia/interfaces/authrequest) | - -## `invalidateAllUserSessions()` - -Invalidates all sessions of a user. Will succeed regardless of the validity of the user id. - -```ts -const invalidateAllUserSessions: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ----------- | -| `userId` | `string` | A user id | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.invalidateAllUserSession(userId); -``` - -## `invalidateSession()` - -Invalidates a session. Will succeed regardless of the validity of the session id. - -```ts -const invalidateSession: (sessionId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ----------- | -------- | ------------ | -| `sessionId` | `string` | A session id | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.invalidateSession(sessionId); -``` - -## `readBearerToken()` - -Takes a `Authorization` request header and returns the bearer token if it exists. This _does not_ validate the session. Bearer token must be stored in the following format: - -```http -Authorization: Bearer -``` - -```ts -const readBearerToken: ( - authorizationHeader: string | null | undefined -) => string | null; -``` - -##### Parameters - -| name | type | description | -| --------------------- | ----------------------------- | ---------------------- | -| `authorizationHeader` | `string \| null \| undefined` | `Authorization` header | - -##### Returns - -| type | description | -| -------- | --------------------------- | -| `string` | Bearer token value | -| `null` | Bearer token does not exist | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const sessionId = auth.readBearerToken( - "Bearer CAbc9LAUY3Q18f0s92Jo817dna8eDtmRrUrDuVFM" -); -``` - -## `readSessionCookie()` - -Takes a `Cookie` request header and returns the session cookie value if it exists. This _does not_ validate the session. - -```ts -const readSessionCookie: ( - cookieHeader: string | null | undefined -) => string | null; -``` - -##### Parameters - -| name | type | description | -| -------------- | ----------------------------- | --------------- | -| `cookieHeader` | `string \| null \| undefined` | `Cookie` header | - -##### Returns - -| type | description | -| -------- | ----------------------------- | -| `string` | Session cookie value | -| `null` | Session cookie does not exist | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const sessionId = auth.readSessionCookie( - "auth_session=CAbc9LAUY3Q18f0s92Jo817dna8eDtmRrUrDuVFM" -); -``` - -## `transformDatabaseKey()` - -Transforms key object returned from database query into Lucia's `Key`. - -```ts -const transformDatabaseKey: (databaseKey: KeySchema) => Key; -``` - -##### Parameters - -| name | type | description | -| ------------- | ---------------------------------------------------- | -------------- | -| `databaseKey` | [`KeySchema`](/reference/lucia/interfaces#keyschema) | Raw key object | - -##### Returns - -| type | -| ---------------------------------------- | -| [`Key`](/reference/lucia/interfaces#key) | - -#### Example - -```ts -import { createKeyId } from "lucia"; -import { auth } from "./lucia.js"; - -const databaseKey = await db.getKey(createKeyId(providerId, providerUserId)); -const key = auth.transformDatabaseKey(databaseKey); -``` - -## `transformDatabaseSession()` - -Transforms session object returned from database query into Lucia's `Session`. - -```ts -const transformDatabaseSession: ( - databaseSession: SessionSchema, - context: { - user: User; - fresh: boolean; - } -) => Session; -``` - -##### Parameters - -| name | type | description | -| ----------------- | --------------------------------------------------------- | ------------------ | -| `databaseSession` | [`SessionSchema`](/reference/lucia/interfaces#userschema) | Raw session object | -| `context.user` | [`User`](/reference/lucia/interfaces#user) | `Session.user` | -| `context.fresh` | `boolean` | `Session.fresh` | - -##### Returns - -| type | -| ------------------------------------------------ | -| [`Session`](/reference/lucia/interfaces#session) | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const databaseUser = await db.getUser(userId); -const databaseSession = await db.getSession(sessionId); -const session = auth.transformDatabaseSession(databaseSession, { - user: auth.transformDatabaseUser(databaseUser), - fresh: false -}); -``` - -## `transformDatabaseUser()` - -Transforms user object returned from database query into Lucia's `User`. - -```ts -const transformDatabaseUser: (databaseUser: UserSchema) => User; -``` - -##### Parameters - -| name | type | description | -| -------------- | ------------------------------------------------------ | --------------- | -| `databaseUser` | [`UserSchema`](/reference/lucia/interfaces#userschema) | Raw user object | - -##### Returns - -| type | -| ------------------------------------------ | -| [`User`](/reference/lucia/interfaces#user) | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const databaseUser = await db.getUser(userId); -const user = auth.transformDatabaseUser(databaseUser); -``` - -## `updateKeyPassword()` - -Updates the password of a key. Pass `null` to parameter `password` to remove the key's password. - -```ts -const updateKeyPassword: ( - providerId: string, - providerUserId: string, - password: string | null -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ---------------- | ---------------- | -------------------------------------- | -| `providerId` | `string` | The provider id of the target key | -| `providerUserId` | `string` | The provider user id of the target key | -| `password` | `string \| null` | A new password | - -##### Returns - -| type | description | -| ---------------------------------------- | ----------- | -| [`Key`](/reference/lucia/interfaces#key) | updated key | - -##### Errors - -| name | -| --------------------- | -| `AUTH_INVALID_KEY_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.updateKeyPassword("email", "user@example.com", "123456"); -await auth.updateKeyPassword("email", "user@example.com", null); // remove password -``` - -## `updateSessionAttributes()` - -Updates one or more fields of a session. Values of parameter `attributes` can be `null` but not `undefined`. - -```ts -const updateSessionAttributes: ( - sessionId: string, - attributes: Partial -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------------ | ------------------------------------------ | -------------------------- | -| `sessionId` | `string` | A session id | -| `attributes` | `Partial` | `session` fields to update | - -##### Returns - -| type | description | -| ------------------------------------------------ | ------------------- | -| [`Session`](/reference/lucia/interfaces#session) | The updated session | - -##### Errors - -| name | -| ------------------------- | -| `AUTH_INVALID_SESSION_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.updateUserAttributes(userId, { - username: "user123", - profile_image: null -}); -``` - -## `updateUserAttributes()` - -Updates one or more database fields of a user. Values of parameter `attributes` can be `null` but not `undefined`. - -```ts -const updateUserAttributes: ( - userId: string, - attributes: Partial -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------------ | --------------------------------------- | ----------------------- | -| `userId` | `string` | A user id | -| `attributes` | `Partial` | `user` fields to update | - -##### Returns - -| type | description | -| ------------------------------------------ | ---------------- | -| [`User`](/reference/lucia/interfaces#user) | The updated user | - -##### Errors - -| name | -| ---------------------- | -| `AUTH_INVALID_USER_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -await auth.updateUserAttributes(userId, { - username: "user123", - profile_image: null -}); -``` - -## `useKey()` - -Validates a key, including the password. `null` must be passed to parameter `password` if the password does not hold a password. - -```ts -const useKey: ( - providerId: string, - providerUserId: string, - password: string | null -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ---------------- | ---------------- | ------------------------------- | -| `providerId` | `string` | The provider id of the key | -| `providerUserId` | `string` | The provider user id of the key | -| `password` | `string \| null` | The password of the key | - -##### Returns - -| type | description | -| ---------------------------------------- | ----------------- | -| [`Key`](/reference/lucia/interfaces#key) | The validated key | - -##### Errors - -| name | -| ------------------------ | -| `AUTH_INVALID_KEY_ID` | -| `AUTH_INVALID_PASSWORD` | -| `AUTH_OUTDATED_PASSWORD` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const key = await auth.useKey("email", "user@example.com", "123456"); -const key = await auth.useKey("github", githubUserId, null); -``` - -## `validateRequestOrigin()` - -**Deprecated: To be removed in the next major release.** - -Used for CSRF protection. Checks if the request origin is trusted for non-GET and non-HEAD requests (e.g. POST, PUT, DELETE), and throws an error if the origin is invalid. Trusted origins include where the server is hosted and its subdomains defined with [`csrfProtection.allowedSubdomains`](/basics/configuration#csrfprotection) configuration. - -```ts -const validateRequestOrigin: (request: { - url: string | URL; - method: string; - originHeader: string | null; -}) => void; -``` - -##### Parameters - -| name | type | description | -| ---------------- | ---------------- | ------------------------------------- | -| `request.url` | `string` | The request url | -| `request.method` | `string` | The request method (case insensitive) | -| `request.origin` | `string \| null` | The request origin header | - -##### Errors - -| name | -| ---------------------- | -| `AUTH_INVALID_REQUEST` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -auth.validateRequestOrigin({ - url: "http://localhost:3000/api", - method: "POST", - originHeader: "http://localhost:3000" -}); -``` - -## `validateSession()` - -Validates a session, resetting it if it's idle. - -```ts -const validateSession: (sessionId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ----------- | -------- | ---------------------------- | -| `sessionId` | `string` | An active or idle session id | - -##### Returns - -| type | description | -| ------------------------------------------------ | --------------------- | -| [`Session`](/reference/lucia/interfaces#session) | The validated session | - -##### Errors - -| name | -| ------------------------- | -| `AUTH_INVALID_SESSION_ID` | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const session = await auth.validateSession(sessionId); -if (session.fresh) { - // session was reset - // extend cookie expiration -} -``` diff --git a/documentation/content/reference/lucia/interfaces/authrequest.md b/documentation/content/reference/lucia/interfaces/authrequest.md deleted file mode 100644 index 591557103..000000000 --- a/documentation/content/reference/lucia/interfaces/authrequest.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: "AuthRequest" -format: "code" ---- - -## `invalidate()` - -Invalidates the internal cache for [`AuthRequest.validate()`](/reference/lucia/interfaces/authrequest#validate) and [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken). - -```ts -const invalidate: () => void; -``` - -## `setSession()` - -Sets a session cookie. Providing `null` will create a blank session cookie that will delete the current one. - -```ts -const setSession: (session: Session | null) => void; -``` - -##### Parameters - -| name | type | description | -| --------- | --------------------------------------------------------- | ---------------- | -| `session` | [`Session`](/reference/lucia/interfaces#session)`\| null` | Session to store | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const authRequest = auth.handleRequest(); -authRequest.setSession(session); -authRequest.setSession(null); // delete session cookie -``` - -## `validate()` - -Validates the session cookie using [`Auth.validateSession()`](/reference/lucia/interfaces/auth#validatesession). This resets the session if its idle, and returns the validated session if the cookie is valid, or `null` if not, Additionally, when a session is reset, a new session cookie is set. - -By default, this method will also return `null` if the request is from an untrusted origin. - -```ts -const validate: () => Promise; -``` - -##### Returns - -| type | description | -| ------------------------------------------------ | ----------------------------- | -| [`Session`](/reference/lucia/interfaces#session) | The validated session | -| `null` | The session stored is invalid | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const authRequest = auth.handleRequest(); -const session = await authRequest.validate(); -if (session) { - // valid session -} -``` - -## `validateBearerToken()` - -Validates the session cookie using [`Auth.validateSession()`](/reference/lucia/interfaces/auth#validatesession. This resets the session if its idle, and returns the validated session if the token is valid, or `null` if not, - -```ts -const validateBearerToken: () => Promise; -``` - -##### Returns - -| type | description | -| ------------------------------------------------ | ---------------------- | -| [`Session`](/reference/lucia/interfaces#session) | The validated session | -| `null` | The session is invalid | - -#### Example - -```ts -import { auth } from "./lucia.js"; - -const authRequest = auth.handleRequest(); -const session = await authRequest.validateBearerToken(); -if (session) { - // valid session -} -``` diff --git a/documentation/content/reference/lucia/interfaces/index.md b/documentation/content/reference/lucia/interfaces/index.md deleted file mode 100644 index 51176ff5a..000000000 --- a/documentation/content/reference/lucia/interfaces/index.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -title: "Interfaces" ---- - -These types can be imported from `lucia`. - -```ts -import type { Adapter } from "lucia"; -``` - -## `Adapter` - -See [Database adapter API](/reference/database-adapter#adapter). - -## `Auth` - -See [`Auth`](/reference/lucia/interfaces/auth). - -## `AuthRequest` - -See [`AuthRequest`](/reference/lucia/interfaces/authrequest). - -## `Configuration` - -See [configuration](/basics/configuration). - -## `Cookie` - -```ts -type Cookie = { - name: string; - value: string; - attributes: CookieAttributes; - serialize: () => string; -}; -``` - -##### Properties - -| property | type | description | -| ------------ | ------------------ | ----------------- | -| `name` | `string` | Cookie name | -| `value` | `string` | Cookie value | -| `attributes` | `CookieAttributes` | Cookie attributes | - -```ts -type CookieAttributes = Partial<{ - domain: string; - encode: (value: string) => string; - expires: Date; - httpOnly: boolean; - maxAge: number; - path: string; - priority: "low" | "medium" | "high"; - sameSite: true | false | "lax" | "strict" | "none"; - secure: boolean; -}>; -``` - -### `serialize()` - -Serializes the cookie into a `Set-Cookie` HTTP response header value. - -```ts -const serialize: () => string; -``` - -## `Env` - -```ts -type Env = "DEV" | "PROD"; -``` - -## `InitializeAdapter` - -```ts -type InitializeAdapter<_Adapter> => (LuciaError: LuciaErrorConstructor) => _Adapter -``` - -## `Key` - -```ts -type Key = { - userId: string; - providerId: string; - providerUserId: string; - passwordDefined: boolean; -}; -``` - -##### Properties - -| name | type | description | -| ----------------- | --------- | -------------------------- | -| `providerId` | `string` | Provider id | -| `providerUserId` | `string` | Provider user id | -| `userId` | `string` | User id of linked user | -| `passwordDefined` | `boolean` | `true` if holds a password | - -## `KeySchema` - -```ts -type KeySchema = { - id: string; - hashed_password: string | null; - user_id: string; -}; -``` - -## `LuciaErrorConstructor` - -Constructor for [`LuciaError`](/reference/lucia/modules/main#luciaerror). - -```ts -const LuciaErrorConstructor: (message: string) => LuciaError; -``` - -## `LuciaRequest` - -```ts -type LuciaRequest = { - method: string; - url: string; - headers: { - origin: string | null; - cookie: string | null; - authorization: string | null; - }; - storedSessionCookie?: string | null; -}; -``` - -##### Properties - -Optional property `storedSessionCookie` is for frameworks with APIs to directly read and set cookies. - -| property | type | optional | description | -| ----------------------- | ---------------- | :------: | -------------------------------------------------------- | -| `method` | `string` | | Request url (case insensitive) | -| `url` | `string` | | Full request url (e.g. `http://localhost:3000/pathname`) | -| `headers.origin` | `string \| null` | | `Origin` header value | -| `headers.cookie` | `string \| null` | | `Cookie` header value | -| `headers.authorization` | `string \| null` | | `Authorization` header value | -| `storedSessionCookie` | `string \| null` | ✓ | Session cookie value | - -## `Middleware` - -See [Middleware API](/reference/middleware#middleware). - -## `RequestContext` - -See [Middleware API](/reference/middleware#requestcontext). - -## `Session` - -```ts -type Session = { - user: User; - sessionId: string; - activePeriodExpiresAt: Date; - idlePeriodExpiresAt: Date; - state: "idle" | "active"; - fresh: boolean; -} & ReturnType<_Configuration["getSessionAttributes"]>; -``` - -##### Properties - -`ReturnType<_Configuration["getSessionAttributes"]>` represents the return type of [`getSessionAttributes()`](/basics/configuration#getsessionattributes) configuration. - -| name | type | description | -| ----------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------- | -| `activePeriodExpiresAt` | `Date` | Time of the [active period](/concepts#session-states-and-session-reset) expiration | -| `idlePeriodExpiresAt` | `Date` | Time of the [idle period](/concepts#session-states-and-session-reset) expiration | -| `fresh` | `boolean` | `true` if the session was newly created or reset | -| `sessionId` | `string` | Session id | -| `state` | `"active" \| "idle"` | [Session state](/concepts#session-states-and-session-reset) | -| `user` | [`User`](/reference/lucia/interfaces#user) | User of the session | - -## `SessionAdapter` - -See [Database adapter API](/reference/database-adapter#sessionadapter). - -## `SessionSchema` - -```ts -type SessionSchema = { - id: string; - active_expires: number; - idle_expires: number; - user_id: string; -} & Lucia.DatabaseSessionAttributes; -``` - -## `User` - -```ts -type User = { - userId: string; -} & ReturnType<_Configuration["getUserAttributes"]>; -``` - -##### Properties - -`ReturnType<_Configuration["getUserAttributes"]>` represents the return type of [`getUserAttributes()`](/basics/configuration#getuserattributes) configuration. - -| name | type | description | -| -------- | -------- | ----------- | -| `userId` | `string` | User id | - -## `UserAdapter` - -See [Database adapter API](/reference/database-adapter#useradapter). - -## `UserSchema` - -```ts -type UserSchema = { - id: string; -} & Lucia.DatabaseUserAttributes; -``` diff --git a/documentation/content/reference/lucia/modules/main.md b/documentation/content/reference/lucia/modules/main.md deleted file mode 100644 index f5469bf37..000000000 --- a/documentation/content/reference/lucia/modules/main.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: "`lucia`" ---- - -## `createKeyId()` - -Creates key id ([`KeySchema.id`](/reference/lucia/interfaces#keyschema)). - -```ts -const createKeyId: (providerId: string, providerUserId: string) => string; -``` - -##### Parameters - -| name | type | description | -| ---------------- | -------- | -------------------- | -| `providerId` | `string` | Key provider id | -| `providerUserId` | `string` | Key provider user id | - -##### Returns - -| type | description | -| -------- | ----------- | -| `string` | Key id | - -## `DEFAULT_SESSION_COOKIE_NAME` - -Default session cookie name. - -```ts -import { DEFAULT_SESSION_COOKIE_NAME } from "lucia"; -``` - -```ts -const DEFAULT_SESSION_COOKIE_NAME = "auth_session"; -``` - -## `lucia()` - -Initialize Lucia and create a new [`Auth`](/reference/lucia/interfaces/auth) instance. - -```ts -import { lucia } from "lucia"; -``` - -```ts -const lucia: (config: Configuration) => Auth; -``` - -##### Parameters - -| name | type | description | -| -------- | ---------------------------------------- | ------------------- | -| `config` | [`Configuration`](/basics/configuration) | Lucia configuration | - -##### Returns - -| type | -| ------------------------------------------ | -| [`Auth`](/reference/lucia/interfaces/auth) | - -## `LuciaError()` - -Error class thrown by Lucia. See reference for [`LuciaError`](/reference/lucia/modules/main#luciaerror). - -```ts -import { LuciaError } from "lucia"; -``` - -##### Example - -```ts -try { - // ... -} catch (e) { - if (e instanceof LuciaError) { - // Lucia error - } -} -``` diff --git a/documentation/content/reference/lucia/modules/middleware.md b/documentation/content/reference/lucia/modules/middleware.md deleted file mode 100644 index 45e2702ff..000000000 --- a/documentation/content/reference/lucia/modules/middleware.md +++ /dev/null @@ -1,449 +0,0 @@ ---- -title: "`lucia/middleware`" -format: "code" ---- - -## `astro()` - -Middleware for Astro 1.x, 2.x, and 3.x. - -```ts -const astro: Middleware; -``` - -##### Usage - -```ts -auth.handleRequest(context as APIContext); -``` - -| name | type | -| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `context` | [`APIContext`](https://docs.astro.build/en/reference/api-reference/#endpoint-context)`\|`[`Astro`](https://docs.astro.build/en/reference/api-reference/#astro-global) | - -```ts -import { astro } from "lucia/middleware"; - -const auth = lucia({ - middleware: astro() - // ... -}); -``` - -## `elysia()` - -Middleware for Elysia. - -```ts -const elysia: Middleware; -``` - -##### Usage - -```ts -auth.handleRequest(context as Context); -``` - -| name | type | -| --------- | -------------------------------------------------------------- | -| `context` | [`Context`](https://elysiajs.com/concept/handler.html#context) | - -```ts -new Elysia().get("/", (context) => { - auth.handleRequest(context); -}); -``` - -## `express()` - -Middleware for Express 4.x and 5.x. - -```ts -const express: () => Middleware; -``` - -##### Usage - -```ts -import { express } from "lucia/middleware"; - -const auth = lucia({ - middleware: express() - // ... -}); -``` - -```ts -auth.handleRequest(request as Request, response as Response); -``` - -| name | type | -| ---------- | ------------------------------------------------------ | -| `request` | [`Request`](https://expressjs.com/en/4x/api.html#req) | -| `response` | [`Response`](https://expressjs.com/en/4x/api.html#res) | - -## `fastify()` - -Middleware for Fastify. - -```ts -const fastify = () => Middleware; -``` - -##### Usage - -```ts -import { fastify } from "lucia/middleware"; - -const auth = lucia({ - middleware: fastify() - // ... -}); -``` - -```ts -auth.handleRequest(request as FastifyRequest, reply as FastifyReply); -``` - -| name | type | -| --------- | --------------------------------------------------------------------------------- | -| `request` | [`FastifyRequest`](https://fastify.dev/docs/latest/Reference/TypeScript/#request) | -| `reply` | [`FastifyReply`](https://fastify.dev/docs/latest/Reference/TypeScript/#reply) | - -## `h3()` - -Middleware for H3 (Nuxt 3). - -```ts -const h3: () => Middleware; -``` - -#### Usage - -```ts -import { h3 } from "lucia/middleware"; - -const auth = lucia({ - middleware: h3() - // ... -}); -``` - -```ts -auth.handleRequest(event as H3Event); -``` - -| name | type | -| ------- | ----------------------------------------------------- | -| `event` | [`H3Event`](https://www.jsdocs.io/package/h3#H3Event) | - -## `hono()` - -Middleware for Hono. - -```ts -const hono: Middleware; -``` - -##### Usage - -```ts -auth.handleRequest(context as Context); -``` - -| name | type | -| --------- | ----------------------------------------- | -| `context` | [`Context`](https://hono.dev/api/context) | - -```ts -app.get("/", (context) => { - auth.handleRequest(context); -}); -``` - -## `lucia()` - -The default middleware. - -```ts -const lucia: () => Middleware; -``` - -##### Usage - -```ts -import { lucia as luciaMiddleware } from "lucia/middleware"; - -const auth = lucia({ - middleware: luciaMiddleware() - // ... -}); -``` - -```ts -auth.handleRequest(requestContext as RequestContext); -``` - -| name | type | -| ---------------- | --------------------------------------------------------- | -| `requestContext` | [`RequestContext`](/reference/lucia/types#requestcontext) | - -## `nextjs()` - -**While this is not deprecated, it will be replaced with [`nextjs_future()`](#nextjs_future) in the next major release**. - -Middleware for Next.js v12 and v13 - supports both `pages` and `app` directory. **[`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession) is disabled** when: - -- Just `NextRequest` is passed -- Used inside `getServerSideProps()` in Edge runtime - -```ts -const nextjs: () => Middleware; -``` - -##### Usage - -```ts -import { nextjs } from "lucia/middleware"; - -const auth = lucia({ - middleware: nextjs() - // ... -}); -``` - -```ts -auth.handleRequest({ - req: req as IncomingMessage, - res: res as OutgoingMessage | undefined -}); - -auth.handleRequest({ - request: request as NextRequest | null, - cookies: cookies as Cookies -}); -``` - -```ts -// for middleware and API routes in edge runtime -const authRequest = auth.handleRequest(request as NextRequest); -authRequest.setSession(); // error! -``` - -| name | type | -| ----- | ------------------------------------------------------------------------------- | -| `req` | [`IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) | -| `res` | [`OutgoingMessage`](https://nodejs.org/api/http.html#class-httpoutgoingmessage) | - -| name | type | description | -| --------- | ------------------------------------------------------------------------------------------ | ---------------------------------------- | -| `request` | [`NextRequest`](https://nextjs.org/docs/app/api-reference/functions/next-request)`\| null` | Should be provided when using API routes | -| `cookies` | [`Cookies`](https://nextjs.org/docs/app/api-reference/functions/cookies) | | - -| name | type | -| ----- | ------------------------------------------------------------------------------- | -| `req` | [`IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) | - -| name | type | -| --------- | --------------------------------------------------------------------------------- | -| `request` | [`NextRequest`](https://nextjs.org/docs/app/api-reference/functions/next-request) | - -## `nextjs_future()` - -A newer version of `nextjs()` middleware for Lucia v3. We recommend using this middleware for future proofing your codebase. - -Middleware for Next.js v12 and v13 - supports both `pages` and `app` directory. **[`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession) is disabled** when: - -- Just `NextRequest` is passed -- Used inside `getServerSideProps()` in Edge runtime - -```ts -const nextjs_future: () => Middleware; -``` - -##### Usage - -```ts -import { nextjs } from "lucia/middleware"; - -const auth = lucia({ - middleware: nextjs() - // ... -}); -``` - -```ts -auth.handleRequest({ - req: req as IncomingMessage, - res: res as OutgoingMessage | undefined -}); - -auth.handleRequest(requestMethod as string, { - cookies: cookies as Cookies, - headers: headers as Headers -}); -``` - -```ts -// for middleware and API routes in edge runtime -const authRequest = auth.handleRequest(req as IncomingMessage); -const authRequest = auth.handleRequest(request as NextRequest); -authRequest.setSession(); // error! -``` - -| name | type | optional | -| ----- | ------------------------------------------------------------------------------- | -------- | -| `req` | [`IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) | | -| `res` | [`OutgoingMessage`](https://nodejs.org/api/http.html#class-httpoutgoingmessage) | | - -| name | type | description | -| ----------------- | ------------------------------------------------------------------------ | -------------------------- | -| `requestMethod` | `string` | Can be upper or lower case | -| `context.cookies` | [`Cookies`](https://nextjs.org/docs/app/api-reference/functions/cookies) | | -| `context.headers` | [`Headers`](https://nextjs.org/docs/app/api-reference/functions/headers) | | - -| name | type | -| ----- | ------------------------------------------------------------------------------- | -| `req` | [`IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) | - -| name | type | -| --------- | --------------------------------------------------------------------------------- | -| `request` | [`NextRequest`](https://nextjs.org/docs/app/api-reference/functions/next-request) | - -## `node()` - -Middleware for Node.js. - -```ts -const node = () => Middleware; -``` - -##### Usage - -```ts -import { node } from "lucia/middleware"; - -const auth = lucia({ - middleware: node() - // ... -}); -``` - -```ts -auth.handleRequest(request as IncomingMessage, response as OutgoingMessage); -``` - -| name | type | -| ---------- | ------------------------------------------------------------------------------- | -| `request` | [`IncomingMessage`](https://nodejs.org/api/http.html#class-httpincomingmessage) | -| `response` | [`OutgoingMessage`](https://nodejs.org/api/http.html#class-httpoutgoingmessage) | - -## `sveltekit()` - -Middleware for SvelteKit 1.x. - -```ts -const sveltekit: () => Middleware; -``` - -##### Usage - -```ts -import { sveltekit } from "lucia/middleware"; - -const auth = lucia({ - middleware: sveltekit() - // ... -}); -``` - -```ts -auth.handleRequest(event as RequestEvent); -``` - -| name | type | -| ------- | ----------------------------------------------------------------------------- | -| `event` | [`RequestEvent`](https://kit.svelte.dev/docs/types#public-types-requestevent) | - -## `web()` - -Middleware for web standard request. **[`AuthRequest.setSession()`](/reference/lucia/interfaces/authrequest#setsession) is disabled when using the `web()` middleware.** - -```ts -const web: () => Middleware; -``` - -##### Usage - -```ts -import { web } from "lucia/middleware"; - -const auth = lucia({ - middleware: web() - // ... -}); -``` - -```ts -const authRequest = auth.handleRequest(request as Request); -authRequest.setSession(); // error! -``` - -| name | type | -| --------- | --------------------------------------------------------------------- | -| `request` | [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) | - -## `qwik()` - -Middleware for Qwik City. - -```ts -const qwik: () => Middleware; -``` - -##### Usage - -```ts -import { qwik } from "lucia/middleware"; - -const auth = lucia({ - middleware: qwik() - // ... -}); -``` - -```ts -auth.handleRequest(requestEvent as RequestEventLoader); -auth.handleRequest(requestEvent as RequestEventAction); -``` - -| name | type | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `requestEvent` | [`RequestEventLoader`](https://qwik.builder.io/docs/route-loader/#requestevent)`\|`[`RequestEventAction`](https://qwik.builder.io/docs/action/#http-request-and-response) | - -## `elysia()` - -Middleware for Elysia. - -```ts -const elysia: () => Middleware; -``` - -##### Usage - -```ts -import { elysia } from "lucia/middleware"; - -const auth = lucia({ - middleware: elysia() - // ... -}); -``` - -```ts -auth.handleRequest(context as Context); -``` - -| name | type | -| --------- | -------------------------------------------------------------- | -| `context` | [`Context`](https://elysiajs.com/concept/handler.html#context) | diff --git a/documentation/content/reference/lucia/modules/polyfill/node.md b/documentation/content/reference/lucia/modules/polyfill/node.md deleted file mode 100644 index 6b8f74cf4..000000000 --- a/documentation/content/reference/lucia/modules/polyfill/node.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: "`lucia/polyfill/node`" -format: "code" ---- - -Used as a side-effect import to polyfill the [`WebCrypto` API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) in Node.js version 18 and below. - -```ts -import "lucia/polyfill/node"; -``` diff --git a/documentation/content/reference/lucia/modules/utils.md b/documentation/content/reference/lucia/modules/utils.md deleted file mode 100644 index 080267a6b..000000000 --- a/documentation/content/reference/lucia/modules/utils.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -title: "`lucia/utils`" -format: "code" ---- - -## `generateLuciaPasswordHash()` - -Creates a new Lucia hash of a password. **If you're looking for a general hashing function, DO NOT use this API.** The output hash is designed in a way to be compatible with older versions of Lucia. **The sole purpose of this API is for interacting with Lucia keys stored in your database, and nothing else.** - -```ts -const generateLuciaPasswordHash: (password: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| ---------- | -------- | ---------------- | -| `password` | `number` | Password to hash | - -##### Returns - -| type | description | -| -------- | ----------- | -| `string` | Hash | - -## `generateRandomString()` - -Generates a cryptographically random string. If argument for parameter `alphabet` is not provided, the result with consist of `a-z0-9` (lowercase letters, numbers). - -```ts -const generateRandomString: (length: number, alphabet?: string) => string; -``` - -##### Parameters - -| name | type | optional | description | -| ---------- | -------- | :------: | -------------------------------------------------- | -| `length` | `number` | | Length string to generate | -| `alphabet` | `string` | ✓ | String of characters to generate the string from ` | - -##### Returns - -| type | description | -| -------- | ------------------------- | -| `string` | Randomly generated string | - -## `isWithinExpiration()` - -Checks with the current time is within the expiration time (in milliseconds UNIX time) provided. - -```ts -const isWithinExpiration: (expiration: number) => boolean; -``` - -##### Parameters - -| name | type | description | -| ------------ | -------- | ------------------------------------------- | -| `expiration` | `number` | Expiration time in milliseconds (UNIX time) | - -##### Returns - -| value | description | -| ------- | -------------------- | -| `true` | Is within expiration | -| `false` | Is expired | - -## `joinAdapters()` - -**This is an experimental API and can change or be removed.** - -Joins multiple adapters into a single adapter, allowing you to override specific methods. - -```ts -import { __experimental_joinAdapters } from "lucia/utils"; -``` - -```ts -const joinAdapters: ( - baseAdapter: InitializeAdapter, - ...adapters: Array< - Partial | InitializeAdapter - > -) => Adapter; -``` - -##### Parameters - -| name | type | -| ------------- | ------------------- | -| `baseAdapter` | `InitializeAdapter` | -| `adapters` | array | - -##### Returns - -| type | -| --------- | -| `Adapter` | - -##### Usage - -```ts -import { lucia } from "lucia"; -import { __experimental_joinAdapters as joinAdapters } from "lucia/utils"; -import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; - -export const auth = lucia({ - adapter: joinAdapters(betterSqlite3(), { - getUser: async (userId) => { - // ... - } - }) -}); -``` - -## `parseCookie()` - -ESM and TypeScript friendly [`cookie.parse()`](https://github.com/jshttp/cookie#cookieparsestr-options) from [`cookie`](https://github.com/jshttp/cookie). - -## `serializeCookie()` - -ESM and TypeScript friendly [`cookie.serialize()`](https://github.com/jshttp/cookie#cookieserializename-value-options) from [`cookie`](https://github.com/jshttp/cookie). - -## `validateLuciaPasswordHash()` - -Validates a password hash generated by Lucia. - -```ts -const validateLuciaPasswordHash: ( - password: string, - passwordHash: string -) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------------- | -------- | --------------------------- | -| `password` | `string` | Password to compare against | -| `passwordHash` | `string` | Hashed password to validate | - -##### Returns - -| value | description | -| ------- | ----------- | -| `true` | Is valid | -| `false` | Is invalid | diff --git a/documentation/content/reference/middleware.md b/documentation/content/reference/middleware.md deleted file mode 100644 index 772ae6d18..000000000 --- a/documentation/content/reference/middleware.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "Middleware API" -description: "Learn how to implement your own middleware" ---- - -Middleware transform framework and runtime specific request/response objects passed to [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) into Lucia's specific API. - -## `Middleware` - -```ts -type Middleware<_Args extends any[] = any> = (context: { - args: _Args; - env: "DEV" | "PROD"; - sessionCookieName: string; -}) => RequestContext; -``` - -##### Parameters - -| name | type | description | -| ------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `context.args` | `_Args` | Arguments passed to [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) when the middleware is used | -| `context.env` | `"DEV" \| "PROD"` | Project [`env`](/basics/configuration#env) configuration | -| `sessionCookieName` | `string` | Session cookie name defined in [`sessionCookie.name`](/basics/configuration#sessioncookie) configuration (or default name if undefined) | - -##### Generics - -| name | extends | description | -| ------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | -| `_Args` | `any[]` | Parameter type of [`Auth.handleRequest()`](/reference/lucia/interfaces/auth#handlerequest) when the middleware is used | - -### `RequestContext` - -```ts -type RequestContext = { - request: LuciaRequest; - setCookie: (cookie: Cookie) => void; -}; -``` - -##### Properties - -| property | type | -| --------- | ---------------------------------------------------------- | -| `request` | [`LuciaRequest`](/reference/lucia/interfaces#luciarequest) | - -#### `setCookie()` - -Sets the provided cookie. - -```ts -const setCookie: (cookie: Cookie) => void; -``` - -##### Parameters - -| name | type | description | -| -------- | ---------------------------------------------- | ------------- | -| `cookie` | [`Cookie`](/reference/lucia/interfaces#cookie) | Cookie to set | - -## Typing `Middleware` - -When creating a middleware, it's crucial that you explicitly declare type `Middleware` so that parameters `context.args` is properly typed. - -```ts -export const customMiddleware = (): Middleware<[Request]> => { - return (context) => { - return { - // ... - }; - }; -}; -``` diff --git a/documentation/content/reference/oauth/interfaces/index.md b/documentation/content/reference/oauth/interfaces/index.md deleted file mode 100644 index 911da2457..000000000 --- a/documentation/content/reference/oauth/interfaces/index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: "Interfaces" ---- - -## `OAuth2ProviderAuth` - -See [`OAuth2ProviderAuth`](/reference/oauth/interfaces/oauth2providerauth). - -## `OAuth2ProviderAuthWithPKCE` - -See [`OAuth2ProviderAuthWithPKCE`](/reference/oauth/interfaces/oauth2providerauthwithpkce). - -## `OAuthRequestError` - -Extends standard `Error`. - -```ts -interface OAuthRequestError extends Error { - request: Request; - response: Response; -} -``` - -## `ProviderUserAuth` - -See [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth). diff --git a/documentation/content/reference/oauth/interfaces/oauth2providerauth.md b/documentation/content/reference/oauth/interfaces/oauth2providerauth.md deleted file mode 100644 index 2d5e75ac9..000000000 --- a/documentation/content/reference/oauth/interfaces/oauth2providerauth.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: "`OAuth2ProviderAuth`" ---- - -```ts -interface OAuth2ProviderAuth<_ProviderUserAuth extends ProviderUserAuth> { - getAuthorizationUrl: () => Promise; - validateCallback: (code: string) => Promise<_ProviderUserAuth>; -} -``` - -##### Generics - -| name | extends | description | -| ------------------- | ------------------------------------------------------------------ | ----------------------------------------------------- | -| `_ProviderUserAuth` | [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth) | [`validateCallback()`](#validatecallback) return type | - -## `getAuthorizationUrl()` - -Creates a new authorization url, optional with a state. - -```ts -const getAuthorizationUrl: () => Promise< - readonly [url: URL, state: string | null] ->; -``` - -##### Returns - -| name | type | description | -| ------- | ---------------- | ----------------- | -| `url` | `URL` | authorization url | -| `state` | `string \| null` | state, if defined | - -## `validateCallback()` - -Validates the authorization code and returns a new [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth) instance. - -```ts -const validateCallback: (code: string) => Promise<_ProviderUserAuth>; -``` - -##### Parameters - -| name | type | description | -| ------ | -------- | ------------------ | -| `code` | `string` | authorization code | - -##### Returns - -| type | -| -------------------------------- | -| [`_ProviderUserAuth`](#generics) | diff --git a/documentation/content/reference/oauth/interfaces/oauth2providerauthwithpkce.md b/documentation/content/reference/oauth/interfaces/oauth2providerauthwithpkce.md deleted file mode 100644 index 4b1674c39..000000000 --- a/documentation/content/reference/oauth/interfaces/oauth2providerauthwithpkce.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "`OAuth2ProviderAuthWithPKCE`" ---- - -```ts -interface OAuth2ProviderAuthWithPKCE< - _ProviderUserAuth extends ProviderUserAuth -> { - getAuthorizationUrl: () => Promise< - readonly [url: URL, codeVerifier: string, state: string | null] - >; - validateCallback: ( - code: string, - codeVerifier: string - ) => Promise<_ProviderUserAuth>; -} -``` - -##### Generics - -| name | extends | description | -| ------------------- | ------------------------------------------------------------------ | ----------------------------------------------------- | -| `_ProviderUserAuth` | [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth) | [`validateCallback()`](#validatecallback) return type | - -## `getAuthorizationUrl()` - -Creates a new authorization url, optional with a state. - -```ts -const getAuthorizationUrl: () => Promise< - readonly [url: URL, codeVerifier: string, state: string | null] ->; -``` - -##### Returns - -| name | type | description | -| -------------- | ---------------- | ----------------- | -| `url` | `URL` | authorization url | -| `codeVerifier` | `string` | code verifier | -| `state` | `string \| null` | state, if defined | - -## `validateCallback()` - -Validates the authorization code and code verifier, and returns a new [`ProviderUserAuth`](/reference/oauth/interfaces/provideruserauth) instance. - -```ts -const validateCallback: ( - code: string, - codeVerifier: string -) => Promise<_ProviderUserAuth>; -``` - -##### Parameters - -| name | type | description | -| -------------- | -------- | ------------------ | -| `code` | `string` | authorization code | -| `codeVerifier` | `string` | code verifier | - -##### Returns - -| type | -| -------------------------------- | -| [`_ProviderUserAuth`](#generics) | diff --git a/documentation/content/reference/oauth/interfaces/provideruserauth.md b/documentation/content/reference/oauth/interfaces/provideruserauth.md deleted file mode 100644 index 32f94dcf6..000000000 --- a/documentation/content/reference/oauth/interfaces/provideruserauth.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "`ProviderUserAuth`" ---- - -```ts -interface ProviderUserAuth { - createKey: (userId: string) => Promise; - createUser: (options: { - userId?: string; - attributes: Lucia.DatabaseUserAttributes; - }) => Promise; - getExistingUser: () => Promise; -} -``` - -### `createKey()` - -Creates a new key using the OAuth provider. - -```ts -const createKey: (userId: string) => Promise; -``` - -##### Parameters - -| name | type | description | -| -------- | -------- | ----------------------- | -| `userId` | `string` | User to link the key to | - -##### Returns - -| type | description | -| ---------------------------------------- | ----------- | -| [`Key`](/reference/lucia/interfaces#key) | A new key | - -### `createUser()` - -Creates a new user and a key using the OAuth provider. - -```ts -const createUser: (options: { - userId?: string; - attributes: Lucia.DatabaseUserAttributes; -}) => Promise; -``` - -##### Parameters - -| name | type | optional | description | -| -------------------- | ------------------------------ | :------: | ------------------------------- | -| `options.userId` | `string` | ✓ | User id of new user | -| `options.attributes` | `Lucia.DatabaseUserAttributes` | | User attributes of the new user | - -##### Returns - -| type | description | -| ------------------------------------------ | ----------- | -| [`User`](/reference/lucia/interfaces#user) | A new user | - -## `getExistingUser()` - -Returns a user linked to the provider account, if it exists. - -##### Returns - -| type | -| --------------------------------------------------- | -| [`User`](/reference/lucia/interfaces#user)`\| null` | diff --git a/documentation/content/reference/oauth/modules/main.md b/documentation/content/reference/oauth/modules/main.md deleted file mode 100644 index 7bea0594e..000000000 --- a/documentation/content/reference/oauth/modules/main.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: "`@lucia-auth/oauth`" ---- - -## `createOAuth2AuthorizationUrl()` - -Creates a new authorization url for OAuth 2.0 authorization code grant with a state. Use [`createOAuth2AuthorizationUrlWithPKCE()`](/reference/oauth/modules/main#createoauth2authorizationurlwithpkce) for creating urls with PKCE code challenge. - -```ts -const createOAuth2AuthorizationUrl: ( - url: string | URL, - options: { - clientId: string; - scope: string[]; - redirectUri?: string; - } -) => Promise; -``` - -##### Parameters - -| name | type | description | -| ------------------ | --------------- | ---------------------------- | -| `url` | `string \| URL` | Authorization url base | -| `options.clientId` | `string` | `client_id` | -| `options.scope` | `string[]` | A list of values for `scope` | -| `redirectUri` | `string` | `redirect_uri` | - -##### Returns - -| name | type | description | -| ------------------ | -------- | ----------------- | -| `authorizationUrl` | `URL` | Authorization url | -| `state` | `string` | Generated state | - -## `createOAuth2AuthorizationUrlWithPKCE()` - -Creates a new authorization url for OAuth 2.0 authorization code grant with a state and PKCE code challenge. - -```ts -const createOAuth2AuthorizationUrlWithPKCE: ( - url: string | URL, - options: { - clientId: string; - scope: string[]; - codeChallengeMethod: "S256"; - redirectUri?: string; - } -) => Promise< - readonly [authorizationUrl: URL, codeVerifier: string, state: string] ->; -``` - -##### Parameters - -| name | type | description | -| ----------------------------- | --------------- | ---------------------------- | -| `url` | `string \| URL` | Authorization url base | -| `options.clientId` | `string` | `client_id` | -| `options.scope` | `string[]` | A list of values for `scope` | -| `options.codeChallengeMethod` | `"S256"` | Code challenge method | -| `redirectUri` | `string` | `redirect_uri` | - -##### Returns - -| name | type | description | -| ------------------ | -------- | ----------------------- | -| `authorizationUrl` | `URL` | Authorization url | -| `codeVerifier` | `string` | Generated code verifier | -| `state` | `string` | Generated state | - -## `decodeIdToken()` - -Decodes the OpenID Connect Id Token and returns the claims. **Does NOT validate the JWT**. Throws `SyntaxError` if provided id token is invalid or malformed. - -```ts -const decodeIdToken: <_Claims extends {}>( - idToken: string -) => { - iss: string; - aud: string; - exp: number; -} & _Claims; -``` - -##### Parameters - -| name | type | -| --------- | -------- | -| `idToken` | `string` | - -##### Generics - -| name | extends | description | -| --------- | ------- | ------------------ | -| `_Claims` | `{}` | JWT payload claims | - -##### Returns - -JWT payload. - -## `OAuthRequestError` - -`class`. See [`OAuthRequestError`](/reference/oauth/interfaces#oauthrequesterror). - -## `providerUserAuth()` - -Creates a new [`ProviderUserAuth`](/reference/oauth/interfaces#provideruserauth) instance. - -```ts -const providerUserAuth: ( - auth: Auth, - providerId: string, - providerUserId: string -) => ProviderUserAuth; -``` - -##### Parameters - -| name | type | description | -| ---------------- | ------------------------------------------ | -------------------- | -| `auth` | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | -| `providerId` | `string` | Key provider id | -| `providerUserId` | `string` | Key provider user id | - -##### Returns - -| type | -| ------------------------------------------------------------------ | -| [`ProviderUserAuth`](/reference/oauth/interfaces#provideruserauth) | - -## `validateOAuth2AuthorizationCode()` - -Validates OAuth 2.0 authorization code by sending a request to the provided url. Returns the JSON-parsed response body. - -```ts -const validateOAuth2AuthorizationCode: <_ResponseBody extends {}>( - authorizationCode: string, - url: string | URL, - options: { - clientId: string; - redirectUri?: string; - codeVerifier?: string; - clientPassword?: { - clientSecret: string; - authenticateWith: "client_secret" | "http_basic_auth"; - }; - } -) => Promise<_ResponseBody>; -``` - -##### Parameters - -| name | type | description | -| ----------------------------------------- | ------------------------- | --------------------- | -| `authorizationCode` | `string` | Authorization code | -| `url` | `URL \| string` | Access token endpoint | -| `options.redirectUri` | `string` | `redirect_uri` | -| `options.codeVerifier` | `string` | `code_verifier` | -| `options.clientPassword` | | | -| `options.clientPassword.clientSecret` | `string` | Client secret | -| `options.clientPassword.authenticateWith` | `AuthenticateWithOptions` | See below | - -##### Generics - -| name | extends | description | -| --------------- | ------- | ----------------------------------------- | -| `_ResponseBody` | `{}` | Response body of the access token request | - -##### `AuthenticateWithOptions` - -| value | description | -| ------------------- | ------------------------------------------------------------------------------- | -| `"client_secret"` | Send the client secret inside request body as `client_secret` | -| `"http_basic_auth"` | Send the client secret with the client id with HTTP Basic authentication scheme | diff --git a/documentation/content/reference/oauth/modules/providers.md b/documentation/content/reference/oauth/modules/providers.md deleted file mode 100644 index 5a5d3fcb9..000000000 --- a/documentation/content/reference/oauth/modules/providers.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: "`@lucia-auth/oauth/providers`" ---- - -## `apple()` - -See [Apple](/oauth/providers/apple) provider. - -## `atlassian()` - -See [Atlassian](/oauth/providers/atlassian) provider. - -## `auth0()` - -See [Auth0](/oauth/providers/auth0) provider. - -## `azureAD()` - -See [Azure Active Directory](/oauth/providers/azure-ad) provider. - -## `bitbucket()` - -See [Bitbucket](/oauth/providers/bitbucket) provider. - -## `box()` - -See [Box](/oauth/providers/box) provider. - -## `discord()` - -See [Discord](/oauth/providers/discord) provider. - -## `dropbox()` - -See [Dropbox](/oauth/providers/dropbox) provider. - -## `facebook()` - -See [Facebook](/oauth/providers/facebook) provider. - -## `github()` - -See [GitHub](/oauth/providers/github) provider. - -## `gitlab()` - -See [GitLab](/oauth/providers/gitlab) provider. - -## `google()` - -See [Google](/oauth/providers/google) provider. - -## `kakao()` - -See [Kakao](/oauth/providers/kakao) provider. - -## `keycloak()` - -See [Keycloak](/oauth/providers/keycloak) provider. - -## `lichess()` - -See [Lichess](/oauth/providers/lichess) provider. - -## `line()` - -See [Line](/oauth/providers/line) provider. - -## `linkedIn()` - -See [LinkedIn](/oauth/providers/linkedin) provider. - -## `osu()` - -See [osu!](/oauth/providers/osu) provider. - -## `patreon()` - -See [Patreon](/oauth/providers/patreon) provider. - -## `reddit()` - -See [Reddit](/oauth/providers/reddit) provider. - -## `salesforce()` - -See [Salesforce](/oauth/providers/salesforce) provider. - -## `slack()` - -See [Slack](/oauth/providers/slack) provider. - -## `spotify()` - -See [Spotify](/oauth/providers/spotify) provider. - -## `strava()` - -See [Strava](/oauth/providers/strava) provider. - -## `twitch()` - -See [Twitch](/oauth/providers/twitch) provider. - -## `twitter()` - -See [Twitter](/oauth/providers/twitter) provider. diff --git a/documentation/integrations/markdown/index.ts b/documentation/integrations/markdown/index.ts deleted file mode 100644 index c687e4e50..000000000 --- a/documentation/integrations/markdown/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import rehype from "./rehype"; - -import type { AstroIntegration } from "astro"; - -export default () => { - const integration: AstroIntegration = { - name: "lucia:markdown", - hooks: { - "astro:config:setup": ({ updateConfig }) => { - updateConfig({ - markdown: { - rehypePlugins: [rehype()] - } - }); - } - } - }; - return integration; -}; - -export const removeMarkdownFormatting = (text: string) => { - return text.replaceAll("`", ""); -}; - -export const generateMarkdownHtml = (text: string) => { - return text.replace(/`(.*)`/g, "$1"); -}; diff --git a/documentation/integrations/markdown/rehype.ts b/documentation/integrations/markdown/rehype.ts deleted file mode 100644 index 7210ea264..000000000 --- a/documentation/integrations/markdown/rehype.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { - Root, - RootContent, - Element as HastElementInterface, - ElementContent as HastElementContent, - Text as HastTextNodeInterface -} from "hast"; - -class HastElement implements HastElementInterface { - public readonly type = "element"; - public children; - public tagName; - public properties?; - constructor( - tagName: string, - options: { - properties?: Record< - any, - string | number | boolean | (string | number)[] | null | undefined - >; - children?: HastElementContent[]; - } - ) { - this.tagName = tagName; - this.children = options.children ?? []; - this.properties = options.properties; - } -} - -class HastTextNode implements HastTextNodeInterface { - public readonly type = "text"; - public value: string; - constructor(value: string) { - this.value = value; - } -} - -const handleHeadings = (element: HastElement) => { - const headingTags = ["h1", "h2", "h3", "h4", "h5"]; - if (!headingTags.includes(element.tagName)) return; - const headingId = element.properties?.id; - if (!headingId) return; - if (!element.properties) { - element.properties = {}; - } - element.properties.id = headingId; - element.properties.class = "relative block flex group"; - element.children.push( - new HastElement("a", { - properties: { - href: `#${headingId}`, - class: - "w-4 -ml-5 pl-0.5 sm:pl-0 sm:-ml-6 absolute block group-hover:!text-main !text-zinc-200 shrink-0", - "aria-label": "Permalink" - }, - children: [new HastTextNode("#")] - }) - ); -}; - -const handleBlockquoteElement = (element: HastElement) => { - if (element.tagName !== "blockquote") return; - const pElement = element.children.find((child) => { - if (child.type !== "element") return false; - if (child.tagName !== "p") return false; - return true; - }); - if (pElement?.type !== "element") return; - if (!element.properties) return; - const firstTextContent = pElement.children.filter( - (child): child is HastTextNode => child.type === "text" - )[0]; - - const classNames = [ - ...(element.properties.class?.toString() ?? "").split(" "), - "bg-default" - ]; - if (firstTextContent.value.startsWith("(warn)")) { - classNames.push("bq-warn"); - firstTextContent.value = firstTextContent.value.replace("(warn)", ""); - } - if (firstTextContent.value.startsWith("(red)")) { - classNames.push("bq-red"); - firstTextContent.value = firstTextContent.value.replace("(red)", ""); - } - element.properties.class = classNames.join(" "); -}; - -const wrapTableElement = (content: Root) => { - const tableChildren = content.children - .map((child, i) => { - return [child, i] as const; - }) - .filter(([child]) => child.type === "element" && child.tagName === "table"); - for (const [tableChild, position] of tableChildren) { - if (tableChild.type !== "element") continue; - const wrapperDivElement = new HastElement("div", { - properties: { - class: "table-wrapper" - }, - children: [tableChild] - }); - content.children[position] = wrapperDivElement; - } -}; - -const parseContent = (content: Root | RootContent) => { - if (content.type !== "element" && content.type !== "root") return; - if (content.type === "root") { - wrapTableElement(content); - } - if (content.type === "element") { - handleBlockquoteElement(content); - handleHeadings(content); - } - for (const children of content.children) { - parseContent(children); - } -}; - -const rehypePlugin = (root: Root) => { - parseContent(root); -}; - -export default () => { - const initializePlugin = () => rehypePlugin; - return initializePlugin; -}; diff --git a/documentation/integrations/og/index.ts b/documentation/integrations/og/index.ts deleted file mode 100644 index affc70bee..000000000 --- a/documentation/integrations/og/index.ts +++ /dev/null @@ -1,220 +0,0 @@ -import fs from "fs/promises"; -import path from "path"; -import os from "os"; -import { - createCanvas, - GlobalFonts, - loadImage, - clearAllCache -} from "@napi-rs/canvas"; - -import type { AstroIntegration } from "astro"; -import type { SKRSContext2D } from "@napi-rs/canvas"; - -export default () => { - const integration: AstroIntegration = { - name: "lucia:og", - hooks: { - "astro:build:done": generateOgImages - } - }; - return integration; -}; - -type Page = { - title: string; - pathname: string; - description: string | null; - url: string; -}; - -clearAllCache(); - -const distDirPathname = path.join(process.cwd(), "dist"); - -export const generateOgImages = async () => { - const distDirState = await fs.stat(distDirPathname); - if (!distDirState.isDirectory()) { - throw new Error("Expect 'dist' to be a directory"); - } - const htmlPathnames = await readHtmlDirectory(distDirPathname); - const pages: Page[] = []; - for (const htmlPathname of htmlPathnames) { - console.log(htmlPathname); - const file = await fs.readFile(htmlPathname); - const htmlContent = file.toString("utf-8"); - const titleMatches = htmlContent.match( - /og:title"\s*?content="([\s\S]*?)"\s*>/ - ); - const title = titleMatches?.at(1)?.replace(" Lucia", ""); - if (!title) continue; - const descriptionMatches = htmlContent.match( - /og:description"\s*?content="([\s\S]*?)"\s*>/ - ); - const description = descriptionMatches?.at(1) || null; - const url = htmlPathname - .replace(distDirPathname, "") - .replace("/index.html", ""); - console.log(url); - pages.push({ - title, - pathname: htmlPathname, - description, - url - }); - } - const concurrency = os.cpus().length; - console.log( - `Generating ${pages.length} images with ${concurrency} concurrency` - ); - const groups = groupByN(pages, concurrency); - for (const group of groups) { - await Promise.all( - group.map(async (page) => { - const imagePathname = path.join( - process.cwd(), - "dist", - "og", - page.url + ".jpg" - ); - const image = await createImage(page.title, page.description); - await fs.mkdir(path.dirname(imagePathname), { - recursive: true - }); - console.log(`Generated image: ${page.url}`); - await fs.writeFile(imagePathname, image); - }) - ); - } -}; - -function groupByN(arr: T[], n: number): T[][] { - const result: T[][] = []; - for (let i = 0; i < arr.length; i += n) { - result.push(arr.slice(i, i + n)); - } - return result; -} - -const readHtmlDirectory = async (pathname: string): Promise => { - const ignoreHtmlPathnames = ["404.html", "index.html"].map((filename) => - path.join(distDirPathname, filename) - ); - const contentNames = await fs.readdir(pathname); - const filePaths: string[] = []; - const readChildDirectoryPromises: Promise[] = []; - for (const contentName of contentNames) { - if (contentName.endsWith(".html")) { - const htmlPathname = path.join(pathname, contentName); - if (ignoreHtmlPathnames.includes(htmlPathname)) continue; - filePaths.push(htmlPathname); - } - if (contentName.includes(".")) { - continue; - } - readChildDirectoryPromises.push( - readHtmlDirectory(path.join(pathname, contentName)) - ); - } - const childDirectoryFiles = await Promise.all(readChildDirectoryPromises); - for (const childDirectoryFilenames of childDirectoryFiles) { - filePaths.push(...childDirectoryFilenames); - } - return filePaths; -}; - -GlobalFonts.registerFromPath("integrations/og/inter-semibold.ttf", "Inter"); -GlobalFonts.registerFromPath("integrations/og/inter-medium.ttf", "Inter"); - -const logo = await fs.readFile( - path.join(process.cwd(), "integrations/og/logo.png") -); - -const logoImage = await loadImage(logo); - -const createImage = async ( - title: string, - description: string | null -): Promise => { - const canvas = createCanvas(1200, 630); - - const canvasContext = canvas.getContext("2d"); - - canvasContext.fillStyle = "white"; - canvasContext.fillRect(0, 0, 1200, 630); - - let titleFontSize = 72; - canvasContext.font = `600 ${titleFontSize}px Inter`; - canvasContext.fillStyle = "black"; - - const titleTextWidth = canvasContext.measureText(title).width; - const maxLineWidth = 1000; - let wrappedTitleLines: string[]; - let titleY = 250; - if (titleTextWidth < maxLineWidth * 2) { - wrappedTitleLines = wrapCanvasText(canvasContext, title, maxLineWidth); - } else { - titleFontSize = 60; - canvasContext.font = `600 ${titleFontSize}px Inter`; - wrappedTitleLines = wrapCanvasText(canvasContext, title, maxLineWidth); - } - if (wrappedTitleLines.length > 2) { - titleY = 200; - } - for (const [lineNum, line] of wrappedTitleLines.entries()) { - canvasContext.fillText(line, 100, titleY + (titleFontSize + 8) * lineNum); - } - - if (description) { - canvasContext.font = `500 ${36}px Inter`; - const wrappedDescription = wrapCanvasText( - canvasContext, - description, - maxLineWidth - ); - for (const [lineNum, line] of wrappedDescription.entries()) { - canvasContext.fillText( - line, - 100, - titleY + - (36 + 8) * lineNum + - titleFontSize * wrappedTitleLines.length + - (wrappedTitleLines.length - 1) * 8 - ); - } - } - - canvasContext.drawImage(logoImage, 103, 500); - canvasContext.font = `500 ${40}px Inter`; - canvasContext.fillStyle = "#5f57ff"; - canvasContext.fillText("Lucia", 138, 532); - - return await canvas.encode("jpeg"); -}; - -const wrapCanvasText = ( - canvasContext: SKRSContext2D, - title: string, - maxLineWidth: number -): string[] => { - let currentLineTextWidth = 0; - let currentLineText = ""; - const lines: string[] = []; - const spaceTextWidth = canvasContext.measureText(" ").width; - for (const word of title.split(" ")) { - const wordTextWidth = canvasContext.measureText(word).width; - if (wordTextWidth + currentLineTextWidth < maxLineWidth) { - currentLineText = currentLineText + word + " "; - currentLineTextWidth = - currentLineTextWidth + wordTextWidth + spaceTextWidth; - } else { - lines.push(currentLineText); - currentLineText = word + " "; - currentLineTextWidth = wordTextWidth + spaceTextWidth; - } - } - if (currentLineText) { - lines.push(currentLineText); - } - return lines; -}; diff --git a/documentation/integrations/og/inter-medium.ttf b/documentation/integrations/og/inter-medium.ttf deleted file mode 100644 index b53fb1c4acbe100c7a91f07564b7f1fa2d5bab12..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 314712 zcmcG12YeO97w*pPy?bv0VhTxUf+3KoAUy#Qkw~}DLhmI62qXkTQ9ybRO;A9jgEZ+P zA_!6xMT%GeML>i6tq1`TNOE`X`_9bny?a9xe82a8C?Vg@o;h>o%$YN1&de%IQIrt; zgOEtAHMDz#71nx9bElc$>|wR>*zl)NE|W8_jrY4>u= zq^dQ_=co5p)M0pA>6z3vvF-JU?;0wOlg06SqdsZ9GOAYjZ5f^?D@xy){d#3)P;H>U z9Y5Xu2ECGUd+&>96@?X5)bc;}@7pW+$s@g*DC!Fr@I0YEfUOuPZj`&;tTTzuJs3Ofx^%PN*UeS#H#IWel zXf}uMQL|ZJHJk4-_JP3+Vq9KeN|@u!(Wg7^?=4NBqzVnc!2fO>osd`smG!LU|k=?+9-+- zU*f}4l&NZAcrvSP!w37yr?SUD&}!Lf(XvuXY+%cnXbm-u+NQ_VNQkc$8&h77C>~j8 zRww&6!^^}%xwyFDcn>J6^O|sfj zG|b}7JyU&BokzH_!!xSY~-4;JZ>iNpw zWR2KmA70Ie|D}A+P7zN01Y2^8)1@xC0{IHg^SyV0J+JB(JsW<}ju$!LD}RYKQj1&V zi_G!i*{q3L)>psP&vClwXQe>>kRuF{Urz3+CLLYx7vt+SpR{#%i}<@%oqjZEbu7yA zQzSXJZgCPc$H*tj-X{g_lW5Qzye%}<;ftX*8l-aR~|W@r8~h zeq%X{;fI#h8~ozny7B%|AA`EgLj`vU0r{<5H@;)a?06 zm7g8mqy30#75!YgDnHTZjYs0jHmOmu&(cR8FWZ=(=-d9C_?9(>CH4ikpGmHK@_f_V zRH|!rShc%fxAZ4y z(nImo(nITAmtP+XecL7V5^|6CJ&z-cD(fcg@!>Sf_~{BQ!t;ytJm`5y;PHU>KJ(#$ z@A>*OfUa4e!faEqUmpV}Vc7w4)(-9j`!9TfOq#BJe_~iT{@p~|tH0!_uFdic)4tEk zS>U-cNc+{T@Y}{be#e;2Z|gtgMyq9+gL`CO26qZ3O?rk}qNh1VShgBC*>H&`J0|*Z zVM_?tlx%d>+h8YLc|?H>Gee_mIK<@WiYTjM;5gt>h-or5CORJf5;JCWwtDS6D?6wA zvhFEI#xLNfuJH=&z@>w|-y6egv*rEQ_u09JcXlgt|5`kL+kg_qov&P4u`KsB7PNUn zuhl(ow0f=2xXs{Q)E!LoMxx+7LoFhB%r>qOPd+?L-y}3Gn^&{r?%w(lfwx4%o4FN7 z9O$eeN-pl5&pW^y7z3|x5?c3=60KBHV8s$)8uW;WXgD|tHEU`Q)>^I3&#haP;{Qwk z^7Y?dQLm%rv%4?tVYR#>+y8^v@?`_o_eRUR=y#eAeYVT;@yCbX#Z>&}$q#i9EMaKKd2K$yZWzE<*quaaO3zY>2?yuZ&N~ltsco4Fy5g8Q`OTH6H^8vW}cHG$s zkGHN6Rx`3edc*(3XEl3e;RE)^K%-_iw^Ulwwdw zJee&N3MX-L03@Eur1L3pQc#I!s9zLXKGP~s_4???>7rf?N>L995{7)LFyxPu8LtU5 zem~K%%i8eko>`7v|3PNl6YT!`uAce;!h(LyhfrRY`jr%2R;yW~LRfq)wL(n!VydfR zeYHZ(M;&3IA*v&~e1%wbKC2zxi`0untzON4+Ya^WU7Q~)IrkDPw}Az-mDh84Pkv`L zzmomBx}?vWP%qZ;ZXW9h^;+16E%rdY_+$J7zK1^!^~do z^d1^*G5|wlvKFnqYz$C0vnSd1Uw-4wj1<=Tcb2f^P5#x7e5qUco7d)t_(5Ll4lA~U zwf^e{Yqb@o4#ptp8DR`c(2^%f+JI20!(@o>t!E4kc^drM$$zfUxtc~gzu6??*)DnA z3$I>|YgcRGWs*Pi$r1y&^mCJGa&wx*U~AQAJ;X+zmSd96IDMa3 zj%6E*No+oRI9unZUu1=ayL&k0(p!kxhIesJv=k?R&J2B)iQf*u=jmUWIO-J+Bk=?4 zNA*I{eey7_1-zG~kF)Vb%RbqT@%mn|>=S$i*Z1KAmDN_e5%QqrpeT>!U>q$6y`PYr z#BxyLH@tJiau9U%Hexv_@tev?u^g1`rsbf-|5DnBIh%X+4+@9G;nY zBbD%=)eDGH8jNF>TPIvwBJep&2pw0mXj+wXkb98bs$@HEd!E%&JWDK#;+dqV-jl{b=T#I9RZ639bVTuDSX#!$ z#YaYBSs5Si>$Te~zR83=L&K^(8B(cu*Rd9@*_m&rR@dBrYg)^+nSR1%+s+A_KZ9ha zXQ)9?F{Zo)otQ6q^Xr6jetwX4$g@O4e_HyWy^7N0o-D-7{TQ4HyB`53Jqpfx1Saj6 zM-?@+R#;etn(z)Iqx2UZ={5D8lr8-CRjXO4UF&mav@&*U6~E`>?(*Mxx8JAVVJ$yh z$I4~DUds7Om)H8iIoxZ<{YrS{fS!ghvK%g$Hs4&F4Po*sN#bC&Pi`tAcV4;2xeD`tb)LPb*9IHA@O zr`bm0>1?A7r`bm08R};eH+qwj`0$bH7HIap^fLqK(=d~ClGz*^eZ_~TF)S!eCESF* ze?n0j2P#{DZ(Bj?WN1uWjP7te$N%76f6vNeZH}&EF+VRV#gCLup1n0?EC2azQq0Xv znO)uZM0@I5ncyM(r!oOL_wL<=b}`Q5YoomRzy3XGj;V=pLKCYCoy#O+#Xr*GHFO~MC~FMy#{Xqc`KzibdnWP*p9s0)$d zfvQ#ro*96*^Oes~cggaIF#D-fWVeCk`hb3GU-<#b9i<%6=YoA7KGW3&LroR&jH+ms zCgOlFv=lJ1{()@P;$45lDl;P zpg23nePWV0u;$vA}8!hlRE)xzq!3Rxzq`>EWVc~z8 z_*#K4x@6Ib2znk))hhLbz~|ScfHcv6!o(qT@bjj?!B5aDl8#zU;LAfnhgAUnlyJ^g zFSsKbA%D9O$`hpD7QcV}LyHT;D9} zGv#7*43G;we?dGq<%8#T`Ox#G;<+g&Jh#hTMa>H}G+~_$R zc%S^T5g4`&a0H^UYLkksMv4I|6kCnt$K{e33vjHCv^Lm;rNLZUDt?@8yVbt+O7CSg zNlk4cc3RDUNJ2NP%~k6sRzh1qxqXp6Fdw9gF1K3~D>qv5YfXfa6S#}>n@SawKVjU~ zKJerEJVzdByBF&^HBwkzQC?5sj|*P_y|2aE@MM0TaPLas9mVV*%ct_sMfq2;hm;yz zAKLf{3>-PU1HJExH&zp;!hXv)()cNX<2Nlq%qJ30=eGo zG0Vu(8A`aIQjnia$q)Yx<-fJ@KictxD1QjG))X6@lFm5C8M&fH`On10q{PQNz7Z?b z1I9JAj*lCdb;kx_C60M-YM5YjUA-*L9?6k@r~bg`ftgc35mw|Ag_b>fs!`6qG6#x5@iA72n&eA!mfOx1&klyy=8eVVB7#l$cpG_0pkTY zmMj7f7$@k-ox*;6q6Ul);GdefZCucrys&=s{zJc$U35TFRIiHWVto1Rkakwsp->CDaC8HNe|dp>UAE#UK;7+wGVyx=i3gDG1kH{4R!=GRC;=IN(EF`aB&E zEh<74CG`|<6`|;|R&v87p6va>hEtG8;;G)#Hk^Vb5+CS2Y{SW~k$9T-Ya33>2#KeA zWpHYYvBR<2#^(sNq>WEnUdZwptfY;OpPx+0kN3KenxFsC&T6*u6tt3b#yQW6Att3s zK_H2b2TptcV~lImaXviDi2)ABIVLXSB=crlbjq4Ixtw~?qGcxTY9V5cpw?I--#1!He)X*Z^E((2(L4>I~tvivLvOpv4eKhfQU_oD8TxQnX~DaT}a zs#eztFZjEhH(g<1tEddp)Wm)_{?kIUS$s*Y3!CCOsy)MJ8L2nn2v*U)@}%T1(H8LE z4e84FqIXeeF~Q47HCAD$gM5}S!K1`86_4Oi;!styd@}QGLk7^v(!Z8;cy&9D0Q{fe zn2|^AhDs`+-iyVUZmi&3KW4Q24q@^L?a{uP&R+JM(%NVXJ*m3EG|vjHmuF!98^j=w zbhSVq$2v4(+^soZQ=FzSCb=t6A5k2^5F277v-tqWXy@oWK^n!6->%g;& zZeBT0;~4&S^CR_dsh-pPJo~vhd?WE z1$){2gsg{Gv%d#{OG}$=9JAy441EgTQx(J`onN6j8pZe$Bbd2L(x!X1eujpva2EU2 zt9+Ud-H{C>zsOF=@{6!qI_PfUZsRZ;t{%~`g)HUehJ0xemK2R`JX&+aF#HLP&SoRk zStZn2Y$QJ)Zmhj!tPSVqQPF35ox6(j^Sq~=pXJ3%nYz6~rZG~cY70vlfy2%+iDzij zOj%!}Dt&mC`w>&77lX0zuyD8Py6iOgx;YyV{~p17(gG%f!>!>$bk2K9%LuSh@{| z-(=zgl-t5@%0>*JFU@=cr))RSfwfchx1arq&Mg>4x|neNFrs2ZPIsK`HuP zJ>qsypD7d={RW}Ghsh8p(uTE_7=e9VCU&%~%!vwNu5WU94}J#;5j}Ep*edK?2eDN- z{M0J!Y41REd=-n_v7JS&awuY_+Mg0(@W=U{Jl0{y4!$ujk8j+ugHfW1s2poKs=Sg& zleiB9G8OgSB-;mN%rmrL;_U;m>iol6CR#rsQOSHu^hg8I}Q|(LEW7e^HdElvp!FiH@(4a6g7z zGw<@d{IA7Ro_{c&+>;SXJ$>R+Pn8E?%O^V;EmC$V%FjakrA+;JGFxQB$}JjmuZYusq=V)h?3>gA4os*)=eM(;*yyOJ)WN2wo0wY z-~$nH)F@(JI0!E)!Z8RCj$g>5^(^qmljXdu5+|gE^+LXbCEc`luRYdm(wj)U z%V~!4yrlHy?+C2utiW@JE3mx%cM8PN@93=otT{$WV* zGG~5AHU1Y*{`FV3ls)7;;i(~#Y|rQAe71{UTe5_O?aE`vSzY!FJ8{>atHFYuS9kHv z*Z<^O-jklPPCE`!VzE@i5f*{{g&0&NlVg!58=9@2zs~9|UB*AT#)H}UMIUzTxPJ*B z<5srvuRq++zuw050lS9{+m(_V1i4_jq>mTcIzqH7S-BvrjKsJ8;&;Y!23MWcwVxDeZy-!$YkeDKE_$aNE)eb&qqY~UcOKdLKkkt z1{IMlXQqAHE0URWlpah;!Du&IBywc67p|f4qPDL5^E717somMM#rq%xs#RpmSrr=9 zM6Rsyos@?q4dL#g)%blHx*FL`7>gsA4A88m@HVuknb3F3ABYZ*V$oV0yN}LmU$JUu z`?7ZY<0~!H?GL8=>SF%e7~YUQK1hpsfFS_UQP-UkI)X03n!V8Oq18JyH(I=SMm_IvX=j_|3m)Mau(cvZRW5|U2=o8rJiKXT;l1cgx-ub_2!b) z8>1&x4!x21NHs=CQZOZ~wZt7~Ro)F`vw zYXS9U>9A_lqe$mIC1!6bPs@8@?esL|EBPMMx^4VodH*}gY06JLQa>HE4^fFnkiHE~ zk7E^U|C6qhFrA4;%yRV*f1#FfQT_f;Dc3)Zv+6@M0uL;PT=zq+tN6n~VShWy1i$+7 z>$e;Kvxvb0MBqaB3PYfb@+d8$L`T9Caz$%?y$_9!M(y-hw)(|wR%hXmuU>0oWH5H= zt-WoUY+J@t_;F`LBb~*oON{RPd-YBBh+A3DFYjD^WAziISg|gv26fw*ik;2bo=j~v z>cy&4$95I|Ra&fOU|mG6dLe_H8qRj|f$WW)K3%ff+-bk9%~~j9`PcrXxlEboy`)dZ z5~?PoZ6Dze7#Co6g zKD9mVUA`sfXTJSFFiQv;_vy$DeSH{#@^k)MtW zVhOC~ILkc^pL^uRVO^5nNNT!Z z@CW>_`Ab=`tuv2KZBwFFM3d3IJIrX=bPCOqa4|!nN>7sgvy_BIn$r|k7qw*)T`go` z#l$mH+d{M7WU*)7U=QEu&+8mw;R|;0BPaWO*}Gf4C0*Y?_FT&F)aZ}d?1no>Wi4yq zeDQbIU|om#osInuFZL=M5A&RP6)N(QPwZdYcXG>$MSfOUwU*<*9F?~V>|id_w_rfQ zbD+H+`ZGqaZcN}OtUVu{BAsKPFDQooc~j`uaF#FhMZ7Ldr^GXrpG5zUfvL5ZAE}CM zJXxLSPgy=gt!$RR7EnG*KP2hH1ls7p1fG|;am(_W3QE*NuZj6uXd=Uk`n$Srb!v735AZQ#6h$ zbGo!#+IiiimyC&0O0#ijgIDjrcGz6MyoeO{=v&N_Y4SjWNr8={V+&cln0#HHNO0xJ z?KLTC*(19p`x|L5kT1;c_)+Se?MVnb;&V^BWTPRJ6vQKn&K|Glavf*T`WcgIq&4yFJOyUEu zbweACk`AqYBtDRtp<C6lvi}I&^_)rz=5g&aDCCKu9VcaN` zkZtUu9`WHL)KSPC;B3r0;g?Wo@{xUU#sogIdR;FIKIR zPEl7|KW>xrX#GQ5d&y6c{Eu_>lYWYEK7jw(u0OD{qjp^mz~{NRI z%LmdSJ(0LiPmJ?47JRti2SM)J-b?O=7;zd7fXJ-f!beBLJMZeza$MZz^N(TdWiQ3p zb1Xf0P)jasOkgxOfq_Z`ZP;5n=S2Py!n}2()Vi#NQI5Z5l*69L7=1FG4uQx-ak0Sv zM2KJ@DsQyq^SZ~Neokm8lSPoY;HJP!JcC)!r2_w z#Q2mSNZie#3{BU@f5XKp?I=-z=qT4 zmE{MhPm9qj>5%In@eJ**EN|pd6MT42dFXqKVcyYd1oo$T7PTvn&v`6tMTBPT92g1>4m!Ko2pf5zbGSoy-uf&N@i4RaF z3qD)$P*<|xN8s3egccDUV}mQhhAR%lH!S+VbwP(I4Z$~@Y1(p=dtx-1G-N&0s7N)6 z^)O2E6X3I(wkE%V=Ym!NEqwuR`~VjLlbCp;zA860@!kupi~2+fwxRjq?*=w+Gro@T zyL?+cI_hOtm7C?_x_DM$3%D-$5cP`DbH*G!G$<$q$;@*s7(PWRn@@fbPZoJQi+8Zw6g+@3E7%#R6TFF_H+NIUg={xX zf3n@_te#D-e)(DbkOcl8HM#m;&{@8oxUT|Sr_2hp3tpNCWI=#b1Mc6j_rwT>o`6EH z0TUQPGz2o^X|pW?e{FWe=O1?N$v^I>e@cIh1)s|OR&1(^!a{HVB;tCqa#^q`aZ(G3 zXQ*XtIO(&*GZmT0Bg^~oR13$dTaIU0-a16R2Ck+JhpYLqWS$??;ZGroMjkRa$Neu* z4iVUiXKT=c&(y4M|KhizlU~T@jymKAqAj8k8o7aKkD3-fB3Zv+umH* z;i;~VSA5mj&|&(L=^Z}Pt25W9x$i$ZW#xjN4;SH{kVrUw6TFw4WA)-VEuBCa!Lhi? z2Fo9;e&kIrbfT56g@_1^j*ls(;szL;<0+=eb(1=M{?!@Do4!h$xw*xH*A{Jw=D*Z9 z>@@c0RP2AAC#zP?y)e>Bj=1pncVAhtF zm4<%3!Q=RwBFzeq-Bi|k=^_`&FYQMVuDkwvf{vF;ZvE07?fex_9VXsFsKj$L zFaAfh_@{~Czo|;VLj7(#lqcWMFg&AurFoLk>=>d zeRzPz1o<@09i~rLoIX01Om#iwXP;gMktZvuk4Hid?KjCDAWvN4k zV-RlaaK35dW`qV`z*qj7MaMl`?WlZh^<+?z=*cEZoB!OCxY>%Xw!+0Q<^@plIty_D z)PL*EoaEI#daO?Fv$A{lm3>;Iq_k++x9@-LQ0IkCi+kV-4gA+*ai^7RV52632RCUn z$Pz$sm`?yR*=)qVOo0U@>9K0K7(hOO8K>;s>b}ax+XP8I0CkA24;)1s2uHnForm@E z(nclUWcLPrz}fC0v>5+WXeX`m*YG7(y+ugMUSw$HMW@3a&)l9jA+(3sivi`|*5 zLeVll8#%Z_hq>+F*2@q2(lU}&YX=xf>yC;66F}5I&KFS{W(!yN4Zf>N$=0mQ zr<49VJS4x7wy@`xVPp5Eq<=iIeO5|oR=WIFo}XXbK1(!NcztHyjb9HEQ_8}vG^HHm zvl16Ac=^#5QPCU!DEGgE+7p}iwBN(O|1$N*5$~stK00*Ul5dAp@7=8CtNZ%>Szr>$ z5tB}z8bw1Qt0uT6zBaiHO*$nKNA>YkYd^D<6j#u4aud}uLPgTlmn<;?$4EBuWYsrU zkk}wbDz7S2y_a*nGm zx95U!QL$j~QNeh!@~L24;+X7IiKi=uI6jQ@Mc_0!qP&=FW)%9~ zMe99kI8BZwy-UhBVsaGq)8uI4*~(clIZD1TsYx97Iqb2~_v5L4+~uOl(WH~Zs))%^ z@@ti+$q{t?{CZCazNkZ~{krSM8#Fai?VV|AG^yQGz7bQSeq+(JTuOAkZiY$#u#l*(}r%6TPJ}xn->@&I9p_j37GeTQnYbz!d zlbhMPh9oDo55&sc&b`@CmB>&axyNeZa7{ z2UK^uZ{WCMo92AEjY+R1vz3q^^g;F)X}ORa_Fx8!{-te9$lZr$>C&a5Td8cgxqZo$ z$=+<|r!b_@D!&orh-qQWSWAda3TUm zj1EIu`!0o5>74B;`IYuNpMU?Faiz_{;0AZH|CIr6ww|LeLNKQ~7$|T@RK)$cImUk& z_VK8=7SUmqOLWgPrrn<_N66Q+j`Y=oFKc?oVR_zvW3Ie+yYm>jqJ)Cfw}0#m7FQqZ zPfT=l>ltdd%w@0Gj(@2wwUL< zo3(?nu2ov?*s*?Yxnr#VpTDra$9%nYdm2BpyxE?;%X@ZMI$+utnRyxQ8$8o));ikP zoQx}5pexWVL_!r8u1;AZbWZ4ym=h(QtZcC1G-pUWRe95flkQ49gUPv4((#wiw8~Sx zKKgWJ3+hGv0rd#y0Q87Ysnc*4h`#A8ShmeCSaKE+PWpf0EU3A6|Iu3zNdqdD0c73O zQLt{BG8LKIHNjDs^0@Mp2rt;SdTK|&tMmQ5{WQri{3|jwrRdU!#OMe__sK1!`~Yckq-2@RWMf&8z}jo4 zJ=GlVNbhh~L4lsPe8nkX5`p@=x#T4VNHAaVIgB%Ifq`i_pV14fltZuw3Q7Bh;>>p~ zB(LIhOdJl%U_hIh^|A343oux?f;!Hy%KaNROlsb+@q~JO%v#o_-_6kn|Lp(q%*|hP z;qm+UrF9!v%-*?MdzTI+SGPo&WVpIJyLWwe;@Sq+&T0i@AOIJ{Xc>el+ z_H5UlC)cN~={)7jVI2e7^N*_S`rO^e5o@aUT_?hiK1YQHw@`Vk=?^i13x`+n;aU1L zSsoJ;I?BQ^F})>mY+BoJToiWR#E%8wGxXQ3dW$qdfYhv4JL&u!Lr!Qe;W%ATUa5hN zn0Qj{cpPPvOdLwdQtA4h!0|or^VGyP|OGcbQ)b)+xLDmne5i`=)lOdHv(dX2=1 zA5Y>3XYBT{?z=m@nf&~kxt*EDjqe;4n07k@I8Grm5;iezo&$4-s@-+>7w z*`r|tHx3@Nt9Sp8vp#0|M(g|!yS_ht>W+>bc1)YOyUW?CLzAo~Bn_#eUgF9LBQA6e z&s)Er|2411ko~io4r`daAtm*lzQ3r;xVrTA>{)*=UzRf?>jw2PuOV~CL?6eQ(}2sl zQ90|aj*vV}ST`~A#|B!~e~AcaJ$~XJ;8e%6XD8k|oRR;uwvyKP`}(GRJgMC*vBuxV zbIToJeXd_+{Xe&@+ds*)t=lm?MAhyVsJaH!%;t9dYe3?k&V@V>TRf~_knz5ZxH$P(IhFU5r0I3s6kbX)3=h_ee^|bVA0?kHp&x0NSzEAb3=R^rA1QYjxkg8i%%^U?R$ zn;B3qt-U3k(ax(j`n0Z<_&9jaw5}yz&`y6io9{0lbki#D?y1jIqO5v@8~E_9&h93k z#{%fgpi`BC{_OyKo_@f@QLl)b5I^J#qF%_)Cl7>0pRw)%i#Dd>B&A%Ium&*CjhcK@JR6j|(RD^);4gmJkFiIgMbl4x7d z(4F6yGZOpBg zJHnS;zseWGy0F;OzNp|^=Y~}8)tsV&3S*dXwOj676-SnOFKI-vE(VKecpp@JZ7~Ku<>UF%TDM(hMa{}Q73219QORtSRJ*u;h zhLV$$r`$uSF(@6dC?(-W1y+z!WA%6ABq((m_h{4N4XdPg)3Q;j7|+QM38aR*W<--H zV?muPIxRyvD|S8wzXG3P;xccz0ldF=+&wYB3tY`pf5dY!-RUZ9zKAa*;y8O;#j=Pk zB*cf_208%2b3d!N#gtl}R2>8Cbd z+F03hdH2-HeVV+zi&d%qa>sXA%fJ6--S<(nGxyR_K1v(>%18~>ScUE|+38MOj2kw;5ajCv-ezVX8Qm`EREN*U3T;-&iT zn1z;X5)Ld{62U6I&yOCpd9Ti$@6)Y?R;1)k?f=v;3@JDpuBU~0oJqZf8Tl7)n8XpO zr=clu49%@#aG^YK8e9@jSH893qzw|!V0Zj@P;Co$<*JJEULo`|1L%|am2{HnE+t=i zGB^@X6MLvkIfk>~_*e?Ub!KX1O|ggw!;W#N_*jU}Zt*h=dkdG|UH<1JerD@isq1@a z^E^nBG4wI>CvxvWPgQNp#(kZ)pqgW_<{*b^;7r2`4nGJL^}@26m6ZDDYG6eQtIAle zEh@Y^b@~@yAVY6HVnNh`P~|bYk5Sz6WxuzQ(b7Eh-e#db#}-zp(WsSOp+Mo4qq(~s(<#7wF7Gk9ch{Fc%-nV3tkZGLqm@XHGdopy+_HziIs?ZS^1S`HyOQL#`a3o z8<1EOY?KR+|7pvzs}mvd#r;=x)0NarE2J0QrnwR8$2!F^Jo{7KKlXqrQqn5pu$+K(L zytU@(ACOx%@5m7zW31uXnB6|*iu0^)8wfGtfVvq@p#{C;9jt%93s1QC?utJru}a&P z3|!Yk&CurERn$BT1g3tZed$S}fdF#-kafHfR=EQ5OX8_bl!PIc>oClukUsKal=Umu zwpzE9qZ(~liqS>Y6p#O5=ZZfkPV5p@*%^u83I$t?n?!t+Ht)_st?sL@O=Rq#S|hzB z-I;;vT$fNC0)B;6Cq+l`Ai2|NtDNT00<}NW_nF8lVUH$N@3!KVh9)B-GITE|xtUh< zM?{0BSzHnN|8?NQhnh%Mg;E)Xt%2vux6lN751uJ;Wwj_zCYEdyP8)b{iwXA_yePlT zBJXJwnN06=QJ!{-WcmL5DOnzxVlO{ae?wf9at!x&*l+|OY>G=F};fajYJKqrF`y9E{1@o<#x;pUK-v@j^4PRFi<75b?`~?Ta8zaa6#kHy z#UKGK*oG%_F}r!;L232GY%9yBaxvR_rvk4lrXz``@tuO7jlfHZSy+X;em1MUCyWH4By#hnswltglO!THUa0kb9)U?jTEii zh3?Yzj2on01$oYtJoA%a~oMp5F$~QEpm5NuI|!J`f8i$VA;NHWDR1-myn4 zNY>!CHe4Lx>x(SM93jKCgxmT;;80*(0wS~vE;eq@5Z7~*j|;6)gIr0pc<^^CLLLbz z7V%JO4_(P!y5yU5!#Q`Qn9C zgqT`HU}gL>ZIQh~ZL+vr-yB33RM0e3W)1=Tj=qH>vc@>*I7`=NNKI6asJLql)uWYa zDGWYw4aq(2w0{dOkPZK!^tavB(lg`u?uP~H5qB!O{yB_@nyFa#`#m~ zr)m&OQ)e1wJXh#a9rgT|o^}{+pyd1&-bzuWEPNH_f@H+KEkSAlHzmR`mxpeKS5&FE zT92h0o7I^fI7v;%tms&^opeyt#rt@SUdHI7PB1b&^PF?O_c``0(}ic+leorkI4-0% zl8y~Pniz(!?W5wE3r}$e6jh)eHL5Yr&p5+A=H8FB>+4WOS9sAqi-U zhCL<>nXkKpU)O#B=p$%VEGUQ35`m6S(UybHlMwZ=TXNs1TvGL}t7sdEpP6lU3uaqv z(&+=ommnqwNwhUeWQMA_-oR+78BOdvzBIx{A52d5O?o8N zulHiwOZ?g%Ry?77gP5Kh1`XWO_xVYid%080Gae@(O}bGJ(nN>RDE-{?73m3ZT}-J$ z`g$_oEA*@ zeyY(tF7Dylk9L3N&;e%YY5{6s@pVwEBv`*p0~l(1PTc0&0{BoHzTevdgH2WHdT*M# zhNVSffUZ&g1^K;XnW1aZp^^56{j=T~TUjMmqj~IOg4@Ff>^g`K1^86(9_E!3U{Y$s zl>K18|!* zd<$*B;DjX*^q>v7;=QxbhBlKX~Ep(k3qnG4$}vA$J8F{0$x zMy-D!RCf%^+G^()FZr@YNbQ_;U$P*6X48oItNGcbueO`p0rT6qx%0-3UcmGC-TXZ5 z_ocJmo#lB_GhSWXX?cIoOVS#N8pXG{?pfVdd>|%VN5#^%TQzglpR4^VSN2uy?EA8a zg{1eY`#KChgpx>GuJWEU@k0nj?%6^p)@smwooAC&-uy~PXFV-3k8kjq2Pj(nx)RRNSO2ZgsxE)OVPp@ z*%C(Ed)b(c)i|X>&|-LYp*+dDoz%h*nwk3IQhKQ&<_U3{i3+Kk;xWUIPpH(kLU4H1 z(p^UsR=YmqwWBGH;JXgJZJHyz5P?wTe3rYE7_#+b^?4IRAM)i)-Ae zR%DNNh75V9SCi2zJ891s%Cz^8vDfJV(+|_$1Km%E%Ncw}>i1K!w^tlNxscW%N2x+8CiS6k8kAIbBw5M~bt0!ws#CHeRv#$WQ~5w z9mg^HNhW^Ky;(T)g1&kL=M`u$(54eoNFm;G_KjPavw2T8VykcYft@?qbKic;p4+*T zA2>H_Pp3|MX3hSfQ>PEg9c9VauCtV*NBNTL*Z9(-`LEx8eZifD3-2tzKX9Z$RE0=| z@8Upz7S%sg_YcPuwSQw{Nx=Zezw@O>SewDtPjtQH6qZASM=dM|gbVvR*}ERXdPz7n zF%TDS3G}B*6y63lb-m;1^~T(P5nQLCdaa>(&U?i7#_a_Q?k-$-cfo?&Z-~mX^<6Mx zZshjCaM4MBx)m_tejGSY{&L2vq@*W zZ`#8-;_0W_M}*fZ*(Vj}GwvrRUD>}sSijd+HiD#%aM5l}LLRko>4NMl!#g<@nK<2Wj+f@bf73r!-7QQzf#4hlNg89oj<7e*W#3t>43}!?L z3$o;sbTcC$kr^qel(h~;41+)aBz>_R$Kq{Uyn%V^xRDJPHWgEyln9Qi$O~kzQ0n@q zh9Y%6^2vbI^+5}h8x%)wc;D@68b8yq^*utQRa(D(R;1FOzFwzY*9sy<+>P5*ve=L8 z$-L|WvR#1zcNmdtskw3~?+c1zZ&%zNN9p0>zBn|1SsxoNFYo4b&BI!fQthh|`yns! zPro?8kNi4z?62&Z17EQ^FGLzUPc!Z1aojk%i|5W;fHQu|olM*Ml6}5~ zHkOrKx@QSXxQmZS2&hJ$ zADHb66srviR!lKJc-&we!>ln4KZ^O`Yer0Y+WVU}6yXkvEBa$B2;u`5l`Eo;6e(2Ef zYT@NO94b(Yv7yF*HA6P8P93P~-v+te9q*}>Dcy}j>M5c6l)o>J47jWW0reFBuIb?V z#jAuzcYLDL+ml{24(W?uY9Hg&ehVs6t>4;VX!=ozq_!l`3j_*eQT@rkdvVg+9V>N; zE?zmJenwMA-(hR}RVxzoo92vZZwAmrS88;jD`p@1E>IyVL4odq{Y9am!g@)Bw7tSe zQZMOx@=lr;5zw~voo)uSU%?JQb>UUXvYBg8)_a)x*+&5fHl2u)g2tODU6l4bW&i8r zoLj9uPn0L~EA&FoP-K8|6#}6)eL=tz;U3T#h0p5XLxN^`3WNd&r)le`_DKkX+TKDK zbS<8WP?V$jJp)Ik9g1KkB=pVnXOm-lmTpwJ{~Bl+Mo!sMc@KqlTIw4(g2Md=j*n{L z1IJ#?Ka3oI#d6?K#b|JQG{qV?VmC)HR9N@>SU_Z7c7I^4w*I%*McwacE;^6uLifK* zB1iYzIIy~ZlqgG1wJe*tmO9`_00ruPhv>E?0;ld5t+01LYNYO$c!u()D39*9)k$%1 z+5Mvl7u`?gZQV~d-%$74-|}_;6y)f_u@gz*&MHVLqoYSrQQ|HIN6e!Y;ws8*T^zru zC@xx{dnujG_>Iu!W4evoG<4jpZ%(h^|J#et2=A@PqDya>wqX0fbnZEKaxp8qB^RF< znNh7)_j*G*z0|#Io$4pn%se;h;)%*bGDml6*1P0W)z7Y-ej@W6?9>!qO7Iav8C^*= zG1-W%ERoC;8_yn?wJCo!9nv7YdU$lp*u6I$M;@F{Hj_7csY7{H|2fF@!zst%2jd!{ zGm>7Yfhj+mbN&POul&p} zFRIfh`n~FXJG_<5E^DvdzX4ES(fvZL`$)A;CbgC*DX>IS+X)nDk7%rk(t*YnLLm#5 z&NNH+3@AO4jklF1wIDCOFd#bidZfZ*Wkb)IG8Vu0L>D5`lq4=)jdVroE+-8Gnk1dNBbu^yu_cIY zLG!|)WmVZDxm#G##f+W!4hMI(FPOHWG>fjt_i@i2eq{w?r@#Jt+|W(qvgVX>%-D4< z^ThPEXRAL|eSW9WnL{d{xH#(E%rz&f*D2fmrA|Y}w*V6ef$HtyX<-u&H>*lE5Hfc~ z;GPS_Wn*GO#hi}_w*Oz06`s9r;8& z$can3&79f2&hxd`ygcP~=hnP7&aJ0dMAo27oBB-b)wN!ue!X5^H!AB``ZJIHUGkYh zeOeCfJ!nB{+X3zSx1KzK+?ST#-?g2t46N%SFu*9Uowk%j&tQ~chc-4IAKm%@hwE!4 zRj-ina6+k`%|@IUt0T9aW#lDw=uj?LyAvG2)Q)?yK%}K{NZSW;l$Qf9uWv=908x}( ze*HZmKD^Kx)V+*dfB*59aE`g;GyNM}xVOHG@x0fU+0LM!;DK~#sn0H~b~MI#`#(0O zS5seO?iOp`v22ibCn%Dso!*}%Wb>o*AvC7Ea9GW)Hqj^8<%mnDkRZ-U;=CJqeGDyQ zvstCy&7SGhr1KL)7aVwb?G{$-#`q&&)J|VBXz)8D>ZvK74_q0!w;FV5-mG@(nrS=7 z?SB8~w9{|3oHMX~<5$=BaX~}xr?C^acYcfKZ26IYv}K3yeD(!-c0TRn$*pH5HEj9PyxGlP+IN2J*du9X zPbUm&JG|F`H`{hzmOSm7(Z>s%s^5HTVzsV`@pYd{Z`EdYd&YWwJRc3|%japI!SfNi zm5H1z&F8Dqa8HPgbWPBjX7e+qYL)KUbi~QAEGLK0b8hizmexsXmQPV#boFKu$b}ZF zOf9vh_v(*U{lEeBi$b&p2Cb#BSo__T0RxCVMHsT-EKC?i3=K!P`%??Y7ukZ9WKq5+ zX_BBRW?u^bjwS+rqFQ4TcAj$f^YLoI57Cd^* zB&Hsqoa1GC+in88f}9%(s~W0>Bi6q>HBO=wVI-8cK&RoSij;(_uI~EtxW4aEC~Hj& z2Ch;DS$>4p)>re75T; zS>CuEfX~yvH}PWu_zaxA^Yx33{|}uq2rl{DWYKqb2cNV7K^fondaWCLWO)kGOFqZr zJMMJRn;0shT~oynvhIrr#1ZPhL)-{dg!*Oa@%m~T6&i>V7r6*j9ygt0Ao}nueHLnA zN-Jy!|Dj{ZLcNhQNLhofUj*^HoB_ zQC5=5klpRWY4}P!g(VbKmRMwzBR1m_MdNPD4aopFst zI9OY&E_{`3FJH4sgT{@=tRDU}=Y3dXp88nz#ODC482&70%UQgE7_Q@6&-)GQg?N?9 zFK4nlo}1b>jlJ+(2ve$#8_7#(O8#I{ORNJ)iz!qmk~V0$AUZah;kJ*d4bw|EYcsx2 zY(?V~Yd~5(g(Vu(Do_8=I2cj%??}CK3YI?k9UU8TYred`ErcNmB84Cv*$R~kC2BuU zfxJIlfhe!V_?OCx6g){@XzfAOtpowJYqI3U3tUSh{8od+lBAjO3_>{neWT0U|5>g-rYcq z6YPSyV(GIx7$;-~>#L64vL}1TRa9&6gM2jXIsZrzbFVr!rtBQ9YLZG(iHhMKZ(O)n0SAsvq(UeI2beWzU(zM1j;6)3S}#X)szVS$Hmn~ z5RAT%h{1&qn**@jBnyu0iJW*3Bb%M;1dC8?c?X z6humhFErs$AKl=bK%&4(60wMI__LB~&cIjSpW44u*QXw9_DqRE@o%U1nmI6e=%RrM zb@@bD3)9+6Trjas$+*bRYW7K*^!lVE|4k>TY9lURRVKKLM2#IV_jTsIqzT(@RmTUB=Q@AaP`Cc53`0dKa^rm(Wh7Cm1@-dXeYgq?I} zO`o@upT(Uu&v|-iwLG6e2TsWgX&OvAGWlOFqyYzKAiX+nGRCyJfl7rJ`u(#raEy-6 zQ$w$?r`N3EpIkAntD)0(ck8x$x^bP(N9g@mBKc|q&soXTHgERpzqA9C7Qrv)CD3+4 zX~pWsdC60UAD17w0j|P{H4*j0WSTelxyUcdMC+LO{~OL4`5hdOw42iO$ z0w`-dWAQchc6rww7xKfkVFvpdXNJc8%)2`u@ zs60)Q=)3yMx=50lF-Qa<%^S+7F30h|bNGyN-?EWc^RKdD=f2@tywvR#*CtQB`lfeD z&a`PcY#TqzTpzNwKmCt&K6rp{_}>-&&H)xV?A*e|=ZB5Buz2B_q0XP=i55}@*F|s< zrIbc{qC@fNKU7yirA&KTQ41cZUVRg*`8}WTcemSo!uPED%-e5wS$<;%Z^LoA$0&>K zh@~HVz>=A&UN*{Z&v_;D+8*_?sDmlkadDl6f;=I*BtBL}xKqSC!@?p$R2L3TM299M zgvE>ROXK8NMDei5sL&;B*+-YA-(^Y1kF%t^(=UC*mKjCIoEWv7|GInHm33W4ofyM{ zLq@ZOEb2QpfVp0Kjpy=3-|^pgDkieqtW~XZtkRBStl@#Q!`xWBgP%E9i&OT|ix7Mt z`Y3^RS(!uT9ADQIKDhTdo;c`RrrKo~2>O=kS7ax=M}arM7ej%k0>_t3ONcL-)(5_W zz9b4tSWJqKG4#PtjVJ|Yh$dH~4VGRo;Sbns76H2#EfTn02#dwXYw?yKKDP_vE_y0mJ`t!xvt}+>|=8UM^_Spr!`=iyz(1wR)>nQD> zJ}AL+ggj2;`wYLEPa?#Vc{uJ)lKKMgM43;X6HgYI?|hDTdeWyRw99O!crxGHT&ae4 z=*e*N3DzH|=S}ko^@sR|E984kJX!8VHZk>x(b3i;aX~Y*9`xMbGk9+A87cv;tsVk} z<3>m7ABRl!Y2*TuuB6a z9OFa@pHxu7E)^>Ax+q~vM_FQYq+x{?To(8Y^>6&daKmTg&SFSEfte#DtTwqf_#6a{ zD&bhrIE#$~7LDDG(BdivJjT1aI64I1AlqIG=jC=B1qzkSCS@!Z#5LoyhhF&_@I z$uSxprIEnoaXC%g$q>cl{en@NL|Ld-j(3WDu1a`Scg?CDY7O1mn8mWk>MfaBqjR-d zK~EO1k{H3;saO?OudF*5iwJvVqtRS_cizI#aPD#FPge7^F&;wqqEp=o?)Ff(yQ~h0 z2ZQ;V_cVsTV1_<{tKAiNe`SG6Vyl5mS8#$6**Ek-8|*Ebn=sgU(P(WcJ_`tQNPb>i z4c1;=(q(G%2MwCA(xGS<j@58`H2F&aVA+nr9zrl(w|(o(+$6 z9=7z%Y`y5+cT$%Qt5mIC{VJ6+mJPyGi(#X0MyHZwV**Tw2~ZI+M+}G&iDJ$HbIv&j zbX`$bT@`iBz)W+0-De2udf)edzwf!veH57K)2F+-y1Kf$QX6W~xhK?M-8y#k3G<+H zQ&M~1IK@2pu5G*$&EBP^vgfNv@lWYJ>oi7#cC`=9Z;1_@&o%y~%U7wI0dLSPqeG?z zkhI0PmP-OaMH*1y0#BHCxUxkLa3b&>asci#`iO7ERKE_yU|B;lvvL#7;e(^BlcZ+T zn}&3vye6%NoU0_We=C;8&^>n8Dty!LZQZZX%1UrG#aKGKdIrLUo?s< zGGfk>#~7+*Uy}n3F_>Xo!&C?%f;eVSWPlI~OeSoZ^P{T#p8z#6wTm%&-_caX8UaV4) zzJ@?0mrCH-M*MXXDy^i=j~+2Ms!8YQFa;W)N=?`bR*^Yq|1fwNAEBX0Iox2Aogw!! zB=W{s8y(MFXC*`ecSi>VAYlAXat&)wYmI1{!-Y`baa4}9H1;g5XD9B<`)?Dm$6t(1 zsQN`|DUCA(v(55^%p+|1mv=02KM~m=QyEn_jdaZpyV&{Pth6#m&$F&*YWmg7)*Da)<=m9k`*864jvG~$t zS+kq_?>frvqr_+S8@PF}K*L6}Zf524#L7Hw0<*eEEo);ECf!Wjr2@~-X(gYa5ie-) z<;!gG3l{Z)MPI%`gI~ly3JHA_N5OIFW5=e)IkKzz;ul%ehj(n@#q%`e{YMJE#6C{_ zvT5D>$&){>-|%^=RO<5#vk*ZG_h+@vOjY|B`) zOq(*cg(p#oCsOhk$RXGVt;{?Ht0|(g8lL7wBjg`E7KCGaEna=KeEq@bHsJ)jvC{)M26!Nn>DTEK=}_R?vq^8+k8UIa;KY zX7~1g@h;c`Nybw2m?RzJt>m@D$2n4rvn0Al-(MZId0Ja_4<0Yg7$;SHLJe`AKJvr} z!G+FF+0*;xDdQa-D32M9XLGkUbmyHD5O9~|=6>cxi!ShADF zchT?vFdBL8G>iWHnQh&p1oCCT9M0X^MIJ}5T%nq42OgYyifZkA$$VJ)O_t8OKHo-e z5MbaOWU&FKs0x~s;3k8#9W)m;)2d!<2qeJtb{l1qCLWWHFnjgjj&+ZeFd~b&8~*|84cxUMoq6k`c)AH8AL`A#=_o%VX*M(OLP+?4@&|hW6I6Rh*#nqz(I?Vl@E}Yp zcS{vp_KvUzmBS%~pY6G%C9c*cU|2r)4_kQQ8jbm!M(xiOVJFQdo*uIzV`{{c#p5qe zwxGI&FR&EDNx5U`H?)>Ytiy5OaCQnie>-yjfbHz~m2=10?X~^(PQ1phB+JvNF#CYr z^h(Y{vzAB*k7MfqD~IwLBwSNHtPprz92uF!iAOE7;!s(Mvr&0#6p3>PT)j|Y){?zv z&DpUH3rW9qI~9$Y2Mxo*KhLwBZz9)HwX*{gdc}|h9IV_owu$V}eoEWB6#a^QSVYBW zbUL-aMLuWFvQ>YvjaS%8_Hpri2tVD>>EZ9525uE7XKgA(=J9M#bc&T+O58gk%@852smyNjc{CD4it0IP6PH;NIV?HM_bq%asaIb%;DqSR#AN&bj}}jLx;n0<}|cx^w|x6|t*1XspFD-~@*Z zdN@(S1Xu%_9KKrA+k--Mj{0RTX0HyJbN|`xUqWQhp|m#K5p&4WohlE z<>Q~4l?L2(*rOQsoOOEth~la25tdbD$-6n*>DL1>3rIdQqHI+=l5Of#101WdRNI06 zA?kH>6y1uG8PBwBQs{FxI@j@6UUd-1PuUy>XHq zJucbxp4z1S%HV+;{a;A6ijnz4;~Y!#{z=zDwz1bMW*E>Rms3+P@M0J^&aV8vZy4bC z5_8kWG(WkcgL6ACC*vW0FE6#lHNCn)w;!;-{d#8}z;-a6Z>Tvh&8C@FEVLM2?zwl) z!{gA!lt0y}oMMAT!G%RRc`OwUO{X?{x~H;?RAt(s{x2?su#dO|R^E|$SswGHvikz| z{Op{+Qo7+1A)kbxt(U4RZeObEZ&Zc)%K|-4$$6-KV>S+-%JQo?y%35|xx9f}X5A`C z8rWJim_}gg+n{W1Wka%u+?_SK#?G!!B>OEx){H4%-0dVgm)7wBG;YqDHX^`e>h7MM zcFv!^qig4F`WV(Vnbv$>gR8{v+@zNKn+>TINhQfV@IV`8#2RSGK4!8Nhq5N#ofZCS z>5M0{rlijVa6G<=pI&qjmMRGmKn4)?CvH1#e=&u~)7JY0<=EYe0Imag$}IqLOTJggr=HPn8Z$zp&s} z|GFn~G<%BG^IIG+a&w=aDZ>XW4Qy<=I%jL$-mXh-I!G7D|HX6acXq(eu`I%1kd>CZ zQ-;iZ7rXq^++pkYwv-ivfsGrx7wR_^79MP=xPam75p4NUy_<`}Xtp)C9Kn_)MewA6 zy+C;a?G-_i-&9MHLIQlV3juo*#Mj&5z}^vjCaHc<`ra zr$G5qs-0hMhTu$fT;{Rlqgl%98@m=57t~$!ijO;gd_Q=eH_jhC&l|6Jo(q3|`+4L3 z!TWjR|H1otc;L_T#+%=M9zK5X{A;|x0<7W%Im1tj%~qWcuFVj1fI#kewOqiHi^u%_ zzQQMWA2;Dy;4=@;-@g|}XA1Q9H1ECHxp0X7a?A7k6VI_@@l~t`e=H^LjL9`(aBp9I zo^KxCU7c)rW-iW|^K>p^U4i9ckCtwoyiPXLlG&48L#}r&=9@Bd?1o<5Rt?%bbk39H zu2b2^vl0Hoqg(Eu+iQ6bmX<<4EAgAynj5lSVY zF$C+N$^;#Kz{cM7PW2xhT6Ihi)uRfl*~J@o+4zg!WLSlQ} zh28g^%CXE6HQIYQwrp2%7#q+2E`6QtIC6+uUManjbUVwM)jn)kUUK^V$&>F-FFE8= zjNR1x02iCIHWqxW6u%zK%gxrrxB!i(qd4@6D}A-XQ`zMHZn{%T&uu=^rGJk1UEKLl zV%*vi^ovEnroU$FV|O=rPJT?Q{z*w2@3x9c?AbeqCJRq0Wo@o;a~|<@)G3TWr`>%Ve_JmP#d7 z)tGQ+ja;s2(2OS2&kXY#w{=)>(74{s0_sg_usWbi&#5uX%c-MLBQqR929%NC%Y%IoYX#4{>r6g+uBC? z#hloXKGA&*`!nQ3Ow1yp*x54=_GkC|>>>pqQhTZ%dZYA^t5p32d&BO80kq#DOwdX> zNrM66=x3YuQkmleUfmQx%9=VbFoOxG_U$UK-I1_0r@{bs`8fR6|dBx zm1Fs$RVw$Z5jy*Kdt>~@$CEubu(bWtpKkImM8A|+8<%ttSU!Gy*~$%z=58*|t^v!I z+x7sE>d)0g7O2m+E46;KW~3>%!PoVpG=CRpC%=A-W?zB&oZYMK#F8CM&yptGn4xV3 z_U9-9Ro!e=`-UkJjawdZb(@O|C3}62LI%g}IukuERW`Yrg`)nk*t|D%308i)TaMW7txCx`D7n5!ANJRWUY^%4ooH!T~K*wd8#YTP28p*-`AHiKs<2hv4r zf}?aaag?`0R#MA3(nUFB30r~OZ_5+lO2$pY-YQlxpUe3t366A4?sCtOO$^k~-y%tAhDV=oq=AlsmeA zdzwGSe|wrgQvNg-^4ym5$NO*Z=8yW{-pwC7p5~1)x8-~c{M*xfJn)t|5pz7`zWWt6 zgrvit>NqyU-2B@+adbjRIqJ-DX-38DZ0_5U_f~FsZcpMVPAairs7Le$e6khG21jsb z;WEJwz_|x=vJ$!?Q2<_Rt#vjyDq%@1?c_;zDNHVJi{Z?2p7A}4O*P7?p;>{HES{V5dzE*Uj z7!ns_i*}x%yqTa+wSG*F1+Slz#oH0o zrIs&1dLG{XnQzLM=Cl-W7UkuFqSX4aIRp91%2|tYk;L1C9g)NvsnIqW{}qm(pVa{ks;6VkH- z->MPJ>f%z3hm~&ABa*LCq3-={^{e)TuKZA_WAu5}AJ zn7Ni|o;6qMzN|i*ZZ~tbyvwk(pP%JVnT3n**q*h^v$ag`&CKm}U5u}?xteK%5#h+_ z)?Uc9dZDvXB`K)LxRW!NbSX~+6WfD`cH_c~i%WTpyQM~3*>(4f;Ka_C4xYRE2RpiP z9sPPX@FdH=bSmM`;2m4z&TN#LhIQE9six1M>Ff^6pd|eRL(MuPI!v5)aK!cK=s(6e z7%uGVzGHgyiAv?G%^%S+ewo+QZo{4HG_rSTLM1ccS^gVVTzF1~?hz=S`(D9T&OMZ| zZ&*2+%qkux@^{gtWZsdi*)LYO zvjf6oTeOOg2#;+>I<(l;K;G*^AF!*tOW$I9_RY!Ju%bocyzbNPj4!>4G%<^xPsrMi z)gn3>$(rPuqSHg&Ot!uR(_9weHe>J)4ibt`J z`;xXy&4{ZpGIesrs=fh@C;j6)qmzG)`n7-ZI83Du*2o~g6PS;+P{j??UI9;Z5!y-- zi3mv;9p|2koWD8@ao7lRw>FV#+^N{KN6Rs({&DQu?KE~ViJJZ%A30$~^X9P=C$4I# zOfo!`?l@ecmPgFJ+LWrc_5Fg<`=GBoLq4AzraziF^U-vN@LQ3Qwh%?Y2#?wd?%I}FZ|N^`soL17V#A+mHyPJ_+X0=1zn;CQU2~ffg`ZaW>B@6p z759i}*P|(T#=$I=dzz~t3EAEnLxZI6ph1Y4OY3n>$87H}Rcl$5DpvWuYMW%1vpq+{ zj_Wjr7E@O?S(+U)8m=(9;7*zB@vH=PKRpsBjfNlq(S=~fGdR=LjVi=hu_zTxS1t8_ z$62mg$`wMqlq-Y|sX`71QxK6Ths60<5RqD+vuL$Efg)Am2sF&dS1t7GB{A+?H-5aH#>w-K0!9l?mzR#s>h7HFL_VzqpB!Bzw#R?FwWdvq-(eHGFO z-NsUa3eXFo;U+zX8kNrN=itI%fU3Q~!(CW4Y^~%~=Xal^x_kPiLCcww!{YDsX}4tX z!QTgX$YEst^lP;Ihs!cSnu78*?3hi149=?Ir5+;68P{WMgxQZMy#_TYIs%@s35G zkMXiavDi=1(eIWQExP<&bo8g#VpvyajY0ZnSPW&k$39;U+?xy*lWH>jXt<^wDB|V` zepEP1&PimM_?SvgmP%HwTaL;*)T>si&rKG`a##ind>~~}d9GtI{(76`j7TDRez!wM z_I0eEboBc0M0V%-v~4sRQq1MgLFjo~&J$2K_m8iEIdR0jWH2J}Lo6JT&@D7fl1)u( zXGg^DTdQBi6IJ(8vw}oX%=&+lEO=d8kgc52Oj8c1}N!Z&PSkyioz^8IV5&V5Q_C9d8J&sY=s zf)tRj08z+JvFg&4JBy{Mmh&Yj%FSoUVS*^XEl-et5=-ja^0|^@f%<%@sqI8d)?#UX zTR%#gQJ|gt`Y}*q@ue*4izTSGvp_nR-+qDerP8weav>$D^<$;oVrg!Jd*xQaa(Fp( zfwC&)3(+&_3fZ~Q-aKMxQ5dER*Q+t0(t51!}YMYPXZ z*4N|z3O_vHz%*Y1KOFx*crQ+_C|H|1pPX5#_?|66=bQIlZh1bu;yIYs5L^J)7fXbj zl|*cs2lrSM#tK#2;3m|4t6;cv^7eCe#%*tEX|$xS~7G4dGp? zcgwYAsaA1Vel^Y>b{_ee)dbhn299sK<;kLyTO+M(T2Y}j`>5QSE$sO1+wABT3ZXHf zhoYGEgk64qyC?RF4yY~NGpl#7fqN0Hb}BO-kD9lh?S1uzB_BLa5ma)iR7@(amu1^8 zrzgIMTbVY}D}2}J*X$|yCR~@b*wf*o{}VS5!fU`mfualSQ8;xv!>NNS;~+uix?r4@ zCXhm|8|Mw$7!dwuEvri>hlhs`$KOGnLcJDFWGi)=KMqb`RjP2ml#yZk1F{0U4<2{V zwcBsq`g2JEIfdfTpO$YLV1QOioUsGD%cY8J3FTm)sIgri4%p1qI-GN(-gcHwg?U|l zxG_SCV3Fz@I4O)mCNZ=LT7ZpRIA`9SAd#EA3$4j5U=GRSjF4zCijdTQ)Z0*R3Pu-^h-? zW3d>_AgIOD(XcHL$5rFJN#TfyAP{_XqCrwe<4)>9_XblHf4WCKjHyz47Rwy_vzz@{ zS=m~8ZH$z{#gDb&wG_ceW{dzG4`Np4$UToS9T0p{)-S_f(hNgVm~z;3f8|?CLA|L; zz4=;GktazHGc#9P@*~PHs-cUUIB9p&37xaDCWcQB2}@MiHF*h9@`kLLOG3NNYP*8H z*|MI!kENJi1BZ`p7EtHqs4l%m4)vLIeCV)a;S&>_EOmWW^`Cob_}~-jRBCTx^n9B5 zUM4m)dM&M{M(m!k7u!$Hl%(`T@Nuu~M4ga4jDP4Jo0%yUl*4?YRKpD>N>cXZtTeUw z1uYg<-B;KPBd(~&J1tbXvzQ@5G&vH z;hpM&9p3}mT24c}IRF_>^B+;yBU4|+_Upf-GlkroYuumPer0x78u%rSJ=?dyxZU|t zsgbc=f;RLqE?4_Apf60#S!Si{rh`w10v``RFDonXR}ii)L_iPMQY@r=w^AFJz%d3| ziaaM>nKM0tU+;2OZ$?_$*8@>z8m^$Bk!bZI8j=e}&g3CO?q)10Rmp`oA0ynUkDuDU z!VGEJVXd7Mn9+=cpBp#F^10$f5C5e0pwbgR|QaOu$HagC|c?Z~cA~(J+ z2tSGoy@i|Ega^i1IUU(OZ5$p3EA#jO@_#t8jej6Yjw2gvDl+ z_2-R?Xlh>5>N&{xO$WRoxQcVgih&qKtWaN2A~)`Ns>>A@;O4`s-H`L}*ACT+m+D%g zLukjm#s|G;W`&u#e*DNm|2727#J&PUkd%3O%ZKwhy@~wL&spU!ti^sJP9F>wYYfDt zovc>shzk7QNUbF+#O^CN*KwYz;L0Q<#r~-GGgE1>G<*%XbqrPE6u}kU-0b0I<-$EX zgtNb$mPfEFkSn=#>jEm{ayG)+N;rH3&#wj8Qq7I5CI~yEroWaKzgP#Ct$XSm$iaM$2bV<}VGv+>q%tDTQ31s4>ef#orriT&d6CG@bhz=5)D^ zgNvPrPm_%mMvlV}KB|Xt?Q(X1b<^eRd$7lBD>=1~9yt9kb)^|S87p0-1fo?O=o7OT zI-?=?t2@gqbcb~^mfdaBDv@%sOv_H~NhA{z-n#$RpPcYmy8nHbijUzuqO{g?*#D(t zbqheAknbE^aZZwK)SoYdF>YBg@}DfhcUcQ_KfLk-2Ul}HIJh3p98|CcaGqwghd}BD zS`xyEb~{?)`VdD4jo3Lc3EZ<)2tl--4#7*ubq32iV8jek*rb${Nvx6lmOcMt{?n;^ zLZ$}{p9>xiUAgXom`SdILmPw!g~ju$20yUgQl#1_(YMWl#ejPmzi*uLDju9%T&VZ{ z6;j^g51>^ZQI&5YHd=FP0NeJtN|{jd|3}lh6VIO;tA5w?WJn>0fDkM0jap!vQ#Uz2gzr#4h3|Zx zWz9;-g;ERLP%s;zwGOD2xP~0{;PG^VED;$Hoz2BNkJ=xPfDR;)+rC(OAWY_O*X783!NuMa6N$WaxTt@?pv$a;# zBBb?@BMUoSXGvFTTw#gVq5+HdoX$kc{~l9g-18-D(9@^b-umUFn*~4s<;rDyt_$YG z&9}6Ce>qj-^4&e>mkbx*Blf4kEp}wg< zBF*CbAv6694S4mM2An?4R=j@2R-7Ttq7MrfeTP2%N={U zM&1#ZKAIAOVy$5(PM|9R?6qOH=*tk1L!F$?Xq6xPpg?7I=B{jZHtq#!SH}QS?#B`zHGm|81enyd| z5GAtP=cAc%UxmNQj*ag*epi2aeEuPajWdT#j1>%GF|`jTg0JLN0B@zWqxpYQw-a^3 z&(0q_Vp%(ixD%P>q;Bfcbdq62xzYCUzWL>zmmj&J_rzVjjiGsay`=dKhlMaPSLM+Q zH^FI5o(m=wEs{unof1#>H?~zyu!p7BE{fdW-|xWWuCsdDQn{bju+N+Bv16OIQ2pC$ zsmLa|&(o)8>;jiheVdT{Vft?)+Stvx7k-`sUOuJ%=fWOMr5! z0~3jLo~BzZ!A^w|b%*9OixUk0uITo%)2#L5pbN9-T^Vtdt^VsS+qY}V>)87KzYRMQ zHtY6ye{b)A?bPJdgKISC;l;J2U$UD0we|dMw(c}a(x>kumknU=gSHHt9`j-0%x6)T zR~%1Wa5;f31now!7J9I2eEKGAhEp>Cr*t#8=(VL-TCY3mo~ttB?7WSm!1miTCtaU} zq3XHoXr2dh0rydi83$;g0Y&D_g1B$`X;L&ZuuSFrW434OHmZMX@`s}%vg;yntM{f+ z;rj>BuoIC!a|V{8QsuU=FUtB^%O4^aUDaC=;Cw5nr!HEEVz z#qmqp|JuH^gL$QLH5->=e(VT#O+UHmwD{)5EOIn*;Wllu=VJxV&(Y|n;AkledmH2@ z$|YTBZtaC0Rq(rlv5D})&@2gBh&)^PYw1cIT{P(IM6+v7u#-Tm)`@M|5~hO3GZpAW`Sj{ zTu4Rs4qw!~T`HCSc zC;yYY_T$tBV>&K=F&#n=HUceTJOx88Bu*VTj_Y}ZD5qsDD39*~G>TrN8#hSH={dDF zZqTs{($nl-N@8{|Gfno^iOS{d9ZH+*>ZD|!SKNO&caDuZcMeVN#-w(I)su635K><( z4Sv)o8)Tw&t`B*-0*_jNr0vX4*)Ue=+_PqALs$=kvwV-0a?rIMn4|HiQye#LZz6}3 z=!~Rhw(L&}@A(}%FY8SfQVB$pjWHxlI$y7y>_4}*Jm?ygoaeXh&e&>xk&W6XgN*AV z(N%l19=vyOFsQK$9Hp$_jpgp4;dG`K-Ro`TyDa^mKY!_1wYXh(Tc4iq-XSXVTD@si zJnU;%3>r(?D;Zgr-{oj;zhG{=3_&mu)WO#+u{*${OqlYS60{? z9Yq8S5>3NN0SZB0WF;XVn*1AiUSl(|eUjbj*CnrKPBPS#FBod_IV)sVC_9>+Vm+^$ zo8?53i*k~EW~0fhDSY?w2NW{`vO))Rm77q)q+x@2#W|2EZirvXt1Qg^Qz3Vk1@2uXpWkuk8jm_ zM^6$zf)_zGWC}5{S9bxEUxxN#@2o#sDO37pFKnf&GN#s)rCs8)qN1|myDppP78=N& zFVAKZ?%$`$+3{3dTjSCt7I@`~yvyj?_ScPU9gI5bH&km?+0e(2(QRr*&FKyc05DxZ zs0RgV)%#~TAmoy#SIi40L*6A@(mQ)<4>Ze4Zzd%eYf98_>YhGlZn7!AliL?+v?`K4 zd1fxnBkShb&vB*7=g*n1hvQ^6UF5}FuwGstz}bC=$-tMu!<*ai(^=OL0= zD)y49L`C7I;mPbnA-CRb2TgWv-|%6l$R5A8ns*^0<#Tp4pUpCiHk`RjRXjRXFIUsbqeNim!6znwZq0|T>*1Rcm}z0ANn(F(Pi23uq0kzY zeCiGKuHLS$b#43B0g-J7ZkpIE`0&Vv%?8z}*|pYh&0UH%a2UTKV8-Qm z52C*EjvB;=Hk7JL6j#7bbZ^FpJ=i&n-%Pd`@L)xeX;!eM+>Uzpu2*yripx``smOv^Uy=ugdA5#b(~ccui;f+`kedOULrp{GE>cJjO#FwB z6{GE^%gD6RWn|T7d(^B(Y>)AvrF{9!8P@R38Kc;qkT6rhU0zRgNf?nGqBqNmWZhpm z@In{s?BU_!QGp-{A;*g&BDA2K|^z& zL`x)}EPL(W{Kr_BU1S@GDz|l~GA$btZHQfbSfZwK0=uIxJ~t%#_3Ys{mi#u_zjEtZ zqZ{`dYwY;m=>OJG!|?ZS;{u#&{5I+L!J(mZcfTBg!NWt#jK|Y(QnD#9-`L^A%7#YY zti~5J{TKXrvd7tPtl2p@(p%YRivt1e&zwE_eV3Z#2_87Yeh;-28W%uEJ@$*helgsp-wqf8V_97vXO72{fIkUZSx9oh6 zP5i=Mu%0hwe5Q7%;m4mIUD6yfiiO4y+I_(wkL_&TmrS;H`;5$W#d7{edZDSf3mJ^J z$w!(-Rtx-gevZNT;+R1{O~9px_b?;`C;ZbBITi$u&qq-lBO$KytpIzFowaVG;rSG~ z_(SnqI>*jf`_0?pw~hUskU%B2;5Mp$HdO1Us6W}Gb?g)C^X4tZlX()AdK9e!`AMSy z(!l;p0|DvUFIjBuR(Jm;eze4x+TTcbusgJ%+s2+dm>L$TF+o|$guVX=pAvIhKK(z2 z$}sT11?Y)B1XNv|lZuxr%QOl|H(2|urI8CMh2}Uvz27?aG4U!4CeMBd-|}2 zisn=6(B=-A*8O4z)!f98b((G1=r%CM&wACD4c+p$+ish{(|BCEZd3l!_QJx};)gi+ z_@|wKTy<9rt_(XL^w<})CvLEa3#Iulr5ED$KV@3zKA{;u3r@am0a)L{{piCvtw4mEEk$Rg+=!t?OYtaEhOPdQuwGQUhP`d?eM#< zwaMMgdog=?IQb6T&73;S_MZ^jvSs|F@RiN=rAa4A;Ih~N8_90LwR+=h^oBYfQkdD2ZI3n5E zqRbRb+u85|8P_b$*zE;l+edZ9H82&6l^&JUci|~F%Tg0}4~@Jzk=^VZ-XLnZ?%|hF z`ZH|Y#zibT05JZjPRAdTs0Ubmo_yZgkL%Fx*1O z<+O`8DCFa33cb#*)AWDhmi;q(8WlQYTwvy|lUFHz+4w>JJ0ve?`+^oRu+GTqX>2fi zo<=37Ph&4{oneQ76JQQ+x(TyUG_*e>ZR9dRCZpk*j@vVM=WBOUc-2C=)n=cUE`T2t@2;fZ~GqR zs~%qo_=#c;g(CHJ81 z#t+;0QUCr|@JUsZ?<5-^;+J9Ze*>m|Dfu(;^VvW5(|-h-Z>|q?-HVg;pIzezhgVw`s&Kd!KY_leno>%l|zQ4G?;(d&>ifY zmDmopjk#nZj5~%Ra;R@A(sE6LPh#9I96u8=VaVGDjEOS?49O&4V&@7oJA%-QygKLD z5qK!_t5S}G-I@bB2*F6vX)=M~NDL`XT?A00OE}v&TAh@34PVfrPm2Grqn&t(4j$$y4sbVqq5smsr0rI?DEfhqSLdf znEQ&OgSV68#%=7#?J@g?1f-7cwj|hV@cz5EeZ=9$Ps4$uCiZE2XQdyeb@ z>~*F!SR&7d;VstAuaGkMp;Z7 zRo#iYt@00y>q_JsR~pv_r9|FaRJ^b=TOIp(UexE6tC@4*;*0J@rZ7XavGcM)gOhvp zN*+9DS!a2#_d>ru^V_wX-^XvEH|S&y#^P&MR-G9yJIK&BmTK-m6SY{|zyVd*YQPxs zl1!P5=JK$aWN~{E4LQpm?MP)$w%bwlvU?Y$8_2TeiX%g}LH5{24epHIkEt2cb!l+J z!TZcglive`ygWw|j-Kpc%>0~`DM>Vw`udD-vYA|uAESDES=PwoW5*sH(Ij+v=Ml%o z3Y7l|W?BZHYyc|U(~$Fk2j}GOncuNg*f0 z>~%3s0(;#J_8OVXUX#FH7lS~Gn%FDw;?8|kOq#HU8WwvpXBdXin+xPjb`gzgIr*gH0KufKmP`UmUP{)OeG z_HWvb_&rB}171M?(*N?!RQ>o-a^E3Qt?~ZMmF<;^I=8KOfZbn975i)m8ose_-;KlJ z9HpIblB#Xoz%E|kfEYW-$T7W5G%i4yWI|_MvblRfU5b6-5p;3@BOVMkK z5xl)nqF{IUUDU8_$i=>^SVcH_;SFm${@t{D;5!cL_@0gDNWX>e#cS~IC&3TLiu!gA z{QFz9hcF~Ow-4suPoX}p5ySTqE^2+=ep!6~sVx7#4ap*+%*x<PVMdi=RJ-@K;fJl30=*=ZW8 z{Eh1YJ)sj(ZC)ZHkv*m(PeI6~LVj&QuD!_GoriSuy9}TwDRYV4j`!aeMlCz_8t>m? z=x=Sp8q29{vmvA3`hZE#SFU7uc>XbFyKA6ZFf`K!_a2~ECMItjq8q8m@lIh2sH6kw2r-8yNA76)-1AH!7+4~*B9UO$Mibo`-HaYI=Q*mw7{KTCe8em zI09p+Ida9w;!>Y6-h8~CzMhz=T)gu5XdZVUx;N>^A^sLXt$^0n2_*A1cI#IP^cxw_ zZEiagZr4$9f!m$a{ri@A=t@e-Hu>na9C~t{SLcmM!3%#}xKh#Z)YyxXje zaP+jawsF*1@H48ly`vL1L-7=z&N3VE4_WIB%*_%S75GvFUv90Uag6*$a3D#>0 z6_P^0T!PV%S#Cp;?C4Pvh6WygWo<2+ojSx`n;If(-VmE^oWVZ!gUeSJ3fi6ln_%J; z_R%;45P6}YYiLM4@IXF?F4#+2z~<6O=41XjA|yS+To@*pq?@TTpStz=vz9vb9Dyyim~NqFRqXxo&^Ic7Kj#bhZZImf+*^@Zd%iM zY0;x1-Kvd$D(#IoU6bWg z1z|BZ{|Xj4h`p4%ES?k39tv2RMh&AOZ6^11S3YrASbD_=a+t85VQB4ey>>)w5 ztXX8dZc$!;4!MJSf$Di?H%NAx@{SIQMtEt0?h&*JLDZ$1xH6O9DroQK&hNJcGZCq; zD{u{;#0gTP2nGZGF1&hFSsO6|a4S5=9NC`r8|ioGa;wtMw416dtvL8>eCPO}!i7r& zZ3+%Q7uDKl@v$KRTSpfw@~LB+?t`jyh<(ET*!qUe$(cNjbQEa_y0flwv-%g`T#1Zp zPOW0cjfm?=)OeZk`a0-`({R~TMYP^#>rU8H10Sto*WWLYh+`jpMrhrsSCe1(ZTc&6E0sZF?#Ej< zvq$R{=j~K_d*&S~y_r4VzMV>Jmfxa)Jpn)SfN}k%SsyGa9$Y~3P2&*uQHnO}^J$2A zG}u%R;}FT;B3eAhT9*9T2aD);cZyU5bFyX;Qh5_jg|(KicCtn}u9jf0rD_Im^9X|v zZlV9KAL*QN2smmqyI>yuX$YR5k{&AF^5fio6mP>Q`7w9P1f7_k>2ydt$pt)(^iUg- zUsoHEg>SsB`b^(2|Czqwmm>wA71HECD})pnd3~Tw{tSI|=6xX9L5Py5Jm)GDFV)M9 z3tmElD7^?hGj{Bm(6G~E$DXE;+)rUB1yb@~EcN}XNN8try((_^RWo4&7c3Y!aN)wQ zn>)H-;Gp^Q2Mt^RfP$pWNo*#;Ud(4oD7smK1z^*Mh@3TD3MsyaQa1B z2T`JLC`vlzmlV>75lA~E27!k2ya&Zwt zwyo{FhQV#3fAi5s9O-;u<8Oi8js+h_-CEjabPmf2b-jeZgj5Ue?sTpJD%b5|ra97i z|Hk@(KF3BM$I$DbGH!ywp(qfuPMV$ICp>GvKpd>08^7QZ6c>s6&Et^_mkh|bLjaXt zJ4C`8C^MXRB7PC&Aq6_(oK z2+YG6^avW#J0bJDr>E4mSh41s{4jB2pdVb%%(hC5o2U8H)DGif+bWv)H0}6uvpIT&j15%{CkHD*` zMR4#4jEP^FRagoG%!P!9eF2Q(7@d!(uh2Us7wWI^EW71Fj(*mm-KJCmD{q{*2NgJz zd}%!T1y1o?eAlMo`$X}5GwP4grlEeMfj>Vb?U8&G&PZT4mM-uTWh!t1Ed(!7c;?BS zcxQt84%Vuq*{QiNchs(h90SAF07s~J;ZfjDa!2-&4S_Hi!6Fc~8GT>siZi1zy13L8 zFKxn0t#e-*OE_N;SEp@4r0UNMyF3gr(JcMGG^9Aw@3bT316=b94EIwELkn1x8;l z>nc{P5xexw8>p&xx5sZ?qpU0P4GRlHRjsSE+c+M(=pm~r*0>y^x^cV)l7_|?-PFX2 zf?80->*RIDU)8$uA49N(g*?xfbW+2Iv=cdk-&$9-=6}9x0NWMJA*Q}LwCH`Vxw&Da z))lYAGTe-{CQGu!`}_}UjTS1=a9w5P0YEUBsEBWRm9&(Vqb2z^=~}&qL=|pU^*31 z@$bqFz-+u?k^KpToUVDK8>J0|RoxTYDD+cY1ty%CT-@x0(#l1+G{6b^$Il+o@cqLF zt{&W?Yt4?mC8IpzU{JrcLz{J~*{;82{NFs2CvCpnzjL>GH9|&hygSIdS3TFEdqt?9%Hux8ak-qIn1UPy5j_lgIqaGRuD>kJJk;GkMH6%M6kcM0a$P0Y)?* zt9$QPB@1{7UkB3fy2{#Am9D%@)E$-mXL?u@0W1y+wFC4R7nHtFEms z{_D4MiJQ8bs9U5=Cp$SzS6|_-k9pLsOyhSea_Xi|y5`tE)YbTJ)J>g~@PjIOALQgs z6+qr;c;Ww)yp_p3TfQL)>Q+J1N(GPZwfM(QqXnW`=^E*=Yir@Bsd)#MnNA0EqjXJB zvWyQNB@d*(b)&VM&qK)$@lev7lN4K8hc?`?35$4=77-(Z`x>GoZ)dSyUEV>S}hF8 zQm_qMIh4ZKw4v1B=B^F(AIhdt*ig38MvkDAzYZNedgw1oVZQ1ocwS(Wq9a;!T|?k; zlSYG6j-r`M_jMPA-Q_3frl32(;7q4Aq*Q=Tg`c`0727yLiJli(Ti}e2v@znYKh=Ry zT1X>2hW_3_? zb`_Z?%z3@iiW#ie1ol|!>$|i^V2lkZR?$!AB(Tq`qy}kh68nQk#!)#6PrFAGaqR+< z7O)2_;$AX2+zq2~ciE%lB=+d;czlF^rY3k^nd*C$m3Tf8~* z^QJWO@c7*r%Ub+*1gg~pX&rR!v7FU>1J)v6D!JL%z-=3@Ou0W0QEin(b|UKLjVN}K zby!5t7G-M|vAI%5t%LN?_(`%b^1td&ys?ku7aoq49HcF&b2rli9aUgPKwc4OU)=d+ z6JiY`@Th`I1tfyCvvtCX*6APym=eS~b3ZB-4&hKWXIjkMM(tX*cK7lQ941MNBPK=7 zt=pn;6Czi4@1U?{WVz@(I~jH3W)wSdVUf0ENRWHYU#dA*D^$d4cqhLgqLD!!)u>`6 z=gQ_qtw(klI7(-NHV<-*z=ggaaG~lHbAuR#f}SISBf&=K)`1vXxiMXb6H`M&9f5{F zspgSrs?D(x&Ps<%CLFLk}ds2(0A)(_Zcr}?g9b4;0#ZT#G zRt)mO&w>iM_?xE#{Pt!FDH1ND6K^8z-^7WxBAX2J@*38pG5@Xj;d|b*Cyj4uMQvqmYzG$GFf~WiN z_aU{sJJj~*&_U5C4YQ9Z$J=|jw{PcByA7m=~k4VH}_|+}?HI`R&H)Qp&(lq39h_r}ij_xv;SNLE)LF|K!IpZ6tFY z^#{8g8%x#ykeWdAsn=wGSwl|y_K?#$E;_*%j-Ng>NG_Gz2;R$6dlJ4#Z=m#SdY`Jt z#Ktx7jsbSAb&XdKio7s-+6%)^HGGJA85l!5++rr3 z3qH84jzi57cE6VILh^=q(y#DcbuA?L@Ul9NZgxM_bf~@TU~nSKTG=h`daC!VPQ6w& zsZ!s*gqKUh#9p0ddFyL+n9;G}r~#FKX;SQ``jr~T_3b#LgLZYJfLdkilquus*m>Rx zW>^(+HMX1AKo7^dWy;iZ@EFi&Icej^US8GBc)a6`_MIA5YF*BuUHJxGI!y0?{}r25 zuH41isa2(ho!ZX;89jv8N?pwV&l+e{fC77A+!N_^#id2ornCZFKQDaMO#Udix~Lj1 zEE;pG?N9Tsh52pk>Fj%KDH4Nq-$bj~`MWpR<)r20^l?&Bn>KM1!V}sSY8RuO;@fct zn^B?MCl^l`=FHjV_HgeU_>$kz;Bb=MwlX&A_}H=$*TNT(CSg>S;4YY?MQjd4Xnw*F z7l}6oZFBmjyo4}(AIwh(X2JY~kR8-9d-xUkrTp}|hH!J@ zS<`Kk;*fzAW2G<3_8I1}{brg^XU#l6pEdWyp=Pylt$YYqSm3tTHDHptVl(M=7di<8n2Hs< z8bjgQGU$uEuroTzJWDZO^>R(AIaM~gUtNJX!ZWP@9;6FvrrQU@UR9$tJEm0S<6%ZL z6Pa^BZ=q{~PxjpDy$cL>kUPIK@X02JLOoLSa4*T%Lv*Ew7>=xF>1knMY4r2zL@Ik{ z{P;WUQR1MTv)avkG&5k&>^4NxpEf~F|E$8}P$@Zy%G@0X`TB8EGF}`<4((>8`p?k2VJ$3VCi;ZmCDJA-`F+^>ysqtFQd3>5KvG zgPK<~vnb~1WY*HVrANugG;iefJ0O}NXDBVrJ3*R+ z&oSR9gi~!UOT_ATvIRHbYh49VH{;#^qU}B4qDq!{;pubEoS8ukh=6)ch=984nlYjn zP*G73LCHxJL_kzTLB)g#bJjJk0nAy<7}lI~x@KJi=E%&Pp6}mhW{_Rr?!E85@29{_ zhwAF;?&|95s%o`Om!v+42jXM*C;1HTW~1Rp5AGh*EM;i7fz1Sk(ntDQ*T2<>HyhXI zj%wMr?iNn5V6WeT(3$(a=VN#A1OYyQ0I+W_aJ^>|D9m;$Or+qNEx4bYe1HDokCuO0 z#`NtH-==kfhsW@eFH7d~xs%>4UHWd)#mug;UJIfIEbAwMvS~W zd+d$T`fc36RxusAB(%vG-qf#ksmImc@VVpOtyuPcV&e0SotJcuZ{wcSY5KS&)mk`} zo45*HYb+?ZLmC#@!5Xch1#3AXZT?!d;-*R)=Ioo)BC+8)eU>e= z7WeZOZq0RD7=^gOLIoM+`ZCh_F}n(piWy3XBCH`y!;X${O^-aU{LprAX#a^N%XS~` zJ@imKsOFpGQMTl#)Vos$k4_zey>Jvacz%4%1|I$5PuJ_&Fz&(ntXCsi_pQ5E_i4;V zKhAIc+*x~l=LOB&Xc9FI#`0j_{ym_o=kJ#qT2l2TR-z{7AZldw`VgE&;VN3k|mxP5b z^2f|h<PL-D*CbyfAM;J>YNHlLC7Ssdm!?eH3pSSgX4BfY zqrt|ct9;tr{W2fxHBVkhX@ZU1oFf*JgH;jh$H;2TJguo<^GQ?AbC}mpUQ_ZNcnwyJ zk=bx*T)p~zn~lmfahttVF+1CDUU1k#Be%h{;(n-0GJkNeY4ezywrZ=Cx0=e+`qXx- z)WxP@rJ%8FT(<;luxhNcHOqlp7u0*&o%LlRU^-~&3)qwxV%}{^&$-G=lR#sFCn#JOyBP~S!c0+c6gU0Dl@f#+5Bl`mE=~5QgPLvM;lyb;QtAk!xm1(8E!E;~ z=@qz5^8gI&A^dZ|VA@-zs$qHq=x~YK}m+~9JWBNeK7b`&b; zKk}t@DTCRA@?etub|V@v?r(w($_6)*Ht}2uYQa~eR&d`?%;$bnpIR-&sZU_YqWBj= zy81rwwquPe@T@3~r}+IBOPZwb%;op{SMo3V{Ojn23rD9en$N$0p$WowlJ0ajy+*In zZ5Qv{x^?^ZtsD1%$RNz(h3fP209ZLFn>a#Qvr&U5?c4te6d#;@eofaH#YTXbjnO<) zyO>8|3L@<F0*bA2@gUxaa(bpxAcwC9 z@5;BD6AwrQA3j8;%_#K174@qZ>H!dUwuX|5H|_*Rqbc7>*HgKV%Cv|W{Ddhpj){dXs&9vK8=Bhi?z`Y|FN z$OL1?`(>N5GlEG4RG_7Tsu{2qF0S8!dA?)cuRHbC>QkAd-ZST`f88Gs?w4|Zj(cMJ zc7r>09_GezQ~3{VXFOkfp49&R=7P?i_m21kMQuw=Jvu0-Ra9Hoel1%@cl2hvVGm4? z55|ep%QS&Ea_&+vze+lcEUo6u1w8?W2?xR<7~je`^|mll2=X8Evoec;m_6n}Er>D% z9P6J_E)#cx=e(G(JUw=D^5qE=FAqs@9@}|XJ6@Qne(VO~F8sD7C}eH_ZrvXo@eYaFF(lW94q8F@2lUy`-(B=&(o;$%yOg~&EOAnn{X3$ zMxoHIM9O43UGU()AO%)M+LdU;6J$rKHf6f+)keY)XlzD@mGA54ElsQw%pG2Qo%Zxmuu> ztz7Hc&lD0Tr(FM;B36{!y74nbAacr0mI7)soU4OG|965R%_vqd1f^1?YJ#iW>TMGq z-bk^LQ||o4jQhcDMxSAhB@bSA4Bi1?Rue4b|DCi%ru~z&1cV#V+1w0%9+0_jLMDaI z<)JC1IbF#=ms1{@QtDv7 z7;8ZpD^GLG4t~2_=7}jKilz(cd@3M6HKh!~yj#e}BITJWWdvGpESGt1N{OShgaA3^ zg()Q*WyZ_3{xqeGLK#;%<)tYl5-Gm?SU`SdO6dW7KFDQW=fjyLgvgNJm{Ouq%U`aQ zYf9NGjpkMfc7XHNl;Vn%nsN#Z^W>ghM5y}xLM4=WZ%T1Oij|!5!IXkn@)v-8%tL0h9LkVkRCa7aQDFjdk!216AFaZ$rt94V2EL6c`IInS76FN z;r+He-lNMwp%`c%Z=&srvSYtl(*tRX?ezJ=)=09gPv4=weu)p>9X|Z5)wt+WoMk2n z?-JA6qj!MkilfJ8dW@OS^Eb^wKnyc{;6DQ*ga%kG{tr@C&50L=l3bD+7@RsGE-ET0 zCEC|#IQN)KJeD%@VDz}?^qEPQ%J?n_b?wo+O*^m59&K8C^>huM_^_OSz^H7(ge+f9wIYOOayRH?;m)DG+<6^sE*?1a&5O&E z=@AF)AEGiOtiNGjq=Z#v)X{kUsW99-bj-Lgm*`42THz4zx`xtWVvm7a`X`<((|bW! z$6m3%euL^c)#BU^(x>&B_gyg{esefO6m5NkB}x?*F{5Mke-N-t$`~eIH45A~smHK8 zi?_b8`m`=jf+`UNbH zjNKTqE5W8YjOsdOoW?mbN~Yw*))6g7 zg>;B&%JZp$52Gbwl+HaerFHvbYh_x-Z4BGh*w4i?zD1upjF{8|^KKswTFhPu*TU38 znf6|r#;&5kg!>@CQ58-rs>G_WpFwaVMIhb4`Esw3Qq7bSLpSg@Q1ZEQ+h{K_;T4q+bdNdvmP0R`+zL8x4R1Ch{ zlq-Tna>X$00iV?}yJ1>o^N|1Zq3*w7H~fcx;y1v_7l*ovYM4fe7!nyL95nfd=*)k4 zO#3G$!ha~1i?HU=*he)sA~xD`^BqB?*+yEL&rA8M8d=75jhcR_sgsF`IUulE>W`GK zGAzsiz=C(Zki}n7%`L79-lHj*XE!~7MIu?ty8&0o=< z5l173Q*2R|;18(k89|uk>MeXp{b#@kLe-JS`|FUz)njym< zw2ElaG~De$a-vuseqysdZcAg6^B(q`Lzqsq8LBFbRpm2?(f3(Cf+$eeHN1JVu(l6| z4-v~BpqplQy(f)I(cSl)OLwEoefUgF#WKdJ$mbv>kk7$Xg#QFvm~u)CpMzP)%3#*X zDPjCJ%sQ5WF_u$&`5epwRtvL0PKiUAhjL1d{4(1xROL_xLnW8#2*|VLGFX^$iXUp} zWXKp4ImI6-U*%dD6gdSuyXGX^8JxvhJW45?#1@Ji1+ZiC3hRO4gN2Q{t-#XZa)44* z@E5o;Y*{|)!^^!;eFtSXIX?`JC=JwQ#S}7QXut{2^n(9LuL;J3B z`;Lf~$lzlKR}d0QZ9u|tmz8+vRg`!nq5qgdaJ3}5z(zZ#3wA~N!7%T^64^x~Pt(E`2j zQiLcX{<$a0f9T17)zi<>Iparc3klgaVth{Y^T>#Z(D1N`NU|er`-llS1Nt8sH*#m# z@c|KG;SrIcQ9qUyNq|Xa0wnHFNZ6kkEB|>A>pNir2ryy7F=IlZ^nW6Zh7qP@hrbbK zuwsm2{Qv*N`6uELHcdXq9vw4kYhd8kQDctA%ESu`4~rr@g13!I+Z`IbGi}t4kmJ$e z;bD;xVf~6Bp1N7k_SCea17i-e(kBLlM})G{-$wmECaQKrF~cAomZzbh=w^6XqK_=@ zuo!F2jBKCv6E7iYIHCvVfl#j8yqSjb$@;978M20Vl zZ%C+uaL(cdp__V%yjen{&pE#)E$LbD0=YmNVaH4qmkKkmftxJt*<}R^{faBZnsk=r z^Nu*u3*u7Pq6DxXSbuB~FCdbe5grP25200W`N{;3AWZZ&Zirg8>Sx}5d0wQ}K5-kl zNoy-O(nI_x=+&NPbw3mBjYmIwv&|nkAE)RP6Vs_{zkVDseGorJckCJ&*|lS|`7eW| zz9Hx>8vd`a)Hlp9%zv?|-xRY!gm?_9!j0$QdDRGE3&vcyNI#K~JQ6Bq6SyptCYiKP zX_P)I?ico`UF9na*_f{ijFw@%hs_oTE1X8i+%r2Tv<~$0YS^O7AG^kP3hLagNwZFP zYiixeT1{&8aVxq02&vViwjW^4R-Y3_Tl6r)a>0QDx^AN(pb=KU*~Bnm37WIVZI@ z$jvT^@&}GYd&b9m;zxWN-Nhq1+M`P}Ce<);1K(293u_fu-b_@^-w$x5!rWfX<`Lv> zj6Yacb{U z34tSPhqVN4%h)DYFC>n&$1{hGB25J@ABhOcJ+}E?!3FATu1It0u0NR?7@W zz8lpqU~7v`Rkx+#WVwRV9uY6lE&0IMJtTnXd3um|yt?w5bb%QggJguJJ+G=qaqeZ9 zGIJH0rtc=tcuspm{7A?B6R**w{};T5XsNh_4pvtvyn3XO-`}4(|Nd*h!2bT~3RyRA zW_2ApL_xHv6#*{h3o+T3^N&*y#(7!cc+h~AlZ?`C!%GOVkD=qro+ zq%Z3CY~0-(utX5z+rg!wtqKoBNY!)IF^H#^(ns_34I$%ui2Keff34fJ-r89sBZ4dq z2JJO5n@&^5V@P81n3FfrenQFub$gkk;TUD-f@!S}r?Uuu?ka9@r-7x7D0~0D6-_r7 zbavu?gP?Xr^?_PXCf({aB9+Fe!;zAp3sP92OrUt1R@aO}%3$rExJe??A!RQ^Kh<%@ z5!&NM32*N3O_o+}9nx-dwfa>{sg74)p0lULz-9xJXThmQmmqDIQWU|cpQL4SKGEr= zCDK)#{PJfy){JotJL_xaT6>Ova$n;;I6OhkXdV~WS|L#aV#=kM=(D5D&NTYq?E6*e@r_20fP}8I684l#Jx<< zp~Ep0w$eLkmZ1=<(_ibJkkB2hPI@=M!(#yZK`(M7QEFw7BLH-ib(oC3HIZ612yC~x zVW*P=JhOglKCtVW{zEXe-KBT5xeQpo?f%I{?F(>J9oIcRo;AbOF|pR4?U~yJJ^iJt z)LxM)kH?GewJ-_lQ5Qo8LsNL(XK5Sa$yTDhDt)7e4I|B)3wvPXuN#$aX;B-M^i8$) z6m}FpH_Ob!*r>3z;Q0m^7c@XLMkavSvu9I>F#iM?T*Bzccu;3-KA+DsQn0}^aeA=i z$JkPntw{x%tDk1AT*=4MF7xjsCEcDo|JJY}x8}tqBn-xX1UW0Ke1~B0&$N}eem8EA zu_JHKNS<+fB>OY{cFOJ9!;_O{%t%TeJ{y}9qO@2>%Hh5CF_}lv1rEII@U{tu%KPfl zkeQ6>Q5NW@Bc;hv_N%8_A-ApUmp(=oyUeE`39mo!tXp7($o?8vX>*Y1#JQoS_GSfgZUaWyQ$ zzb-GTMx|ogs8IdI-`jxCr3ovg(GHhkrr{jHXz*jkj>UO6hZxB(@+uOA@`a4$PN|es zlBB?^@*`+2!;x|QFW{7L`f`^jES!pfLm&;`)5Mti>@3p=VR-)7#SPATAXb-V?&8-v z?%)O!flJt_XIm}Gm)!8T@)k;z|GL~F+*p1mmw@tvcbLmp`aAsc4E~D;6-@98l;`vD zG55Zho@HK+@yr+bd zLGksjg;P;r2-kyrQTN3J!hbg_dpq{Xhc(wjspRxqgx-5~8#qp@6eV3&w$Mkvg`FEv zzJaMcsObXt3Ty7cUZJ;s3#U{XIPxW1@RPJ!-kTK$+y;m@Xaw%eED(8h1FeqZy>%P+ z3cj3D)M_QcFI$%>m+yh{eNE*tomKFr;#I5n>NfJ;oK|PxM9XYlmZTIJCYWcRF-|4f zW@?=MjEq2u2*2xx@Y8om>(wWu^}G1#`XRfxzTaDMeM|Dw`RO~P^<3W_`XMaUPs%a^ zDqH(M0xH?zC!kLUivc9CnJ>>ArOXatD%+8bt%jH9zY6o;?&r9Cw=%9%(@L;#*MHlC z7~LNF{IO>F{Mx^+3b_p+>j+hn={2fCZIqg59{W$T|I$XOllo6hiNf;ie{O@2?HGm& zFxJDcg;{~5m=jjQVJT}5KU1ckSmP%-^6rCzI}KmdmrRqw3XBLNVWm9A`R&mU)(_di zVSeS^=lbq44U1TVRgq??FK8Oeq7W-?YKGV;+nPoOBWE5hX%?S>@l@UT+3t^D08qiP zSx{mgv7l|n-q`3=b~MKIICeYo+O2@fO6eZUOg zvX5Sp;bY@Em=J$3QM@jV+QHqE9Cz^3Il7lL0Ql@^Dgl|qg9%ix|GJC2!`;KAWV2Bb z0H(f{5x-wCOkJKBCZ-O4fEr+wYoXK4!uFJAOaaqoDI0#KFsMk`_%p>+3)`Y14K0VF z0_GX-TB9JbrD4mv6>s&9!Cv33-0;1!q^ zj>m~s`1Pc&1fPD2mK1(Dr7NT0I9_p4wOLHW=>Z3qex`_R+}V@DlT)X3l}`yzE}jzp zeNtClcyLnZfOF9tLz)GY!jK5q+kA5@Dri5e72naKVh$vZP3kn3GlY;i%96^G>NGiL zhC!jJp^@u=c$ymDW7G=_zznz{z6vrkO__-7RhDI0D0Hm!&$dvqm%WCUo73;*yYL>Y zQB|6Dh;lkBh^9BnO{3rt_F&x{rKPgu_YLA){T_w{HeOclCnAh28V>JRnUK*&8xi_` zNKDrs!V>t27AtC{L3j`Kf*&!;g{&mQPk)GS&$kEN+M@B>atGumq|khUQ#cN8_HC8j zl-;zuOrmTPB3e6%Elt2A{Y^`W-`C@S>#wVT-Oe76dGVJUc2APrIv#jsr3glp3%6ax!a>&nv zxxZE}g`iuQCHW;~)21+Hri|kzVk014CLDIz6Bx{$(q0arY0Gu~ZOxkBlxgg@cAm0N z(<)V(YBTXWKM`3(QKjk6ECs?_<)djW`=G$`lX)%tr-n$HO8w)<>0(+_&iYg5_y4@f z@hZ)a?|&52B=7g9H0wV!Nfgs*PKlBlrUzwfqeMyVX>GQ$v!KxCDBCLADiDJrzkn&i zq?@Lcy0VllZR2_>o8fOZ*I7S6+9rQ*YfKkEuvD(6HixBhosFqnXMC5_F+B@{Ma!bO zW&V=BzP<*3-;9A4*Mj=m`q~)$f>n&N0ekuox7)zTQ6u6%_iwqqo)X@~q&f7c;9_o{ zG{14(o-qjh-US+4s!9W@i@2b)!eX zA-)*OS)5nr;;dK|bA~zl|B84QE{c}yRRSod{;Cj!J*>0YlL;PnJiGd3-gxZ#m&tks zzOzT^l#fy`jvRS0b<~BCBQK0<7wbma#(v+m}3 zeH2U6FXBRz;c{Y6FJbptk%-1lU?hf5;VJgsET6p!KAkSu&}~lF63y z=gDh183&)AJ+;O;n+=1xzlHkwKlSVB|3OcLp8pf_9Kt0&vPe+ZfwLFmjl_v73zuEN z&QAUfqgoX~cztLRoe)8tu-_tcNnWYbr+9gf9EG{zHfr+Z(FBwqHPYKFMW^R`bZ+o# z=dMR`x+BEw$!P=z9S*Y!RP@Yr=IYL)VtSAY9Yc)i>gq zG4aoGI3bxzIuo3FhvQV5!N^n&(+{?i^U#XIkIXkOlN)EGi_bqCo-}j`mHA zpx}@aB~3RYfOwvbxjQTZB?BroCBYq7X@Y!&S|4 zbv>;AE_lDRpJIq&INOX(e1OT;1y};~xCKHn$O67m!!MTtQ!wh5pj#zKO}shg2)v0M78dX(7TW6f^X?fafBp3T;#W+tj4j&A-DDS27+m~kV@$+x z{9%1N?P$IhKbf1Uzoj1m-zRi856<7B1Ol0BvfZ4?SaL1Yv|>SR`AK{&X|O(->&qV| zyeLsAk2kuFGtHpJ*rKR#LYsIbm7um2i_e6J&&B8R5Qlfj_2u;S)#1ah5*z3ZZLYGg#4=WzyV!oJ1E+sLh6k5x`_EYPlV)&NCN z0tgfk$Hn~}jWC%Ijal$2D>+itPm%OBg5ylK+wSO~eDrl92a_IsNGsWE%sjFC+hg zRXGzlW$n5J3%bM(qD-V?+I2ja%}_Rm5`V0T|2|t4|6{f?NUz?^|B!#-T@@p)S|dyu zG&Y26Qcryvx_VyTIsi$-9aM|ozX@uDE~!Tr)BfC5p^EMaI%P}BYVM$64n-OB$DN!U zMgdwCnf>#V*(5lwDwSu@pW+*lvTUHGve^E>A#}i;eNwRadj;VXgi-*)-)o~@dDOd? z-#%t8QKDtUKC)6pbBW(u(;uo*q%0fRG}fNEHLSrqP|+B%Hn+y)s{*a@kw0ua3EV}j4Vl19QZP|g*@974cam~Z$|qzF!&T=b+}72EUAmE` zf3M1DgCfq4E)zD9zA)%gv>=#cg>#`PI3(426#@b2r3npoP5g?sn03(tVCO+s91`E(>v)?OVled0Rl8G zme?Z;##a`M&nzg);#op061+sIFnz9JUrjKV5fGR3sC0x@r7MmdBVpt*ha(GVgWr!y z7@lWdL8~J7C}!wa@;hmqnMu#%b*~b53ivg(z^4mYSupqboqW@${)g)MO=wM}i47pa zP0a)`7_eT`HztVaQj9*w;&q9(@(GBY1pabfcKqAHTsoGyZChrE%k1|TYfDX)8a1kX z{$AP9QJ<5aTt9Q0*0GYMMdcbbDu4b~$*}-wofRB^7KHpsVl&a56>b7ZT4YWa9-5;f zb9@ABY(?gT2rY}rnId#9CTBW7rI?&md`2-je~2}U&LJA17_3PtOo<|~Mlk2u#pF0} z%z~i^x{+V_$;ITf@y{i^;JSAT|{V%}!vd=ptyKEHC_0OiqR3){nAcu{8LXzzjEvYNt}MSXbt+6jP^) zAR7thv)p`spsXrH6;r1HuEC4Opb6fdC_1OB;809Xuo7Esk^Nc9Uo9r*w_-j+kxxc)fVqf@~<0cr#v~Z4@`xnmf;g86;70xkp|H3&_O!KX9j+y%x&RNCJET$bZ z_b*fj+Tyrzn1q&;8{(IFdbvs}p+= zsFVWxEYlX?pZOhJcJC+KSld4qBz-e-_8Dn|*TRlIqfN=#3424XpQKHp4f`fto<~37 zQL&fHi7;SY$kbEdTI;3d>S4?T&M3#Jd0~`cIxh4rz^2Mtcp_bvSH%H}mmEyyhy}G$ zyTGK*l&K0g{P zqYXgHadvGRiGwBK89lOL195vsHtTx}TD)7|>&)(+nd!?(dAzn}wTR=0RbCx#$MLDK zO$Ta^;RaO!!9987+lUNBM`b;pJ?qEbd~3v>T2_`qgwJZmho3PLP&K2Js=Ica8dn>( zQ8PD(Y^HBlts>T&gE!CUXGa=VTl9eDE~7taWw{VrxiQJ}rH~%0{7!@#T?$W>2%&^hPxZkTeG~#UVhvB745h4HRJ)Z_H-V)}}y++D46(2!>)dZ^4~0)N@RY$u%jX5D)@pwt^uyEYXjG0QTEitDAJc4s zl_(7JRcea|bk5WL^x}i$9OC;>YNNca`-4m0x|_aSG5YnYNzbOMW3SS~d&ayXtqzWSKcWo{iLV|jLsUKi_KxQiv7dQ zN|bF@@f54^(Q4D%_P8%$mm)Goh}I=GcV11u<=m#b_hplgSFe#uOH0$M*85h#g+A_B zTK@&1b_fxomaf)M?3GD=Iq~Qiskv^UFIyf5f!AWO%L$5Iv#% zXo|)iFz4W;VQ1sSZe;Kh>3@ez%eRO3JM?fUSI;^mmUsG{em{Df?%BVObi8t%R9aF7 zi0xmIbU1F%@i7AygxJyfeD0R3Pb@XJcxB*F8ut%KVkEJs)iq@f^#`LH(Qx1KmscFZ zujR_iCXNK!VLIU-!4JP&s>+q72RU1*s`4_6kE^Y(ysVR7(EvnufLUj;1glU!6^SV# zBC-RH56p@+ScOX54>trsoUNJC*c!LqGDTD_re|$L6;E)}0-qumYuq}Bh`)fLalx%C zX;UXz*jRV8u3kTZ<1J!_+x*MYu2S27-MeSWb4m}P&cbuicx%&x^d6yB^1`seR@E)b zS87QjDv^!+@oKb-K3WKJ>QE&lkVkk0;qGZ?zhCva(l4>Q<6OpdxHPvtOU^JAsK3#6LDig6`2gD_?7R4jx=jYe$hG8=OTYde=lM$a8fGV>B?v>}V0 zzH#a7scl>L(ZPGpkr0x;iC#L@CCt}7qHbdJUYP&=yTjmBzIV=_+Vt}E7dmn~O;IaG zzh1NE)5Ie?)2A($Jn^>Fx4kqk!@F&}z}Dr?IiwvLb0E9dlIZmt#2t?wm8KI&YH8y< zD#1LQotSc&%_9*Gcu0SWoh_TKOHv-Q0G<@M6~)^&igavy(@pbB(GQoM)*zuUUH>SE zB7%WIxWeX?5pKx=GF(~DKs0I>z$c0`fO}pv#`?p)#&qS(5?^_qK9+iyrFZb0=ss&^ zCdB9J%ZUm-&r6tjR}IfJBDjHhp9PNQw%N=^)RHEIjk1wJfT7x8Cdzw)nL{9@6sHmO z3xntZ{#Afhtv7P{*X`Az6LQBNWst`gSXp=Z{&Ys1~=vh)`S zcdm8&zKE)wwT%4sghb_y>ppSM*Bte;nK1CgfPj%`T7dOKh13hp%l=kP{_=tC2Tsn+ z`J@K-m^9g(l95FkuNpsLH)*<_{<(GLGmjqc5%c5yGOnZYfVSht4I4HV{Ck#uoZK%e zs^4T%8WPhALs``kcnYzRv}1-F2x4JnjUQ5m6YR|rNHx5oQpWTwZ5bw_)F>-#`10k8 zpm|Fp5iUHBM!tIou|tJ7n|^ysFRi5SHf@pxlyVz4k+LhvbEz(OO=Tg~NXa%QP%wnPMifgRvTRqgobbZ04kV!ZvnN4X#_Qe(3fU8LirQAKbdw zYr)d(k~Xnkqu1{JXuagcONP(LBBdA6y!BgY?#h*=5AY0wF^k8tc(#$ zmumWEH*{;*&Z9zurL*xt+T5*qWTOcgO$YVeLqF_3Dpfj`y-)6vm!Vc0EOxz1~ z1s8mB8fJF>bb;{KT38@Z2Wco5v?GqCALL)nrCgfiYcN?&LFGAZ@hc1aILhA=2h{ zx>;X`n{iq#Cr`XG;?ra=?`gVUiw2H9nxO)` zk;Mz~bTipbdP$+u2-2Q(m^CqFA_Kmg@Y80{&s-jNOWtCCo$1wm%BSQj>631xvh9f< zD~wfL1iAB32Xr=zn6)FI8x2NyKY9_blr@qlEloybR#-?@P2`m&a@fZa zV-V)WTWNN=F^@_2N5q4ic=?{X(xXpE-380(xo4SQ7qk(YP0*cKDzu8%-4V{OBK1Gd zp!=l*bnI-J4mYj~q|u*b4XHdI&mx}IPNS4saDyI8bLeO;`lYlC9OfVdh!AkZTCxQz zXC+IxCZV@UW7$s_vV$w%2&ml?^3eJuYVj{rri=~^Gh|tSE%D)olCvxy<_w}_4hnGDz6C7u4Tp*t&IIk4uz;66Jhk(;YljeH?JgT}9UtJ=+Nr*(KmJnx*u zo!heKYv}pgw@%Hck1n5@_i$;wWleTY7`ClHFigdS#+^8J+{Fw-6(uDU?2*}2YE>{@ z&GWP51ps}W>LM67FMAVX0rYf7P#`(nC>m}|WSMwQZi|fOjXA;cf$e9V* zAvaD+x7k4&Phy=kAEjZWn}3+bPF5*7l~+5ispUQCq1JVmZJM}sf}v9ngutn1m`|^7sC}L zBL$0qYtNe~fu+SP67b6CT;ff)a5;aH4q0&*XM{^L2st-)i=Wq~DReH~tegs4s4(u8 zDv6Y*FEggmPbcO*UG@v&$Msnp7QNIruN1XW4%7Yyzf#33xS*`ld=s1m8^|=6Rpbwn z1w@=6<@^ceRPd1p*l#%Cz8>yW2q_i~~%;R?BY-uZ>N^`hSK0ywvJeh@P_*3e_ z?S1Q@nw90+F8_9pvi(T)LTokJ6*X2@&0oeUy=+`837 z+#f-=?mi;S_8{$c-x0~~RGqoXa&dqDN zc$#R#o=ey55j>b5fo{WO;eo!k?j|mERLoSR#1DA_st}p2IyQ6m(!6}+U_v4GhawO) z-sArTQI)Osyc-k;wdf8KOZsqYe<#ZQk#x(h94TPLdS8L+{xG3i^$h(L=$-_+73mHk zfa(YwIhYm!mL&fR@&aDEQK%4w$TUJ$KpQwA7KaOdfIPER5JYJaUZuKnkvx!=*1pJ{ zrW5ocw}f6+HT!XzHL2=w@P~r$#-dP%O69m*7)ksN3k`_Pm;x%<%*IA$@g^}@S-6R% z`qw^)XQ7Z=x_W^;L`xUAGjzOO#Vw|n5nfIbcCikgF1ks~!2{AFF+$dPfuUgz4kORy zWqAP0FGA*c#`FP-Sf=w*c*5vxwS^kzJQPRyIPOD%bfzOSrVrq}We%y}{RIcOrrKd? z6$}``1VWj4-@0{ps#~~E@5ZgVT_cC4c8%!P!=;4>?_VHb1oOvLaJtT74h>Pi1a(S- z;MQ1;^4S`0;*c=)3CBqt7>n`#o<8sw@8jd;C+3g=5N|Ww0)k!f1H*(0;%b_yf#0PJ z0rWINz*1)r97q{546hA;4EFL12=?&~5?ABNfN(C`JtVxndr*+Uz_q7MgaHZ;p2v}j zO5X>79?iuIT(B`B0EH$U{^NPn+EB&th?UV^{qBx35IJNmg~+rN`2G!qt1b&859II2 z7t>^xm?VE^uDDDuBr?4a%A0w+$ap)xV+;^Og|W~}=K9J9?CfpSf&jPU@|XZF^p;2` zrR1@SBqikZ_88lf@)&B2W2pFnvEmFDfyY)%KgKWQlW19$?cetUXqarsetAF0A|xw+ zN0M7|qEyphu3hxJ8?TZ&Nf_ubghW_#h!neZ>keJz$ZV#uc_e|{sdzHf+bhdC7 zpGocLV?#eEGqFv^8W7MmY=PgQ*`^PE3m`wG^Hqf@DqDH&Vj5u{!CA!GnsW_)4Kv5Mdk71zd>+nM zC9}BxdJX?wT1*pAZ4+Nl*s5|iRzt*eD<%9{mC8*5FU{o!=_Im%8zel*Cex|mrSyT! z-W~QB=e=*@WtwMk2KGKzwI2NHzr%5JbYxaoj*d3MR_JYJ3cOC@2E(rDN#ILD=n!ch z5gv!_E`&7?b2KmUhHWiF>My)ejXO^Uynaa{&h*Uy)?J=*jr=AUK^D#_R-Y2 zpQQOh+acYAcNMMv{Y`>Psp70+mUS`h)olkyxJwW{CnbjhuwIh>_|KU6TK%dK( z+aleK=6<0A7SUMFDNec}IJ4Bv(w+QN=RpNhW4PLMz+yU(L+VZPVIeFUYsX4A*7BD6 zFYDOHn}V;LI7qs_mM^3Ku#Wo$C8aGzl*Ggu2WHa~kVcewoEw2$eX@l*OY9`4;xhF8NOt z$jX(bP37mxOlo4xLjL9+QVwz1FW-Dj&oAFhyuM7iniP3sQ{er{J`tI1qr-g%49$vd z+$Ya3d`(1`rKzJa94kZ12K~^{t~>%k*(L>A3t2Y`2IssLR<*F zwfy@_)%A&4Src7@gL~m}ioGv>PRuM80N=;5q=b z(rf?*l^F)|UfaYNJm0xMtQJfb72hM5ygqmO%^MOpa8RJ%^%9Z(gbeR%dF#%YZa{4P z`iZUq0ikR7sx^M8HfB&j_l_C_94RTb<4>vdvgd^Z<4Blacp5@hXxZ|ozB)}l@;)Qu z{o%>ZUz_asPF#=Xkk&tTitQYGcW=<3dvC|y%SU;18MPb{KO{u_NH7eOovn=#XH>@U z$~69~KZ`l@^*zGRNZ(k{nao?aK%6V$Dxs|=0X5iR_b=47Mno%YHkj|C@zF||@$la7 za?hW6_ckDYP@vni@%_36_ipt|>x^+xb-Q=&R;P9w)%8i6HcfI33LCdLATr4)F2`?q~M9)ykOIXCa$UT4v>`WN*6Bg-LpX37r>9|c~A$MFmW2iDw zTagP|Bn`(XJ(n)fDTV;Zq^MvH&eI!O9S*^Hlys_efuxqbch8EBM_(U`8FaeF9cJO> zP}~?yNFEoJ9MlJ~s`Qq9d-Uq#@71Gk#pB0`4V`|0&QWh@-#4(Wo1fp@_3O(+2~xiz zKq1f-ouhe;b{Qyh^@8%B?$z0Iq#R8@OD7Y&{?lap zM<jJ1bdB^g5IC2pnJ|6}>7qEmLQbU?$|WLi zN>!%@h;TU7@jMvNUan4`xkSwHkA3WklTpzhffKPNbiAT*D zXfj>nkD9h@9@smgrjoG}ro%$$Cz+j;Cu3=zK_SsRc!QkSFZ%Qx&_i?ZSV9MNMY{Is zQxdRplUTi0ea9Ii2K4sQDButbsAV!ISPk@DTiWD1m%Z zM+P)_<<3XJyw<4z>d*=tfIr%JAl)p}0-mqVpC7{Fb^6wY5( z9>O`Dci2VNov4B^a)A>ks+?ejbi+v}6eW6vvX_*pPJrtH zmOfK9p!+UXQf8aMT{pLEN7M;a3Ou=|T};T8QA9-CYpU#}SZ6HX%Ur&R)jGL&`@fe~ z=tX}{t&WhlN9Zy2#L^up8VTO#iZggMqJw%IYgv93R}Ph?I@3D1!PYvzaAL*X0UIYd zC3RaKnu)^MN*~Tg9m+g~-0&=lv$MUOZCP78l2*Q(dzn(Cn#QtD-BL;)x5q@8JSbr@ z)n99kS^o;J$W2s-ViVTt8ek3*#WuhRFr&}p2Jk4A)wZ^Y-8Q%CK$Jd&lY1L{c{J66 zI!>U@MKg6^Vt~>%R(dCb?rv()UfGNAfv0x2iwVA(O7j5y5I;xo5yIr|f`=aB7xHs1 zT|&k8~=*5ufwi^0UO}!YtjY5!>fI86t%bdps9yx=NYcew?}3 zF_o#gv5`1i8zl=eht&M^xL%JssVO51{jVgB@3Lwj< z8}|#TD36(q9G(m+Nu<>AX?4ZEg(InWAtZZ4*H&F;F6ez5#TQU3a#-<|5w9Opy&R^u zze<(3XBdV7qCq;MJ`QCPg4;E*SJ_FPFNjL;5e>v@=Q(C(aZcGu=z{rSA#dTpSDwgF z>i_)64RIaZZL@1zUeRVqx6Q3Ps6!0_r}ngq4Y@d)aK?{8m&Ond(8fu6}MX~v64q7>OI>4o{LlwOFX+9P(?W*y>ml)2L$OZ|G zkrA10!MquR5cUN=OG8OjyhpZ_#<&j{;NB%7f@nJq7|_upBElkBc3H0;)xJwuSeN!u z=D&~?wBi}M&LR;b${elbec!wt7KBZ}wsjlaV`ALp7^0>RwA?Gw3VNb5=CGgUzvR9g zN|4uH6<5*an({LEY5;GBjk)Jo;4?9AcG|0bdU*Nw_44xLlKs6s{rmLv_NSTh4m|q` zouhJf3k+=6&d(ou7k{@lK>=K)b?fZdR$Kvnxhn3V%QO%EhsOMSdIbgd^bF(@g7AsO zk)-SMH*1w&+pysFh#=XfeOOrgw!y*N*}Hek&~ao8LRM<8qh~ACXY&UJ@frC7Ju28c z{fO;3`Xzlmz5V@rdi!xBm{j5C<>jwFOK&40>`-%`+W7~xZ4=}V;MdTT<(hl>qf!iD z9GK zZ)MM^=nc|*8!fPY*mb>r zZ@1XkZe3$zq3u8~qdIqvVm~qxoc6Bxg3i=@lgC^RjahIhQq&;#P_-+5G~cdW(|_o1 ztll5|=bqvzUQzxo^bKEKhzwqoWwl4dMXqpud2NTCw>jqzcaU9 zWX?hO3vmd_4Q_pj-T2EZRv8{6qNcz18MEpSnqw;~h>~RMpBMwaPn;L9G?8~pG-!|Q&M>lUJuD3Et$<29}=?y;c)vNQi0~b&Huxibh zNv#KWx6QaW`W%UV^MXX49r=KC&_W^t&i!#6P*=uzyAemR43idhM8A^y8ANciDq2^m zK89hq;Ta^wa_qu@IjWnE#Vn$s>AuYdfly55R5D8z1@21Poz1ShyPg_2c2`*K-V*`? zrg&-iX1YlWNX6sVNt5-F`{|EL9me!s9U72yXRgc8W7B*1tRGeG{j^--z3U8J_R?hy z{qRRD@SFgXm{Uxu<=h#bP)RSrUrrGvtrRxl7%#_r>1frMo!sqYodiS!46*t24)T># zkQc=6bN!IImj7z+lsH!U`fEsSb;XX3!^V*I^rc$t|AQz?YN#lVO3qs>Wy&ANB#Hg= z7JrBM!6}lY{mPkWtA=bm$iywMLQ~L6JEAfgFJT0kVzQOcfGMO3qQe_{p58I-bnL5r z)1JkX$PKTCW(Tjy&TTcY`rU#Ebjv3jl zdFxKS+uIJEH#LA>5xWCbOA8rQOry0xY!@CDXGExrh2dzlxp1*z7;;SrBHtEJXgDcN z_jpD{AMZsvga+6&saPYt_KgQpZ!-B=#?XfIGCqM00KeS8CpavnG2C<;TY)m#|rpRLwW>U);-`G~z zC@~O>LQHsbsIF+j*P@5hhBFcu+S zBH=Il%s1IhpZ)$jsk95vIJL2+SFC1F$3v=w^qMD(({J~u*Z7@MHNJ;_JKyK+YEtdc z?Zc$%Dp-nqpEqN`uEF;c_rypGK7JHdYX>RkN@w4{=bCRq;JVXJ7J=WI&X}>rQT(U7bVQsyI1v;P_T;J}a@nGs)aj=j6hjcl7weLVl29v&O9Drn5k zgeK7kMqVW!^h-s>9cf}2>t)}Uj{0yk(IxKpfteS&#xh<-0Eoqj!FQ&i$_Jv&Sc!Bk3@mMf>C zNfmpzy%#tyI%{t&Paufx%p}fe!rRnECWeE9i)_ow1kO_2B?R5nZ-aS`{g?SEJ?Dmr zr1@<6@?19kIDeG(z~J=eDU&CZ9KM|h88dXhk4kSWDsrTt<7BzEXW#I;aiJ+s=Oms_ zAF+L;lU?I(tvq}_58R)ezB#r=#ZK&{s|4r|kK(Lvyr9JEz4bU3C$lMW{vg>20wg_# zWesp50cJ-{87EhcopfN$mvlm=6Mox%Qeq}OL^39(k9Nz5C#A^07ScBdrok=u^vw8q z@K5J@&6}&QNNlfy&`i@^L@Xd`v1^ihcUXk+@z+aEcC4cVUk2-|XU z^iTLI><2whBWL*Our(>AsD?3-!@S~wTq8FmrP#p+&b{VUo>r}+Q(O$^<}*K{Y6G6H z+OboO;P_uj$tJbSH?`tNl5(X=FHGTHx z#C}_s@D3F!IXYLYIXr#?edXM?sco;s7U)2_v{K}-tBK4p4yJ)~aC^K0yuwMyW*t&d zdeZ=X5!2lWE7Z)8mZghgB<}8TE&@4bz#4*C=0H`+xVq`|qq1)*PetR=$iaclIj-aC zjHPW_E}5OWRYNbS))A{Uf0AG4&7;>|tS0VsC#Rk9aHP-hhcjozPk%M-9E{g}@ar5Q zlE}e!GQTw*tufUoW267nrm#UDLmK?&cFBg9FGzAPgR(n!f1f+E z5aMgzyx-^fKmRAe*}Z$`&YfG&{hZG^=f6FhgT(Y$m8sI4IcwYoRhsEn63v z@b1b+w6aUc$jKu^h(8I=nUoW}nEw97RrOFML7Kv)eeenWqZfIS>R^u-Bg<`SToV7aYG8Z#UZgu#DfbIZ+ zZ6#Wst(}$39+x_T?Ka$#xM(!*WVVA3`&-Q3g-{sOJF_)DOZHw&Mh?TBP`_m*kV`i-NpiCFP zs;VLPww!-0t|txXOO0L60Y{6s$E--ZPr4B0w#DxkJnyqyC(*4;ATloh`iL zJ5|voPv09Gzf|#^GsA%@_7-=+i>7p|9}ZclmaOe!0;L5Y`4$dwz7|fFc1hx{_fN@S zx}XBf!aaabp+`zV6~2LqHD7U?I4>oKQ>7xZ)=~JYl(?AG)R@>*zF%-g zMsP@a`p5U;JAWWNU4X8ItiJLHJxnyEYXT?j$>J(WJ#E#*G?nUY9TU{SP$zscl2l;mNh<>SnxvEA z+G6>M6hEU=9Ed4-Q(x5n#(lxc21h=ZSaXAM=9zU#$07}~CVwxr#Ywyy_MbuQ4M99n zox)1UL*RfB92?jK+HK}I&PrLOOXp0eC&qCpzle^Zr^zv3gxSnSgrBD!HbYrQ`$kt! z$ygL~#VXZwv{WtIikfp|jco;+!Kl3%@9-VHxoidLaAy}FSb8*$~6OI#|V*da9L%wd(paBCTggxKi9nRgr7E4!_u zrACt%F(;>OnoaaTuVI+UpD8bjdxZ0t+oQFs)PwP&tPILBu1G;P) zSxkRB9&qyNh&|W1SC9M`ZP9o%GfbHEir+1J9>5ct?}~~ zCZ7&^zhKwloT0;VR9mY}F;Lwl`X@rVrk&J77=ykR#05PB5&{m%glR9GoXzD^EYx(; z(#(!&{GVcTE}G7xa{iHn|!Wb zP)RHQ6sKwbQ2lPi`HmY}<=Ec6Db)*_&J#u|JChMXa1H$jylNAL4~RgPw;Ff0QY zIk#aw9zib93n2FSkPxCIqc6D*-SE>~K6_bGJ48IEd@kX)(F*!z%Cl2*dRN7i9vMKI z66R=WfzY1hX;`;l?1Ao|_gFJnJjrU#e z>mQ9WHTgpjroiu*qCzy|hr!g4iJDM1fm35`ZVm$A5&HA~wSJjBD)!S~MKf*|9mSw6 zGD~Fm{&4+ch8%SF&aBWVhv5t@a*NvH@4-Oi1 zIA1zSN@ve6Nxiw`Fg^UuM*3R{$c&BNc=QOKf?Vi2(*UUVzT(1jT%0}4655bz{2s~hMK8~c2eIS?bp4c?5# zAkNJOvW#Y){N@`8;hjP&j4Y5@hTc)`KOwwyk*;T*n25gd@ezGu8NeGJ6W=>LUY&5~ z4r$?)oZ#yl8|!6I9!v1f0wF4%jX?QjU6rk}uxAP1>Ig05(dXaCU}E720&(IP&7YmKTOg+J&rpv*N*2MoYM7fol7QN&a%;16rMGoB*d zfBQDp?{##hGESd_g>0z)dSw!{)%R&FVY`NStM+h^9=AYI;+vHDBP@8uCc|6x9} z)`t*x;R1ri?NdVY`DGq4|4p^2Dt1n}_t>k2%CG66qhpBqQ$x}irmjmP1>74h3YB9J zfK?hdPt4|`*n3?R&k0@Gp3BdW_U5%X5N6go5bkCNLX-eeP4ynzH+HF13^QlfCY+lU zhd2!j#IwYUtT)YF6lo)1f2Q~8FM>krd>EXC=A^}HI#&0RE`?dQM)^J0iycK(WBaiZ z*NBI>EWH%Eu%h=@!;T`rp z8`jVnIC-M2+4;FffCVYf%*J5VC0Pp)(;_uf+4{2evOd-6I*-14jmkCydAoE1N~X&vIJy!x+_P zl_!M#tI1HtE!qzo(4Ep&j6)CBB4*DmD=E#KEld?M76tncf}a|J4$rEqFawMr=fWn( zwNdT?NOoE8BwuA``C2#5JT?JKF^FihgucX=)N28`(&DlyC zuSZW@M3sA^Z>^<0MHZ!M@CWIGVTY417m3Zn^wFzHo4Gkz<)qDgZAEO^xDJ6o%vkYu zHfPSi(3vkPTfBr_mn@dAhdxZOkMT`QoA3dXVw^rnPz&Rvd1o*-gVcj=F-N!Axf(1& znf(Ed{^4s}`7&-aSd|YD>3FE{& zTwATVc%AD^qb`t)=Pyad`SW!7%jb0Y1)^B^cEQ4TrKRr{E_k~T^_d7M^Cw^cO`sd~ zkqpetoX{~)Ff)N4vb(bk(7;;Nmee*bLzhVnf;kOaK^3-r7Z=F{jm2!2S=h`%O_o$u zhs!_AD|xeQ{=+FcGIMxxNvUpH`hw!*;d8_(KQCDJ`>eU|Im;Y}PR|3-?+;3Tc237oZp~H=nIc*X9x-%O^|^gAaTFqEB@~r;dMztnyo>=PWyJK* zPq0~nh<5AyJ+K5MPkWB?oBwcZj|oqg99i-OFSp8+_CJ()uQR#-$uGIPp1`!BsJ$2z9(!RL>T#*9Lp^aD)8@_0!D9r#>mQfmu zf~AN74~DC9({Oqhz3e-8pwI5CnXC^qnk0qKayFWT zJ>#ufp>G8J2c$QkOwXA}zmdHRkw?pr9GY$Fv%Cthi@L(ebKe<5uq;Ct$CLQHdc7`1`J(dR*`I> zHOOb2=20aKc8ObJy4~`kG4p;N^>A8(ZNG2gS$b_w1>69Bd$u2vxk$BuCedyZjcMD!@9QzD{ zHdBBe;JffmP+umeLDW`b8|KFSe3*+9H}XxymE3@H@^+l9ej=$lR3l&ckxsl)g$@aT!Ig6P)=$8fJaLimrAZ>#>;Yv}mz-Ycbbea9hkdEE2!s zoR^AUq0PTi1uFY9D{##;pjB$kG!7R`_uZ;M&JW|pSP~Mz{a^<1yNM#&vovg^w?=U~ z=Tux)_iab};dr}8u~MiMPD>9pht8~n?4nyPAU_#i$o{Dh|3Nlmyhs_$`l{R~Z2n|6hT-pz1IX(*6& zjY9)X>yuW-CLJ8=>J@GYZp1agEn*bOqb9W(S%No`-4a}KBO~(A6c5Caf(O>iMvz9l z@Bnu5hDwtIM5nS48bB6lA$wy$yVe}f*_UaCs+ltV$kDoBD9B)reDh3Zj2Z zZtzaZ07j(rv$K)beOc9rxA~0cOw3yb^fK*e>exQjX=q{aXWXmbJ|DX_(wKj0VV+iz zfOeRss^YDUCgYQt1X)5^z)MU6L-$Kz{IKRp+dAoffwZ814w0g(j@!|9dW2_qID)H& zYbJoWsud@!&FxhXwr*sviJ|j*ySw!vz1`gVV6+ZVRgxl2QwcVaX$%%P+hJ~#qEQ<+ zj&kqW(_K|rI%fRJlHT3j!Z7TIke8%L*+5WhW+@a}M%WvGA|}d3M9P$WF!PzpaDZ#C zUalxbRaqS7*1dPh%JE}LQNjR}Fi|R@gq46*s<)UWX6fhP;5$qEuyW+zQcASHu@vUQ zqdu(9l(qy5HgK%7>b>&Scnhv-#o4-RBnh`Stkklkd`z2(baj-Jjs;(}UouN-EPY3L z>Y>tiX#RydK%EJ3DXlMi8m#GhWI4y3i8 z^@t@A#$l-!mNpL|_0&$4kn1}MLd2pB)BW_;ZsuDfksr?OkaTs}#tgbT7lW?O2Bljk zw)AT^YzWkIY0aGx%cS1*zv<-O^QqdsmJ@3DRKWgQWy*0_dWjwL z`aKm)@1tB#5;d3J?sJ-M65sLDv}X#HUCCbQqQ0>DOWIg0(fvYASCOS$8P|dwAtS~4 z;(~|V5ZzJff?+*X|6KJ(XeoK=sj!mwQIC*If3 zLm`s*g7cb%vx~Nz^Q8k{iv755zyKD(!8Z%@{F`bCw!jW->dBg58wb8-w=)>-%X^$` zxDV5~4dKazgmt4EGWM55EXGm%3sQd>sKJ_w*(FQ6C#*~*LnfB`O+Fz0MwFf&u3jdw z%@fOh*${t@^tsGH>cvNAeLp&`Xlr`&L3T?Qrp!-q1b(?d93no`7h-8PuiCFG#!E~S z$BB3KTdH3Iwqy+OdH-14%ms+!xX$ABoxpE6?RwG4>>S4c|t-RL-_B1$|VEW;^)$2q`8 zHqHaFhN*~{U@CKtaPY(t=WpyM#`^(&P(#R4CTKtCh+j~w{eV98?7kShWNQ$A4m60y ze{@cEFlp*0{cI+$&INOwo9wu+TpO#@qk`mmTT{>YQ|< zGUx;aEF@Mg2r??wt)^AEee}zog`1M>qpTZrYKYJ%q^@osnlYh=wQobap}|A8rK^6p zk+W{F6C8i3)OHDdethwww|2pZ-p0Jv#60foDm2SBtYyR0U2w$0q*AGEcxz(grec3U zs#R)b9(_Pc)9DcH8$hessiX|7tYHI5DI8y%A!UP1tb8D&GvB^RGhA-a9H7Lyv#sdt!?`aTQM1RV8*wi`KRJA?~xhH zhih4ZBN}u27#v^_03D?_(d^TIkO2POuEy0``zDA%6qic@X7w2k(uyY?`y{c2$4K#Y7ViD(Bs4~DcfY@{biP+9Fm zLv1Rtc(~d+tlP|jOs}*QkM`bTlbF8Y31CadG32>-Jj0>Yhix(!IXMM8+orAP?C4Kg z`6R~q!E(vFMRQbt75k)CyO0ZiHOyXJkjCM!wn$%{%-VwN{O&sjYXG}D&+h}? z{&|&|7RM5uwO}kJz-W`1HbGiv$e_f0ki~u$)?R1r>OhEN=iW0DY#r@860%+}-sRM+ zP4bYgelhKLS+}t3KcrjQV20fbDfE)YQ`*hoNy*&G&Y4~kO)3f0zi-th+ek38!`ljc zAr){`&929`M?RGvB`UF4ee=D&`X*yqe<~;WkDMPm{qK1x93uU%$ub5&2@a?>GH5NW=#2LPb>`zt{;KG0*Ku9HO|K{B z(>Fh4eY<-I@!(e%md#!;XGp#WjpL@6^B(jaz16%E&yRkxcK)*>*X*q$8x0t-FgJI8 z5Z|1>g9ja@B1`{JxC|j(1szFS$*C21bUf%#Z0H*t=3rT{vnJI+w+wmk6@5ZFUg-ASe%LQObrM$+Q07? z&vM?yb7&WBp}K3;!eQa`kN8WQ+O`Yz`^@>9GgEFAOny?9vm!sqzmHQR&#PY)oya70 zdnk8HRIGu3b)HGG<8Fg&3C%2%AvIq~ayoV$<@Yta4$`Gvhf@eJJTOL1c?z&f<4yqA!v21sU>Pt`b#zl{oV0Q(E@)DOxpI%+MUsr!cLsZ!4M4 zY30kTB%_H$5v|QxeDhiC5$6>ZUyjLpdHsW?~DAh6GIw@ zKUw{bjJQV5Qdnte6z@(L`&6n(U8e_dn3b|8tn@)yug{Maa8?0*Ptkn(eJ~ZN8(jdE zn_`c+Ns@?~NZXf-Ya7XaOwv(U$(_#JJs}Q~8?*I?@>ykuJn$rIOWfX)!LRa89vHPf zneJv@N53XM`}Wgq)S7%de#V)sGjs)QO7A@*!|C)4a=tQT$y7fz#3Wao@r3vJO?bmI-DmGt@b zyi)y|kt0SNS6(lnFD~7r59iGz&2C&F7A2KOjvn2;`vg46nztQ4VbsKCh>twx$+nBn z2Ds*bwPf3q(SlylY~rX1`F0qwz$b)KNdN3zeE;QL@D5B1#$LrmavdSR3ETH1CK+24 z`SC5kBXJil&`saB{ElwCaDl|VZ+ZUZ%kwSIy?XWK*wZ=r!^?H0U(wB9IetaATwN%Z z4<9%3)M(|o>Gbz&x9H=N64LaW>%?sOt7C@`9Y1m80K(ICom*H`+Tjo7w5l(@em`W$ z`>*#^O;`TWp|q%QZda_?rOe`V2`kk)MJW}WI{tqRM%wpzWopG4HnNI?I%O;Ij9N)+^ZfSPmvH)Jw>>*uqA?qw@ zv@Q54>38TPT@8bo1<#6}Y}4oL8wi)vUkf)dub(%2PWqobM7KT--bSCD&ALF6X|uV% z77g5&qaV3HtLPbfZSjoJ3`{S!X|-2$X9$v^3 z-lXdBzrK}v$GManZt2Ke_ z1!bc2ocK9QyS3~!b4bZ*fu2%T5R3i4kS@!Y(aTR_h#CC6&6;+5?DeSiGb1-l*+i5W zom($o2Y>epX(W!~X5oQH@W2?fwZy26lr>nA^EjEbi%HIAJQG}wl#%+o_Y!5vh(FKe z1eQ&NbBx*3XOF@FIl>=1M(UKzpyJULuVx(Iy{PodFILen#c3#iK6bw%iKGFRQ7Yk` z8RyPfXe8bXrrbTap?yT>az9X^j0}P$e%S}O&4pTOA$G#{?1ay4V%w+0F89Krjj{WV zN@|+}(ss(eZ6<0Q2X|D(1Q zGH{CZ$h!MKvhEhYm`aaue=_0#c_w8gifiI=l{A+FTaC=tgR%xjzF3dX*009szzBrrD+}54)oShm9pA8>vq1-jx^#y4{+jcA40Es1g(|qq-+_X!)%5A`tn7!-Y zD8z0uY$M6|yXJqMzIbu^=ktqtICTgN?BLXc9?Y0JHDmCk$%7nyeH}ad`ZBu^&}*Gx z&mwWPt7c&6?^E?tu2r{2%6Qc~S_Gm`fGV8Vs#_psta=EYiUCj}Zc$Frw`a%C0Lw<7 zJW%t8FOY~?F#*Y;;qI;>SHeqTyCy`&y14ebZJJpgYtz{_+{O>ry!|~95TwU@~ z(_Lw&%GZ~1trTip=L#qBuBDipXK}EQdMpy!UgrCy^x;F!5kE8$e<48((5_v?Me`j#jQ!0+(&gx75D$%;SMEuZ9d@Pmsw@l3C4D`_!tS(> zGQPS3)wd|)#m_oCrXHtGk$CYjG`PHCk+?~DUcnssNdagXDGfl79Y7p6<$2xsN}>^8 zui&;JJVfHUD2NH3;@crKg;XFMMb&q3f3sAxq59sxInDX*XKT)*)nj1}*I4|O^gDf; zZe^bUfgTg1X`p_I#1ZWP-Y4UTUaxcjhI&a15v%lRQh8YH5cw1BAvSuYd0L8=%NQ>>4qN!?-xf1AuO}KYm z)WsI#_O;)Dl)yl!rqlh>Q`rZ+^)-MeXN?+Qcl&gOq{Y#!7N}yVsFW^#V1Pv!lauvU@s*)g*T5U7hF}qW;mOZ+=hFhS{)*`xpdN!C7@0^gx;fbgXs-2Hk7oZ-v%pq?KrUxEZg6tzs8MArw7|-5>^!5}~ zrK9pzO^Wt%k3z#zA%KoB@-Vn@k~K6?)JWY~8&#wuD&q$YPJB1yd=Z>i%}wnWoz_3D zU#j{b^Wv1fbHI1B82o`izX7SzU~W%)>zNjhX+I1k2VI|OgifDHg1Fb}z9D>f{j6mh=ng6l6zyB4@M9ks^A^z9Xu6s&7XHL z()FUCTwXJar2JTXE%B%3-@LCYXz6>d?g}vmC+yX$>G+ZoI*W|&LpsuLVB(}5FASs| zjq*@ui8L&$Qe;dVWW+|6F#LjCCm(a!nNY@YaTIhGd%Jsh^!Re;oax(4>YuGs--65w z?&IOwu9tHY%P|8-&fdF0-Gqd7_3YF^lTie5_+sbyj@D5mbhKZ4yN=e5mcyM=^9K*h zM}vt5z1p5I(-SR}h1&U{cCr>0q!0G;PK{tXC^1Up3gTNM-R*T3 zF09*hmXNcX68iQ9dz_GPA?4;a(;c_(Y%|^bZTW-@(}5Ew4y>O!0p(5?2GP%qhCmXN z7y}H6bdzdR!xj^xPcJw1FIhgib0Dd^6o(>^nf-t}`g*4jMc&}8^)~xYRRdr1}ARJCT&JF;Gi+ngjPl*|) zH$R%YasB)Og#pIK0fk*d_=u)hA*JFyi*dS$JOT^ZMBN>u!$ z%<4wTrJvzhytEmho6HT0ZhVx+rY`8@Ca*aQ$Idm{414>=LpCp&G$%_UoD4{{=+?+6U8))-uur3XrfEbKKZ3%NC<+Up*l zT1M)dIW&rl44yKjja(i%RHsfx|CJC6wMT_v;tXSZ!%Jh-yBX+$2KAwxID@)OJlCpC z18Yq^kG3NR5To3sOAm^Hj$L>Jy*8RQ7KIEEa%oqiVR&f>_BfFlETwf1rro4}OddMR zX%hWIy@1?hR>8CleF)oNqFsfXa&Qxq3K4gPH@CJ^TbuK`qJqLlkDfo6S^VJnBcoxD zAJZaQ@ccQb;A0>eg4iR#+P z(hK~p7`YG}_RhfHcI&EVszy}Qy{01b@Qu4s2{Dsf#{O{cDIjXmf?Ntf$5ial1XWOB zX5hlsE2c-|l$ZH-7qn%h2fupBHsbHmu~9u6hkjF4`BST=GNZj_KmMr<(H_X@qkh$_ z9!@R)KhvLOyJ+TAt&RjO*V0HbX8fl)6l^JGzSp?VW5Ofj;v>RiPW^*%?)(Y6)UpRQ z?oXzW^iN6_9j}puTTVAg;d}W!gg&A*99$j**2pFs$hy82M%tfrPZ%y=5X*os!S&iw71r%wSD7uv7w#1joOjh zCaH~CC)2!1{uz!9+B8E*j&GPbs%4vj?S^d_4ucySMj9@DeZ1$mQ@f7j4cIm**u>~h zl`3mO*1ns|ycQf_BASw`WuJ=rtpccbTvB?N19z2u%Kck>p3BUI|Nj=A$6cJwyY&{L7o$xerBI%uki9%PI`!KwUnMGk>5DqAbrnLI_>UM=llnA zx%_v`w{eR8y>gf8j$Hm|DOd%>3n^L!Q%_1*u9Z2?v?*kLNx66T@_96oV0d?4w1|HF zgeJV>ZfmF99`BU$a^7DAil#J+>G$yTu) z=xCTIgJx|k3HTV1*2Idz1Z-JTnvjdcN}PUAoK0Fp)1#^}@YJwCIgD8yGnZfNS_NL1 zVjh;os~9gpY$Ii-3QR%9F=0doZL34Ou*RE5BAQ)0u=T;P0YB{Dd8ZZK+2||M`xa2M z;b$(3y!wC z{|6i;%}n(9VIiJ`TUkD>sC=bY zXrK>$S)C`uNB5w-d|ENm!-Bi(9;_~}&R@B5<@BQR6<%Q>-Jc-G^wlUOxVvzD`26|9 zT_U4sSk8h4Iqp$WVq=!#8WEwR!xt^cagC0qJ=jmK(XqnyzvaPQq9VI=;RyVM7JDdO zAn(+#CAp;@W_4qr{Vae8i!SKQ6FWB>OJd^!niJx2m;+u$z(MS^YNV+w%y~DW)@7P* zo}R{am_f+NBvPNYYzS6SbNX_6<7?WdDaTE>>c)TyHJv~O<4k_axg1B#iv^R0WMlStF2;*fn6qXkhF zAXi_JCJ_dn@S_x211KYy$Ac7QQd9=S7*dUwJRm%h2H2oGjBEK5HLD%rQo|DnNJzg( zjcdD)L3%%l!Ut84L}s>Su^|}nPplM1$Pc}`YBN&^@Vn)7n3EY{3Qyz*Dd!r=N%06ldmO1)Mhp5 z3ZR5}MQ6Q&`-uLj(J`s~kkqL7{;7bnG(OBpBHa<3k`@@yzyDVu{b5c4%!;ovL-{XJ zK9kcJIZP^BK7_IuDz(cO$HPtos2GY*qZ(2lb`ulDnaQx-faONA@G>whEzmDD)ri^G z!eFC+aA0x@WZ)~JQrx7U%>r5mNQjMPJ#4~E?x9F7A{%D&v;co358k^QNsoMak zfOY*kb&VRTbJTX8ZEafDGpSq0#MG)~i?+=y>NGHEVF77APsrArYjV)14E(Vqc6M$m zHC>$}UdgG>2X^g8Zyv(xHXr$Ai^XznmiLr%i@;qsR2np>4Hp;B+lS6Y+vR7VAX=JOalz zXPeq*twy?^PA@0*8`@ih_Y0mfxj}1MPKR;+{GmFU)bv$f6EK(agk_4>n$MUh7YQjA z;FF8&1K-7}wQidGxv`gDy(Mu&vSO8;)qee=Zf_17IWi3Q`3cMPFXa0$v+OW=WJLyi z%~Pi_=RI|^mG*I2Sus_wgk@3vZnL{K-$uiB5K@#oz?8;`BT~}0Kz|ad96;~tQgo^G z0j#3h(!DJcNh|sw(V8#>>V&GFctyiWzyOZ&&|NV14hEzFpLEL#HmU{BIivO@PIqu` z=hLuB>tPyh#-y-iGhLFqO^xd0+{pNt1=Rl9$r#*k^}yOOj&R5e;H}M7><==8?p)4| zkwl++Gl#w>DJz)r=>hwvqM`y-$Q2XxIr@!~^2AvNrg5SHHLxC-nDQL3({=x9x_f_X zm*}WYZA0pBP*P43sFi@mis%Cr^Q(mtB{vWb99 zFOb7EphQ?K$taVh9EyTL7>yc$dGZ+76sEIWuEKQ_If6 ze#iD5LiicohWAY`{(OYHWBYc_9hF7X24>`shzWAV6P*Nm8fhFXp#TgUsYVqIZj|Mf z@God-W^{S_pq*KJU!<%!67LnWf5MoAm=UQF?R}fJvF@+lJ-1_K;*>zLCbX-kn>w#(M!>kfu3jD<)gQ1976>W8H1aTLZ4^N$-LQ%q zP%H~Oh$|8mg>{iUrm<2I10EzDk3Nm2^>yp!(c|cjIn%fAl;As$?7CLXXJqEi+OvVY z&f5^1Sdp=5qqvKd`h`dKOpEnQvre~NqW-ziqZ-#6;v)fifzFwJ0~wx2+ox>Il2Euc zAq<6^_<7UJp>~d$ZgBvC^Y9jJ3~%Ae+`|3e-vh87Y`GO*?acI24m?noQ3Glzk8?wS6kg9>ESj;|VqKHiKs zP=Yhdv?vT{u`@8xF*DgY^q*l?FF!Ge99@DDG5{a#@ ziBG|uX_;FFeZD!Z|C-E*Ik8;QfMM}5ge1gArVT7NZeH4QH`P{jA9HYE;);~4)p22? zeKqYua$@^tP0jV5bqdH-t>;3w;rl;6*#`3DzdfDopw8~(`oC8qP_O6-(_3B}tLjPH z8x??c(OS_NmHEqn!i=GNIEBdwDjPAMfKYXNd+l^fv>)cuKgn|2z^&s-4)t4@JYb@~ ziAm5zm-vVVb>m}VVNz5T{_>Dad~*L|zlZ1@~M(&~rxra3zYByY=UJz`VR^2FX# zA`%NTO*0Cy82X`CIvQ;=0L36ov1c2G0}}_?^6mmPGq`HH6YIYv)I)K*o4GEjZKG@(l6}5R?vAr()r(d;yolnAzk7^GpgAqV`=81LdUIN!n%crfO`bY+rY9%4S zSVGOLb;+ksGn5y-dj-E#8f`MjL|D$45?idKgB?z(8cq>5^tccC!waQ?mQ!Ml{cI2={g9+)O-7{Oi$uh=BpULZ(<{G)`J2BtNXO zVuihoK@^bJFt&Ed0-EUNpB|VWws~5~p~GtLKo^gIUM0(qP9L(Q&llWaH!n}Me``le zQ@6yR^n#KMdzW@Lw(S~NWegrRI&@TbZ-)*TT`_o{?~E@LM|ULZKhham{)mpLsw46jc=f>sUF)Heg-BLK8bI{i<}K@l1pA^`HC;t zXe8f?v!-)7<>k1yBle7*M&m(1ZYeF!|2Xq;Dg%Bbga3wY+|Eolbm8`1bJLSX`xw>f zF{V>&tXbWp7>}OT2+*nC*`iT>-qg~gWo#r723=iUH}Bx!^sPf|Ms0{)HZr+=+C-o1 zo^{hka)ZUE_U)9@O@*;}2-(78h^5hHe^=?KEtz!U!U(g~Tdd)z$aGuU_)|?D_b&{t zQzy7EdBOViM{C zg{abKC*wjeb30)Uu;OJ=$OQ{@_1HF-Q>|SL2@q!3RD-CSmszp*P({eX)BttffSA~n zpuX-UNA}I^;U3&GvWG|Jsln?sdsd7sUF|X|wYf|GsGj}%aPG4ws_Sd!H zzaKHzAHyvJdNNB4Wkw}rJz!;ls1{NMWLZjfbS+IOEP(qqZRGmW&jb5J_Do6ePHvOZ zX5lENc&vazZ%xkv4e3>doRPX`Qi~kyIhfvHBbdLqtb*#-m$uJ1r7IqTW&8ltj$yAIU zYjHqqKcCJ%UG18+jU|zOwthX@G;bRl+2ij?&DzoTr%xMYHuLS@vZX_d#@z>6S#{dm z*gLIdE5~LQKIus0ys_{iUWF=)+M(rm9JyMdI_8!7OYOuy)-3cDw1eo8{vMNjg!}j0qT*BULukRg$h6 zV&9aHcPxUCVZ^ZEX$c$=1Cossw&J-mRpX`&Ox0*)B&!HS|+jf+*S zrM{B9o%?>&PinSDvtx;2jEarVtsP_8sZ|}bX6BuI&!Gc9>(bN&D@A7?wo=q|o{#Qm z(JW(O5wCLhLd(5D%W=}Gp#iWZwH(`n{zKDS^@g?Bo^w$5&RI6PAk5Lq9|P?ofJxm8DI~MIpgnjRQRLz@)jty zOKsf6yjuJ}v1fA-4V-v%o&FlXV2?^)a(RsfCp(ZD)7~TggMayyQ#L zXzh1^H1^SWHY|Q1j5pF&cyw{?N=R2%m#!q<%f->FyOWDocbqFOtlxO{?8f-M%n2PK z5&uz7-5`0W-`Q4w%eUp@2i6RAr8WoBn95MKW)k^AlE-AW^L-+t!Ir?Lb`)7AlC=&WnbNH4*y9Z0F|W*AX}uShip`=8i>X6jd+6J zt{;aL2*~w+MDpAIr|qm0FN$Y9KQ^=7pRRk}6R#7;NcRtW#S?bXp`AO2^|OmEpAZs^ zNPZxl!K?OJ00|Q5EZ`r=W&eeA)~}>r4e4xF3`l1oT_n<(f3kszCMAN27FV|~7-qID zygM8BDgeXGQ?IDsXIvm6_W3v(^_mb0&KX{{kr1nIVjPZGR@bYt`1T6rhcMAp@x6f& z%K&H`9GE#RBe>!ZUQwORE$N(LFMg+4RT?gXWYNXqa{e0WO^5ydbB@^L1DPQf8t-4| zkYnLc`UQgCkLL>M*bh+WKY@=}u`K5I~}J?j+)@ zTPyT`PtXWcc=~vb1D|&zD zMf$_4Nk5V%OZz+`Gl=W8cJ$AdMK|cf1Dh)D=JZ>eJM;7eJiZK%KMNlf&_c;aw!b}I zAt}-Sv|1l}Gp@AZ!U$>m8|rF{~XRzLp|y=!>>Lpbd3OEk%^ z0x!@%>CS7JYknLVy?)fJ)A^9`(Y)&IXbHxg_)L;U0W9-!@N~8;$e@uV_8QD$Af7U? zg{Pb^8K#@;Li=;+J|{$bQZoB-5yEhkJ}NAJJhN-E+n8uiMBo5(`TqOoM)>xMNyy79=x84h+3^LJP;>O{N4P<~Brf)FT}`*i+4G z)ydKhF3I4#pF2hzCeUxGC9&+9=sr5yGsS&;1RhcJ^Q_{Zi^&n5R6r-atz*#%_y~3o zD`%_vyg$Rjeq=iiKmZVGsL_naGgI%V>_0e9-W7*&CIuYUfZMD@O%!UQRpf%Cu3*ZI zl~Ou(1u4C_TBE4CETu;p($7fgU+G`)=2H3)qzgu?zGI_U^0mlPc|hD!DVO9@Q2G`r zcab4|ft0=*&bM|+=_}=QzAdi*H{Za>eoKty+&~Nk`pd*XEUuCe=**lrpBq$szfEg1 zYa?@4F?MVb=|Jzec2t(CjsNeDnXgV$7HEE9+;?9Iq?3rnj6P>DmnR9A_D%6h>>J_K zHRK%GJ0&PNILyT@NS*evl{^N;LZMVS(I`yXiXjiy_Q(CVokEfbCHol0+%*`DFHDGz zOh|}~PT*!GMnxqhMMWj178Pc=_4N(t*r6M+)Q-JE!a5+{sS7kO0X>qEdN3EK_~YW+ z4p`LOwt0Y~P1=eUZJYaJNxLdER(8{9Aa*coN(*ZO2WTL|$@sL3vfCtj0tP@Ui08=g zp>7^RSVOX}S$5w!dG=A>3)2v6UGa_3Na>>y{$2p!5a~eaqV$;zpRm?+rDu+Gf6xE8 z|CwN_w8vzXD*&pw1%bAv`hM>7a@>7#rRVUl;T|qRr^e(^Ga5?1mfb^lEr}n7D!f7w zP<;Qr2!;qVOpzP05-)IK1?e`t*FP6gAT;LZ88t!gveU#To1N84E|M2!PR?}eG|$tx`#aqI_8;$l zE8ksM>C}Dr$WPq;J-Vq|&D~N@tC)}`J)RrC6v3BH(3{!Q{&aP(?&y}?PEE)gmNd&9 z55JGwegEEEQlTY?F*?!BW^Vp~)68EW|R`<@x@XdAVi_Z}theyDs@fv;agp4|iVf&v!{Smas2*=+4!wZ^ zi)kn@K3lcm&$+)OCTnO|bb_>|aBfkF(vqTH%OYXXKazULO9&^eA6P?b6>kW8_fKmV zyaDi^SgGiHc$ItgA4}72_B<@gn}oL9KsBPTVsv6DGPfX4*!rMXy>S?sH`kPgBLUeJ_ErAXt9bXW$5$R~vp&B=jgG+06Yhb)R)f+^>L2N`35 zmd1-TNpAhw$gyqqh^gwCdBZ#h#@Mo%K@=F<9DEyol=u6}MvivFKhC>fJfqwIuL*d^ zNI-{HX8ZZq^r-SGyhglLv&aqmXnU0rw?$B^wks4>TQH`2!{U9LCLU`vTb@3)c_*#l z6F|*^6}OUO6)VTfM(|lH|1yG4p5BlCKojY6Vh#f(+o);%>04TUpYEk0vsVlzt;qb3 z?Bk8!eZG6p821eC$?;>dcV@sMtA|KcWe`*CP_H>`$l*G-ssLkRvOY?AQ*v=pLmH@U zCQo=0Ws12xyMN{@hK_sF-hv1m4%u@@o7L>T0mfGs+QuPK>VSB z_i(4wS<1W8_ABYbWUEn0LnH0P!n4BACksb@edbuouvKBXvA-{07i>V!FVi1ZDOE{IJ6LE;J3i-M;*MPXVe(QPFWtd% z;umGIMuT5gpsc%wU#2T{Qdwd0RTwDe6b3?y5Ez^!_(0rcEmGsrlMQpmIb+n$>ZQl3 z)@K~nKbhW2Xr)UfEy$#>^z<-Svq*{_y#*rhKndAG9omg#_kzGcHY%vm4q zw!C)%o>YL7#(hnS!b71L32(Ws4IuuwRwM5SG9hDbb}rQ*tsQ}X7Qf1eZO(-u0Y(70?+U@*_HzyX* zhjfbgD=fVZQ5FSzB(}0&uzd2+&oEex0j)CS+SNdrhaWSvtmT#2CFfehPvr~4rL!<|6P_sC0~@WrWI7OyvOiM%xB-@iO`1F`8AQ!m z%!aXaZQHO>$^5T0fa436EV+EJtab(=83S21YQ$p6xsr~ZmljPdy%y+~*thA`a`Mi0 zSy4gh)ezsrc&;$&Kk=MbIZJ;!J<>U(w`Z?tAHGui^U-I2T-)7k!)lO}8Zau0u?xeEX{pY<^}EoYHZ`^f&Pfw?`JA9q+Mk+a>sJsw+H2&Brk7+z){Q79CZ- zb_}j+Rlc}I(CPJ+Km3)W(n8Mh>6{iY{SfCdl!C_zNwivZ8OGx z_F_sKG`wVTR{d2ihc>I3v+Ur0niAK!&^ zadCGhuH&yQy+?~5fxxzH<%%sm9@63m_vp-rg!pb*v2wc~A&+VCedKuD1K+o?-h8R&6+Q;9Z!&_^mbKC%~z<(%DKyAuYO}A(|Ou=>?EO6A|aJ?D3R8 z=vGRPkmmGDI1NmFOeVi3y)H55+=pq^%tB)F4Z=T~tRucJvaS=yjl}dRjildaKB4pU z3b-(l!M7LWC~?XBk)%{jqpug!Z+_0YN0078#rI%S++wIWSc$2i^cgDN#u@^Hq2f^3 zN)=}+GkHtEZ%zs`*3Y~NO;D26mr3Kf#q`ZN>IFA>sQ05uf52~!B`W@!>U3EP!qzUP zFUe;*;(~AuueOko7g^7V|E5>J(O&eM%!jm0KV7cuhtjVjHckw&l{h8tTe4eR5rimAY1YTd&;r9?9n}Y_TDqk%pOa3;@rNQO*`M&eC|*h->&4_RGE8xNwyb{kdF7gz3bbxLmPSY z>Q+_gz)xpYfmKmQkE#zhJ@9?%yY-_l()a28O)4J?{lES|(Qm%Qc6Ocsh<(kQcf(+y zNE0W`!dJJF#=V;?uzh(~@d|lO)RwWK%<+otP!;QYv$Bx`ylz|cRaea%AvPleqyrL1r9dSaRsRoM@vM*Z8CqiNqSGka1!e6pS@OcZ&Ur_sSWj) zt<`Cw%$TIh91sjEwL#r}!$U{LQ9!EYf5{U*A5}Vv9;L zYnt!jLEpMrrD|uZ*JS?ki>Hp>@HWoVTUCvF(|=JlHy(Mwi{!3P_FebYd!Wi4%GY7l z*S(h=segF7@8*S`r+iT}s3>9OiZuAN>VaLW8Z6(&A@`5G{C6_zmWm5d<)sN*aBrEy zR{T%b?yt7r*tO7EpZHbwvL*gOmB21FNkOmJz2k*H_NX@5$Ezh{ueb4qwLdm;-lL_5 zdoKa>Hgk?KysuLP?r$kjz*?s&{E<&{RV(soM&vDD3tyD4>(`~~zSpqf>P_FDJaV(# znPyWv7H+z(_hS{jh0n}Q^F7+{%g6Q}@COAhJKMZ2FbP-j(gYsvqa1XuKJ!wA%-MNajGbAh`PU<4^qVVcv z;JYB~cT>Dwua%5BIPKs;{n};U-lnO2^Do~?(=^pi-y%)r^))K7W9hc2Z};!0&^W3} z11Hl|bt7>7IBj(`)7P4N_PuXPUd z;h8FzC{xbZu*Ek-m3B(co~?3uNr`@SVRf3WYg&HPt6sr^Mcb53Q?_-H(j^DZRTW2! z;Kyq=FPI+}XoHA77v~aP8}I%sn!otaVlwu#g}u<~aWA^)y@S5>2dpwYQFYKv@*ujz zJ>%&?-`0b>ciX9Vd+*~6xF6p~d3~bIvr-tt{^s&2?*YiDSt(wmtJ!nO7rmBst&;ZD zdqa0Toig?5j-lGTTutj1ELgW`uBPio*Qzyo9T#B_S%v(!xHq0#$I7P2#8;66p9jv8 zagp!cp?+z7k?EGIgz5{YR8;nAoh$S|G`u`Z=3ACVXzV<-I2`vrrJ6^}az}Mh370ZU z>VAiOZ|o*f6^0+|U!ilg?0)~7TUJ^BclJ@V-o&ljDPW8W@+*^GlkUVy0Qe^qcdZ~a_;agK5>OdD2j-hfI6@>Q+R(YYP7+JD2J$2&SNn<8uA{w^Bh z1Bc0}GQ~u6Tv@K~n(kQ^<(U7=oJJcO`tJYmq3h(Wo~KmZDUE~ez!J}zSNzqzlgnD1 z0CU#n?c?Hae&>PAz7LuWxZJeS_%1clEzG{)>D=P;W`Fp>q8+{@KO{}*1O=zhTQXCb z`jZ;)Wo$E1QeNAz(bwER-(Qq0z2txLx;5Fpz}Gx_a$v}1|BbjIEog`@#xz$L!V4v2 zCC3Y0c-paq_n9&8lz^9-dvBZO<+Gey22Rlf-zfJkQhK9V(SlU5*e6#qYWmori$4v| zRG>hnuvUi_hgK_Gpltrqi+wk5{Or5AsBD3<1&UM;U2?c(Sb_2d!arSdw^FykMJf&M z)2nysJYCzCiAh?haK#}LHuY(KWNDi=OOG_~vuVPRiiHa$jVaT%Yo1!urp;?qWN^1i zoG@OKDE&k2YG9L=ue^2&_A1x3&wMTJL|wY1Ui-X9mTJcA_ufWM>6Hl_%*Xd<^ZLC< zo1{y0HzKRX!NU;OYl{v1YI|?ER_w2MEJT^@qqT?n0pG73V_s>MQfpOIxfsW{JH~c) zzN{aVt@j;Yr#IuQ9^SzgT++$LgvZ3*u^bOeNLJKg6rb<-Ofb zuiFD+j%h_%^jcaK+6Eqk&gwnq%88ZtVK>fylkrY@zU~t^A@dqtyv^AAQkS7S8-34a zb7E9+VC_1CJ}Fx|OQ~F0Dle_ku78Ei-_ zi*WCIp|QsZ;occ8PtB%KUh@s*%K@4lcaZigYgd<3sQpJ(##z4IvljSD&7WsvkA7eE zRr^(knBCuTR(3f5cQHHk?!G9ncg}wOa zk5QVR)!V@d%Lj3-jowFVv&4R@%G+~!?=awbI4^a4$ddv8G;!A1x1+}UF6%LYG`>%I za;mz%-LpyKy!bTkQypW|cqm_78ujwO|E}$deM&ZvUK*md{Oj}@-b+WV)aDIpMf?l= z$R=L!rh_D!-jS5+8{*rp)q0^m@%8WbocAUF96f;A@v9yg9ZLIGCOuNcyv?L_e)Onj zOcV`^mhiGbrxUs09U6Nd1_`{N+j|)yZD>Tu2JGE(#DZwxqsPwf>pz%?f3Ko)tgnyK-RClu zb?8dj()Xu7^N;?<4E4-l27V6Idnk?1@FiY~XzXer0cK--ElxPus) z6;j}Rlt&Zv#5Y)qBe;&2WNt!a1=AVz@Hqy9bQtS#j7_8{YT+KdpIvSiu=0X=tZ7({ zU7-ELiX!`1N1NBY*dL=Az5?;t#Alxa@i{koLTT8#)*V*KoY|IA;nP#tXs%Xe9K3NSnotx@e#UU z1m{WTZJ+6|_QMOabegoV-s?-Y2I_liw9d(O)DbP2(wR zqa%iZW5$%5a1ysTYwAkd2kA?F9{0t2R!-7z$dKj(R77)p4bqZkAAZ17k+e2KQ5d8< z?L@p5NtX!OLHVWI58_Qnyy=NIJ@KX|-t@$qo_Ny}Z+hlSzXD8~{zouv1_wD%5$!Pw zD{vh5#rs6EWF*3jly^qTJ0s2xzih^w;QwI#jENsLH zMB)u!4o!mhP#T}%ON_=s?7;VUAd*EP4f3H9TA(+WHVe~cA$?R?$x8aN=0Fkjz&KDJ zv+f4TLDec3)GLiq(3j|&rACAQa`+(&kr)9 z5UQgsNXrM)LH+pPC~m+fk}oClpaPnr7sg{Awt>2lkM!l&K%LBA2sOd>mj8uF0k*9I z3@?xyB~TTuaZ%($_6Z*{?8Ca)EmAN)%Ag_0!$OTg85SA`%CHdA6rv0ZJr*f!fcXnE zf8l*1MGAwo7iC*4+71IT1M6`NH$j}mD3@ZCOR-PT0OVuwQDC~_Ojn%giZfjarYpg8 zB|Zkzm6!^~eUuhC@ilHETBKxhuzn@Wfi#yS%_T{5$t57oB}sEh()_W3^!N}}(Hi|Q z6>D%9KjVc+sRYP^5~zz#7=}6c-8;wwX)8_IN-x4rT);z-GL%Ib(piT5EYk;*u?h!q z6--k$Jz9eCWf@;i2X(R>b*~)z)^e@T7gIo4m7{)^i{W^i?V~))E>E2+-w53?7K^b9 zm+^~81(sid`sfAfSjA=7iz{HB zO3YJ<=_)Z@C8n!Ho>U@FDzhJ|OnFqEh!xlm=Bdm)RhXwrMifGIk*bAJ1MM&n(?qJV z&eh6-^{K}8R=pd>U=cQn)W`+K*I;}N*0;tKti~Z+!*h|E!N?4ztI4*=JH@2tNX*Ap zoWUKChqY3GIBPL&E#j<2oVCVcF?Qi19*NY}(HAVQPI7F#aa0PE0#{A+Op*F{<~t|jSdc|)XCZj?h~^bl#C2ETFM7X;R^ zO)=C)M-0JiP@ZiSQh|A>2GVY@Nc(&s?H!o6LvN9eiP0IXcgMLP4IL@Nj(0#Aeoh&F zo)zTb=U;*S;OFcII}u-})c61uL7sJ@Zg!$>cA{=}+Aq>MB}jW`(%zZ0cP1U3$6*O} zYCY5Rh-bI4CF&k z%B<&l90PT(=TnhhE%-#70haw0Y5AJ8e4PtrF$pV0dXtyES-(Ei-#+AJp9rv@>zf!k zP!jdg1=RPx^FST!7Y^35AM4qlyzWmK^!JGjAdd&+K?O8LFN_x%SP0e876ULH>u?k| z@JeJ*2(qCh>Z1!rU>>&NG#-l#W}OD7MSfI9OHfvWDXYP(%ix{3fQKSOG%)QDrX9kx zLk{B-ZsUc>&;-Z=mND#okl(`|hzwUqf^S4du#Jyk*htEK+5c{_GADC4ozxpCBiapdQ?!l(f@j&TDq16x5I z`=*}Ac%~aq9*n;Q%4h=dOd!7|kY5wXuLoQ?4Sk}ZkxGXZM6LyJAE{aj` zi%dB#GBpLhz(~x;cAUdKFwL}7$cq7(iZvqBQ-k=XpTJGL<`gjz$fp?}qXCF>264_H z&KblxgE(gp=M3VUNt`o@bLJ;#hOaOI%drnX;Hk(g6B$qt)zA+sz`D)4A~Ksio6Y*n zW}RoV&a=ti*{t*IyYPv8n-UGt4XpFGl))UffjO-~+0Si+v|L`*ZJeH1nfKJUlLhR8$zgx*LKBCk#*Uz5#-7GJlKloA{$C$4oKg|jG$aMQdc*n0Oh=CugGS~Y;zCzMYep7 z-$b@jPFq>#R@P_hDv@mk@KR*^c#-cI_uWVw7uit^ks>>pZ|7@~U5P+ic72Qn_ySD3 zYd*H)9PWwiw!w6}KS4W;#BvbwK(LQ%??4PY!R!DMaC& z$dP0q-;a=vBb_l8D?wV1kl#mjkcUS}>(P2(nxjl}j66QZJjdFhKc-u_qJ$Z5*_G`}gQ*=|l#erMQj z&V+&O<_vZ9OnVH%Ol$yUcZT|T=68{^i9z0+C2!7>H)p$m^*%R3R^*4a7y!!P$K+^&p7;hALHs}Q8}d^Lke@%@19j>u!>(2Vaa`+# z@i+kT;pfU|i9?{yTrY*OxG!>}Eau^j$j$y>8;WR+XCjfz7s)nqD>2yrMCHU+BDX2q z+e~}=w8)))puF$a#wC$^#CLC<$o(WB-UsC81Iqs)>+tZd$fMFA&c`2ud4Hk){W4VK z3DZ3xy-$hfSt3wgzm637jr{uUYh1=JBF|ap=hUGWxey`pvJS|vm*m4s^5GTBd=)A3 zx+xxu{5}agMcz>6Z{NozuwA`th*=`hX)qh4DS9_pr|6%-ywTBI(n^5z$c+-H3gV8T z++tX64EY?xGJTYrk2rkn>--inf@S$n;+7~L`co=7HsPHpjYr+Jj+hH>HP>E>(g%q$ z5(v*U;uCy^&%xt&<^(LnW>J=oQ+OoGrbX2*fI9dB1Mw}k<07I&agT>`dVqUdoYidQSWGHQu)b){1rg_v@far8zju{}%qPuVgH$@e*kQ%IaG1j{{ z^A~6S;*@RiHuwq)uoID@O0et_(Dw_c%@EMlkS5f6C=W=Z0<#vEPDo>o{ zse7~)sq(B-dFoLG=Bq$jD{L3_i9$wvfF|gV)i@%mViJ@=4{XFCJQGz(198$0q$<@# zTg=00_(fID4f3S&5^Te9QB|1!zm2l0asWT!0p5tJN*b$90%@yCnODn>FTlE0yCF+7oyCJYf58PkFTOhw0dWlei7aphIdT?TWVEsR@i!K<2Mc9oiApbfAAqz_4Q;VB|H(;%|RxVKz($@ z7%Ty4>Yf~=y?Z${2Jv+NPE-%Jy&j~i$4OB=8P;r`pg4q>pM_Xzr3Koe=$)5KEe;UCu$&R8Mp{Bq6V@3 z4<_FR6VG7MHJJ4ud|%X%N@xM<>5$R*mCNg-b13N?dRo-54x)xNfl z2+}v=TU-}4G9x}f8Pr8<^Z@aUoQfsb0^%Hb8|2xjCSY41bxYJ}_5q`7fV7OBiL;`{ zG(Ww&6+7MNQ6)w%9FdN)~*Kj+l+(Al*}w;1jT%siVL?bn1Cg)5>8Gh;P~p zQPUG4A1I$0EOSO-%)l#AGgW|6L0cSX%+S+mLi*@xj5^=)>LAK#L;Z%Nx6 z6GK6M&t?BIm-;y`FUYI;#54aRR6`q43mCtkkEn&ou~pQf5PS{NyNKx*ZNPp}i&=-o zl<(rfAZ<%n#uB=hycD%GAt>{uH$^QgfjOd<2Z6F!P90ia3dFtqnWz=y+X|*x(H<;k z1!c2h8TNv>Rub1r@?d2_>=3m|Ar+e81b!E_nz&apj`kC^hP13g7OcZI@^o8eG(;!Fh}xbSAE2hF?*ute1dT8bOK@J)juE&cYUdjKB5GG{ zP?oz`-mW9~0T1v_)b7kEjw&FY-PGmXOuL))+r1jQa1GBz?O~nvuugjlpbnUJ&rVT$ z$=|)CaqoSQxBDpnefvc1r#|e@f^ujK>d1cbXumg(X%Dci9T*1EdEg>Q^8wcLAmw-P zJ5h(~f^F__PLLOeSBN^|Vv?w%;h?@8y&&osd3>xaCV;#=c2m^xU=&6xF#JSfY`_^& zCkJ5xSjNc+QKu}_z)4Z3DcjQzMV!stMK3#eYpQy_$^Ku7F1o?cKv|ORwuTZzIv<7Ls zas|&s{g4LzME#fu^YKp9PX*(&C)|T4qOJ;(A~#B+CP>dU(sr#0z5w<98u@*V{QfyJ zmW#U1x?V4jxnTH>3fP3#qHYrZO{Td?x+BQ$3Y<8$1Lk{cPz#wQNN@FM#4nWh6Ai^NLDW;0{gnK8dP>wY z%JtVeU>#{YQNOXA=Y2)J2m#Z)VEta4#Vb)Si(nA8iF#EA^F+N)iYgc>>UY-Xcb4~t z^?matSngZ$$b@LCzG5mn1qH&j@ z_gPj=5vt9iX&<8jzQ9P##a5ib9ntjUsEWQgE}BsrQ*co*1yTNk(18P_k@9=N_)q7pA9T`&V^dHih`ZG5yk)~k8tbwqT(^c`th6S1fV-W(E~ zMbU01VsDlT|4T~z=kfZ^+ME6FqabWt$MJtZo_{@maFA5^GdsUT{*}v8!%Zyx;)gk@ zWWKW#Uii;V_Qu)IB+~tvc?th9|N0OmksfQ&zwP{gFfH-=kNkt_`N{!l`c=LlolW=< zW0#;3(%HQxpSwTMZAcedDmyzh< zR)5Q`L0$R>oM{qjQ%?5m*zs0D>dfEqFZg3v1u0||^sje*qiiDnT2RKn?sp1P=Kqcw z{`3EGx+e168y|Fl__|Xs|7_*o^#6I>f4_Uq-!02q-k-%UW1atz^Va{Es|D?Yllq?o zP4b@!%I$v^RMr2~ZR>v$h-ayE`m6Zj<_+4xe#jyJylkzX*Y6DG5$hg|PtEv$ zM@EUVFEDK}$zUg!iT|;M%=;e8{#T~{^>`-(-UmhrcoV*Xcu{+$UrD8>F6 zyRKCKheka%wS!H=m+JD49=RceNzl{IrpW_S4B!mCVx+Nq2Gyc>6+4TQ1{z&VVe>L$%S|{a1 zAoRLDAImU~Xop!>P)0HZ`n_TQO#FJBWO)x`mucUVk@l*8y!_y6G9q4rdom&@e!5;P z!_y}&jPL&+3H=KLasP|BjN~f6kw{AMN}lrZ-ELngMgDl@G%^r+-T%yd@-e_a*;0-> zei+>&u#$d5JK~R_n_<2)giB+m3B2x;2<~0d?@tw){>1pU3^TyZxK~Zdu;){_Ov789_hDhy+WC%iG4c z$>~7ob^pZ(ySj|viv9@apv*UlQEmbLsf5LiaZ+5LDoyqIa`Z3b40uHtz z8QyaK#40W||IAG$wf`$tV)iG0wu1inczz|m(o*k_{`lelL1?@Dj>iealSEcpo*-Va zSOMGP`s0WHDYP@nCOajsKWs?(aE|RAuUU;{8ed9#Wv7yZ&Rm)4?BzJ8tIT$yrK&U6 zf6DpEf0WjdlEJS3RL~0liJ+nW^FbT@=iE2`lR+W=lWvp$dYV9d#L51)r?bL;&kOBF zlHV@MZ(CzDxnly27D`JLm`P8>74oHGQgpn!B< zC~tVquwT$ci4IOJ(asy`=MLeRv4TV;NFucpv}XQ(GC#O7VHa7z@nd9y%(9N|`MkRI zyqiXr+pA?5b#1BrL@KxmIQQtzwz^$Tkfss;H&|2Ug!!0y(ntnbDOk3ryi5p$%{aa( zFMXW~GUShO@zeY%G=G)>|7w5wKQgX~%+j2`As5f_e*4P_p~7GO60nFw#=EsoP4 zLbw9M(Fq;!g*99nP={CBjY)SS&PV^?A8Lijc=COPIaG>K7AO8EI6b-f){+TMXMUer z$j1Lo?AJS4Q#eLpA8sZEzr$uye-F-o3b+-eoyQz6U95T3u{NwuwP}~3uAK79oKjzu#{4$5kqFjL zZrdk^JfvY4<7QhILRXi|=5gslt8o{)yU;z_ z>-Rz7AsbwZ$^kI3!bf5_&4@J@wE1+G^Qs^pwqdPT8bqkfY9X>XpOsB;9}T#$}Y1^n1fk z=;>tLALG)pyu<-(`7PaIm6Nr09hLNNyhp#sLP{iv3z?36e;v6&|9?D>l~PWciTp9v zY1z*Hs-am-I+}ZAi^py#-vlL=dmgXbIxk(RdtJE$v#V2%efeb>WQz}awB%O_dQq`C% zql}NGI$>?{yt;N$KICcj+Qwk%>&2;__%HLR@V@E+aZl%Xj%65MGW{g!huV5D{f<=A z&PqPxE4qtIZYz_#Z)B9BUbm5!bp0Z2_1-LRF~^n1q>B+jy<8;yc{@OBUVmD{I6(hi z>7d<|?I?+}$YxxZRRKTCq5of{wU>NKooj3qmX1a(X<@Wydn?F#SCRVq9nL>%O9Q$a z>bkG8@o!FRbmX|&(?Qm0OJoU({NI>2D4R46>L!idErbK51?L=HtoNlpF9|9iR9YGb zYwVK}NDDW;JPuwfk3&8nTq;$AcS`l(&1ff8gPu!O^J^_T`Ou9o$gk4t%NwhTs^KwP z$rW>uw=Kvdvx!_HJdcao1RALA{=G^&sJ1ap@xxb4}#UPa^NaMBeVh zX$g9JS)8{%JeCB}8N_^hk>S5FO)5zk?Ml$Q#$v|ok&t&?CBZxH$A0@TFug>lWY~U5 z@U{ZeG-SR!48Nc~6!ECLT!PBR`9f;mijbZC-=Nt2puUFj`x(!j>fZj5{Yqe3c`kv! z1SZFXuVhW|SF$G1rW|*iY^RlzL4#Dvko_uU2EzDm!sZNX&ame3-9dx28ps}}hBr^L zQnB-TA=7QdW>11eDkVbb{f$)2D>GCqjkg1_reEv4f3PeF;5 z^S6R$%Qt^zzq|!SUg--it%85O2tdqT%IIs&hUJ+ z5e$;=f_^0rVx^7`-Bv1fa0RA;bx@vr5>=EiwB|m1N;0}*T)#K${{^kRb^m{XcO2)P zd&Qs2h0vB{KjZi=jr_{7+_IpR^m7hK_act{=JWfwEiirtWHQG|DO22G^1wTEieg2lF%oEH=_@L3^c(LSJ#b#`T_^F@*}G;z z8-L*3-MiN1wEmXx1TlZ$v0*K=|TS;vESYl>+Kc7TcW)Sa#KiC z?>xZboFX8E>t~6iu$7ejPOBV_Gq!k$o7M99w7U|mC**i8Sh(Tq-2=i6?BlbMhCLil z4be?0$!|q%J-y7;YRYuxYtM1gDcT5zu-sYnhm(%CEdK)i7fA0Vy1n0v#~zk@l<;R< zrQP^iNH?kJTC&hNBkfq8;ymOl?>xl}k+zggFYB%HnD#5 zz0ey*MksGy%J?YraKB14Y4%k&>Pjcp{frlS=YsJhB)YP9j^(A9@{FPmF7iT-MNg3a zS;RR(J1FbOn>73uhuO(wh8ZNKG)E>=uP*QkpGlZV-expHWPw>#85AnF^xD#rd^kb=Ev8+ffgVJe263(w*Y-)?v~^p{Ha9tGxhZ{|CNhxm z+*l{my>)d~kT*x9E@f4i-{%jA_l4I^?3It*goo`CsANV`xu8$;M{^E7z$-KFoSby6 z;9O>j@kq{aC(uTF8Rr8zWW85s^)$36G~|Kl`_d^+E5cUHQ{9tz7VSt?*iN|48#uS* z-UqH{1hyfD2d<|lT*Nf1*f+2}dDnTl9vPXij8r$Du-^!h!MY=jo#eE;{OQ_+(@eUt zJ*@M#(crC8)2SzACFo5J>fZqGe3WC8dDItEmU%%%G#%h`*olznEeEJ)Mpzr{8&i?Z(M9hW(cr9z^2M48-sUC*+!Y=k#-Zh9RUK@o)y8>xD zVK$UTT6Nl+>&Zy!5dh`aeA+Q}Z;vA@vD$lm`4fE6^O;hoGTsW09 ztuT|xPLsAq*2g>k+l!NQ&&D{MLuZDOf8JkoOZt7W)3AS&ciuLB+ACj1_M(3z{p$(6 zczC9geV0TNhvyy70KV&s-t>EMcEkv<4|~@(w)^co#$}>=H#%WHzcsHZr#>7LjK?5nf$qdu;Auy31bsAmi3C%|vfsjmh5jOB$#04B}ec^7hqU z+oRX6;_bt|{e@TmSY|uo8OXG0!G8B0>-o-G$4T^WBrHS7I=&r?yQDiK$Iy&_Qi-bRb(6PUSxe++vm0$gmh84F%Q!8+^sp+*9DW1Cv^Mg!*RR!;Wz_pQ zfi}(hw8?!YecWc=vFzJ~)Ccc8i2C_9DtMio)b_C5g-S;2xb*d|nFY0zGwdHXv5y)+ zothfhm!^^5+~%@@;TsZklppmvGK%@rlCM_6th8Y@lutx|cgWj%i2GJ-7)}41g8}-x zow38-+HvEkTW=P4$n%xnzSnDa_Sn0nn$bt)@_s|uKdfcjh>?WvPCB#`dihU2tO?F2 zJFL&7jI&L)d+nDVFJ7-b)BCj*#bM2g80uPvL9ZiTyhz{C8}jEr_ct-@JnDs*oQY+# zW;5<$Y^YzA{W|@cm^wo>F(O3s^P|d^9UDvSI@RjPehKsYW0w)MLR-P8`Cj~%I9?DY z3HWcoiz7>Hck;>fdFjYP7$ic!k(_j^)(o^ModK$%I;1_-{CY;crru0%p?BB6(g*5e z^l|z^J<^zK_|3c_A7?0=p;Crw8ER&zo#E3AjWV>!Fek%;47)PC3{4c8A~Zv2_Rx<* z%ZFAAZ4mldXrs_3q5VTghOQ6Y5_&lFV(7Kdr=c%GePO|2*~6NJeHqp}Y+%^vu<>DY z!uEyT2)iHlEL`z6V=LU{?Z&CXGlypj&lR30Jb!rc@M_^T!s~?h4IdRgD|}V>#_+A- zyTZ?hUkJYxelz?|_`UFlnY2t+rbL;NW=fkWW2Ri0DrRb)sZC}*vza+{=IohsWge7y zWahD%&t=J+rG1ujSsrFxlyz0s%~`i+-IMh|)?-;uWj&YmZq}#SB%773M79>$+GJah zJz4ft+3RO-oqbgHr`g};vT`NMl|FaB+ymeD=SjV6{&N4yJP{HR9FZ%cazu@Y#u3dU zIz)7g7!)x*Vspfvh&vH?BhyFbj4T}aQDoD|)=$h=hkWn*_<|j|A0`V_WpzY*ri&h~ z*V3EoE%hFHAAK0PJYHX{KQk7@RZhz?C0nmRNrG)HKu(2Aio z<8rxK=%CP1p<Cg&qyP5_&!Kx6oH%5|%hDZ&=r`9$|gL28E3Yn;5nr>{=j~gC(#aW~?5WX8x`kwqd)MmBq*$mOuOTqdJw_jnWd&$e#zd+GhfT#XHRDt*?g zGxyoM-{;rk{%{OM-urp(XMe=+(VgOvZSEB0dH*N3LhqEgHTKq+Tcdg6e-p;U$8dWu z&;0Y7?)}~2ci_(W*lyyvc>naBmUq}9@65he!5eyO_nkg>df#k#yYHQ(_q*I-$o(>m zO@5~u|BKzecKgQd+jp|uNq@(Wy>;kT?OR16i{0GpEg}9d(!JR&GDXDgh^UC=5yK(|M-;xf^XB%OV{Q(;xj2%u zgKM|1ox1kmr@@!MzINt^Os(>?IM;k(wp(G-!mfwE5Xm$dZ!)`C)@OMplJ)bfowBmt zyf;u~GqZ(yU5T~?ZwUI8r}$fIVR~1iCHtH$7Sq~QJ$7yO`C_-V$2pG6IPZ_MO9@IJ zhu`nmKW~Wlm!L#!82g`l$|b=}@REguX#;QbB>xiDO*kasEke)3gujIZg;b26Dy5;?a91b}f+eOY!GV__8%__zdtFifk*~u(!mN3_v zQ_a3+Ewi6l%?vRUnTgG$W->FmnbJ&UrZ&@<1I;1kcyqEj!7ON&<@cksRN^e8nT(ci zWT6~UF5e+Ztum`zs+8)b`l|tIteT~ksw3(+Uv{{xelb5XYnmUMYs{5aTlKs4u~tE= zt+m#MYh$#j+H&oXc1pXbJ=0$6wq9H>t(VtZvHu#XFVR=%8})7a8MCxm-7I6ywkDee zjHkx0W>w>vS;O3KwKq4IJI!(C3bVGk-s)`bFlU-~%)3@=eWbb99BK75Z=1W!Z>+jj zZL6MHgx^Ms-z9@zD~FFfRgvb>LRxaRbCNTT(_D=jt8(*Il?ps$T}G8Qr>nAjaASp9 z$wOj4=s)S*w1n!7rnFR=qb1YQYn8QjT6?X7)<#>eZO}GqD~+t$?|K3~p&p{Ux?|4J zgY?n-){SLPywk|0Z&eSj`QnlpV~vUI?|fAShUeT zm$q6bX{U9T_F5O2sC~mZ#dw*lO^_+tMDAyrFDtcqoX@T1I>9D6r)`$=+E)2qTVYSu z4yqvSs7j=raVo1c+BKC{`&p&auG_P;t13*pq~6zjeA3LX^77+dNPDX)>WNe(J+Z2+ zCs951qH3UCO%2kktHF8=HBN7wVR?dOv%$-bbz0hpRREIJH*) zMy=DwtM&Q>wLzb#PUx%EkNPh4lX{|GP*3%X>Y0AYX`q$j9C3hFnr}{@mfBKTt0;}7 zo;_Tv&(-5T_6pj3#u&fJ1pS>hTYiw^Dy4Q>yX@3*TB(9sjOwP!>B06qr<3ZVe`Ie` zGxRU*F;05Dr&{RjP>a+ueT4qn-l(qV->H}SS$mv4-pTA_F_V}noKsGI_pZ~=>F*41 zLZzwJ+@7g2X(#P()yKLeAsVll(YC27dQu+Eo1`}BlhtN@irS)2Ra^CG+E7WPxsq5* zz;&4nT(29)HMmJCv36Ef)sv}edUE?4CtN#b50NHXQ<`6|fc0{{i&(_O0`J4h;Gp(i8!a3_~ z*T2?AJ6WAuPLz{Po1@J&&pRc|3+7d4k(1qCX5KU-&E94oJKFr*e(QYWjCBe+h3)h9 z1^c3X&gy7&cj`DfoRv;4_qNl*DQ54tU)nF6@y6~zmId`19&OPV8V>+K&eXOso!PZc#pYxS9z#3-tw+30m zt=`rUr=C;S>TBP&2HG+9EBl@O+9_@ybT&Dg?K9R0XM(fE+30-al;r!8-&o_Van>kn zq*LFHvd`N0tkKpOJHndiw6rET7o0=(Rr@DrE#EkuXy0Y)UT^H`VG}rzsaXjBlQS%NsrWT z$yF6)S`ahkJv};V}@%4*~g7wBcYMh zNMMlvd05UO!}9 z(6<)*al`o8h%#<-I5tQdtj%!p8u#_C+J5bTcEX4>ZfVn?5R!#d!1g)Uf_IT zFLb)vi<}daN?HJAw_d~a!TS%2K_Pd3Rn>@l_LXR)fl1v)og0&K_((RoRV`+EdA=rRBB9 z>1a_6l~LLdBivbPuXL8#yWQXHo$ga3lkr#;bIPgWP8s#3UPN_wI;tMd=WY?VsD45} zqMy`{>ZkN$_8|L-Tg)wPZ+7qNK4Yi4Xk2tlxF4A(&C})?^P+j#{L%c`ylzHYrsY_! z+tcmke&v2G$+eWc>bImgrgJ*BJGOU7!WWV$v*mTC)SnYKulb0@|kZJMm$O7sbBjU3ZfsU+Gtl~g;gl4;+o z^x6%TLA%K(MI%(G7RggTFH~0TrOKwgQu%aE<=1t-HDIU@byL;V)2VuTdR1S~pc?S$ z&ad>6>TCUDHB7IqhU<0I2)(Wv$<^OU`sZr0-bqc-JFBUB7d1`)Le1rKr1SKFYQ8>5 zEzk$6@AR2!hdxW~)Mu+*`nPJgK1UtZm#Jg=a&=N)qfY5-)oFdDI-{>sXZ7{!yuL|& zuWwe@^gZfV{j&N^zoMS&KWIz!A^HY=ua;CBsf}=ZtC?;eNudQxO4{2~X_}BE;-~q$tfFUlWdkPvQ=n_k?)MI#+ODnqr1_==xOva zzT%rNKg)HwAvYz0miJrgo-yB8U@SBi8H&}XV!aWNwbt$$SiDr=q@(*nT^cG z?hP!gYpNM#esBJ2{$@Tmf8iU?SNPIQ z1}meL+Dc=-Fn_l+E7;v)zB7HMpGJP(0%uv4?PN5Sshhz@v}qZB<9E|F-kG-6%6MtK z<(t!{yVZDOiVVuZ-vJccx>!FoVp5?hbdSZP~Wf!fI|UG&iZyYLvOf z+-h!iZ&~%N&#XpPL+ewkfiufl;LLNTJ5!x+o!QPLXQng9ne5DWrZ_X4Y0g}yn{(dT z<>YkUb5=X6oa9bhXPcAKIcKe~c3Nw#UDh^hx3$UI%(?PbE86l~@62QRHS>@;&HB}P zZT)V&wcc3gtxMJg>q@{m>!S6&bvfXs6=@!~ezI0t>#Xh88f&AqLtCqD(ROM3w8MG@ zy{cYc|J2-V?lGU5&&-$REAzGa#(ZlTR*;pzN@%6E(m6NX&CYG7k<-j+>@;^5x#Qhw z?nHNnJK3G(&T|*oK6{_D&vESs_Cx!Tec4e?QYVp<(5dYlbecN5onS|tG4`)ckP~9x zw|}`a(BmRW_+YsrN1gfMF?t_9IN5zG95FncW1 zJA2Q8_h|hoPM9;6Nh>{DU4Y3I3bwxhldC3di2-IU!aT9eeuQ~rnY7l3w~YM68A4b9 zv=VVWL)b5PSFeS(c#&eUOj?q}TQ~@34|ZV;w^|GrDAa& zFPz86;`B$##PNoC<2;p6E>2y-@^P9G(&iB0wdH&g=Nm$=6~(g<-u#{;@LDCHTtYA3 zp}h6=+AW|`6MA_8<&{tMSjx-S8nINV|Bth`4wu^M!oIV!lk6l=>R#p?C{m@9nH&z( zfiqKgceIqEMe6Qe>fTa!H|o^Alp6Iy-QD%OSMC+y>Ggi^A8&j8xF?xp>)Jb8a%Mrd zBxYZzlr)YiTN73 z3kju+#XdkNY1xg0QpQ+m62cpyQbr)W4my}bdqRg0BQ`sf;FlUa*>M$oDc9k|N8Y>< z#Fuk&Kk$!%%ANr5PlWD8e6hVz#J>i*H}M~Xjwb$d&@se+1v-}a-$BO_>?wQRcw!xN z0>PfX=j}tR*vUj<8_-F_N{YPqo7h}z+MU6kJxLW`xAQ`^Z;V-h8{@l3($i| zDDfUlLa`C4dk~8K9!kOspwkH6$)zpA9`R1I=Uq;MZ=qL^;CrZ~34~&gSCK%<h*=H#GI$kjxdDBR7_p7li4psmO~P}aZxAyG`X+b>_o9w5#w### z?R&(^_wN%s5juzX3qj`+^E>nd;v49P#9taJWeuWmIz7q0ATP`td=Rky=^3BM)7!u%3jTlw zL>wTRkYJq@Kg&ys=Y+#&fVETn>@F#u8OV=3Tl`I-NVg!*5F>E|tg+&!afv)njKl%3 z?pi#Dif7UG_`ERjcZ5n>fjbH+=>S-t#ZTN4>8B0qPw@AKE=UpLDWe;gF{A$U`vT?y8O@pHGtJ003WuvYAuR)RN|JkyO} z{n#_ziFYP+MS?YD&-5VDFB;g8;9m+wy$aIDncf6z&-nRU;=KS}nP5HI!#YZVv|D+8 z3s|G}q&nl4mS(o_eGt7F#oey1~ z_-{c6C{iZ_i7)-sAVtcfgZOAq(tj05*))ldwq-U@q?|S+7FA;;4}#=%6XO2?mG&UW zwVM&VkAa_7RW^cdLF^*XEfooCD`FRgZmmp#ZbPh;*|y4I(Cvtoc%&@}hl3r6l{lqc z2}gjPh?V$vR!)ZQLhPQL~9Z#&} zWrA`GbRS|RFB6qpp_7Q63Z1Mx1C_b~R?@bw@&<-~) zJV`OWKMtQkF@HE7p8@ucJaYn(vA8*r$ar7o0D_b7qz-^DwkYKeu-k;6=Oi-5G*Z66 zT?>^m1y15Qop=(q^fdtcQ~2pk;>mqy5&I|fY=Ye@{M06qv4%O9$hgLwrz{0MpZJnr zxd-^KK`$iu+eG}tCc*pC_?bC z66dYNO8wtP?5|Ks2XJ!k4&`;|oy1Ou-lhBuy_?t|7PC2=?Ccf^-E|DIr<7(XFNWc|SWNU(Q| zpO7S;l+Vuu`^oqjNh0$_BkcjiuR?!Qs?gsF_MP!_lEjlT{F7iW8b2#ZWK9Sw^#y(f z`j0Xk`Y*Ao(w@YoyhZUD;TAY3P72;)P^+Nsup>|)>k!x(AmgF1SHasGD)$Q$Kuo+> zph#;0+N+-unalZ|6tr=v`=b6ghb~0ylF%+h#xmGrFR)8NQLn|E17$9A3Bhg&m2?An z7Rek;uvwl1aMmQAq-8DQNjle7Hh``}WZvViOXRsHYi)uZ0bQTi zm!Jc{K!pDebP$pCPV5pEn8%@V4DkMh=QoLW094`=_5>S}V$Qh{J_`?ljfs`;HX&Bx zLH|f_fQ^$4pvTr4k5AF z(NN;1LWhyyF6eOL-hfKo2p0k=(~;mju$OW(bQB53K=&r@Oz3Du%0a#pB+RkOZ0I-= zOa8`#3E&5?kMccqqVgA1%1-zlOeXFk=oDhbX7?rTVyM_Iz~A0@{{AFB2r9M$tk}qb zM7)$^N3_6786Hfm)WadfNN6|jv@H% zJT{FUP_#VF^yPhUs8V{&Y+i*xDLHSkv1W9BMb#nM#2yv z=>zeL&}&G15A<4M#dfY!q#mS9LEIC11Go{NKY-pu;$Bc`8@B+-)2$?Y33?le*M{Cs zLdn-1Bzy}hbpv9_hqOr$_lDk0oTOLM2g3KE(@7}hb}zUO=f!^RC+;uk10);`eUP|6 zpbwE)?B!vC-!Sw186<9lK0@$&o}R4l2u||y7PmyR6RO}DL z+d!Wo_|24JoGoK*{6?oI`yxWTD)f1hd<1=g#8O{UPas|sD)9j)>6J7A_cL@BaX&#N zeIPy+`U;7qt-MMSDZ|$Y{$9uPUnlY5(Agv&4t;|})KxSA`ZkHAoxVe&(a?8E zGy*DZ6~tmc5+8`AZNi22p1^61_NEqLcID~#n;vJ#ifq!ug?z0|=4ul#Kp?M$L0p$ifG?V@M9ka%0@ z!X!Efx(IP7J6T5)qQjtz5%)I~buYMIpi2<LA#RpPH2V1=o@S|&>i8d16`3sCqa9Fo=Ed4&|V~h4cp!%Iv%NH?lpuCUGA?rK~}G3v_LQzrponeMCqk z{p*tWap-y^-WR$)i6!g-#61EXNMf<)K_uP++CgFmZID>BN$}fio{S3x{JmH)hLCmB z_z!&ElDJvWt-u~gn-3jK;u+8(#Jvxda-4)~7KKg*QxMKxP)RqCb!{1c3;1oTV%<~L zJ`-t=2Z2NIS?ogE5(uU4#HNAlvB>_A5RHUN`vH-}Ben>OQ@6HoGV4v~4b%is_tTOM(oAJh>cvLybHaQm=Vxv%6rhuh^)QZD-@}( zD~YVt$rwwJwGex?G6#AMF|R_e1=j(&=6d2ues3VMUSh@WK_Kar_5gwg^kx!l3zhtX zU>sEH0|Zh(w~?SXRQjE}aDD*vZg3AiZw;Le?#1WP(ECU%<$pg3U^g-k6!05no_&zS zVlxksKy2z^Wi{vw@Cd>@9Qr7D0-uk8K1l+plcyAd+TRa1!)I5=gzxB=BtE$y`xzZ$oF1Sn?`;kd*yz;58D6UA#_W$;)g2TTZ0Rr9Oa@ z`jN0eEM@!-acDnQYz4%NK*c_Q6Pp#A0P&{KImBH9mHGy;*v@w(-U#|ViI0c=Kx7VS zembJ9JqhdyK(yij=|f zisW?#B73JnSK{3dm2v@Q0<;BmhYdUqT@grGN_q4o4)rK|@C8yfy%i~sl>pjMk-ycU zsJ9|rl7Ap;S3w5q0PQ%)6-h%Ik$Gp(S9t&`d6P1J2uR*QJP5j`B5ido; z?nq=V66^$a26MqKMCP@@t|XQ;?M4!*o85^O8`wj69Xgn}OQAzZjJ6yMCGL6XFl7#O zIB~~9M-cpOix=!k+&$2dB>4)u7je^}qe${KbZ_GBf{rH17tk@p-3=W}k}si>Cg7yp z#uNAs@d7C$;I4q~L*UQA3nnTKI*G{mE|{#W2%VxxdiEvmEa-j&zwhb=`xAFF^Z+7b zz2HFNra=!Pv83x@;x2<8Lh!r0o;mKg7b)zFwZB+H_!`+ycZ+S#e&D6 z7b%kWi8UZuPMy;|uCy+-*Idacp|y-xWY zdOdL{_dwzR_}wTkxRE$fi37xULB*bcy9KHwbMX0A;zVyF$z15|#Jmi>LwOZ?r_vcJ z;R@1@?pC@$?@``>PFEI&N?8kU0Hv7_Lti7ZClkC*yyKy> ziLA2*QU^jeAmt8XvAeg36PtRQ#8Uq65GVEUF2OH$c)@$bNu9k<5=s9Yr8`vYM)(SR zK+KxZ4+(w`)eAl%W-aK)BpwL;gqVGxpAwn-2A>fleX7_!i1&tmLCj>R*ayIG2{Ufk zMI!fpLyEDrq!kon@$c{%$XGiN`v5XF4M8@gCPsAM#{h2rk`xo#l;*vc6 zMx3;j-$^2A|AW|npnob-hJO(wb^Es>pXIuL5$4*MudYGF2D_|BLbSWCgGdN_?79I7 zk(aI;f{kzwY_RKQB%BG|f`kaa>y{*h?R4Fmgs_*c+mjHs(sc*0Bf^0#blru7sQ<1* zNr<#}9Y&%MIvk9^+&P9`L_(Bd*J&h#-FLm8gs_XQ4+E5AvMyA@lKf8byoyJ>C7_1* zOF@0&!A>fc_$Y%)Ks@=3bO=6dtP&AVK0D$!ph%zKp$sYsk^QPlN<8Gd(uv6aQUzr# zcqog?Ld4%0+J$&>&BDY-IaL-Ro?Npi@ppwTMm)J@apLa=m3#tk40K5%`#BZKGw{Yj zmnO2WQ(1<1srL016`XKDdTmBtY25wB}U3`J>vfaU7r{!%K^my z87g)UjFhX`I`DsiihTnkd2J9`Z>}_nk$i7JWZk*4Au&=0=qChOhpuc)jFiVFMAoA# zn-a4v6#a=H>$R25iP;Xi1(CJe%9g}z58aB$I&NiaVx)a-Lu5_2BH;qFA5_8uvQAl% z`+?aXD)#_cv#dxv1m27HcOzEf+?|-?pnDK2@ed~E zbm$ObM?;4ad9SWAj96*2!->3SR~bR9wAnq0ISV?HSZT9+5py`&w!w8{ZQ))XrT67vA`AR_CFm4k_S5_$-cwZMwx4Vb5(k{=-J zf)&XFFwa6I-9Xma;| zOL>TGfkf&}>J|6_RQh+|Een-;0)7a+o_NbaZy0_Ar>HzYyh+dpi7(~<5b>n_Jxt_1i^>e*O@Tf_jMVMN zMD|iDpAaMU{3(%rmC9$tNS%L9WRIot1u-+AUlKbF`V}!^3tto2d#QXw%%jk6iM<^9 z9g%l?E8i2@pQ-#njFgA;H9+<&DnAh;W%Dz!l7?T1k#hQ#$bLrUH)5pBekZcOQTYS> zi#`Ol+TsC+ISk6JB?b%Q#EQ^Gz>+w>33M6I73U9uwgS|NtUY@Drm>~ z3DACEJ)B3~wyX~tNPi#bhF~+;$w=syU!;`B!1XL%j39rPw122DSRFUeHuK2>!nQ4-?X5g@$QA9j}biF(}F%m zNKS#GpAiz|r)4IA4^pq?CGaxBlrnpTBxgd=XSB@5c`2tiz}pD(L+HCCk@AF13-H10 zwY*OfDVsUq1KfKyRPF)E1yHde*n+H&wus$)iqCTWX8>*0h@zbeM&kV%e2aT8g#G}2 z!u3+Fzkpxy`3~rB#EgahPRw}dAH+%+e-bNc`HNTy<8P9Pt^Pw2^a-uVYHKIV!IA#f zMM#XgYh9GY1E7nMa9il&B-{qN1XvRD{q3Mjkp%I!E=}TD&}G1~xCZsux*Q4jfi6#C z*lp_ypn_}0L0d=+J8wneTD#%=_0aAl+yS~G33rC}AhCqmlZ1ytdyx>f-rAeQ>qFNd z5$e6Q3Nqw81r9dSI*_>8&_N{J3EDv-^o^|z5=z)j5=wX*fDMthzR-=LAk%oVu7m&z8F9a9i z-UvF4z;}b!dKn1~^l}o)^-{hdl(?iELGTw;$_|7Q&ecTL=~}NLvi{e4EeR9obwt+u zTCXRegWf>=VNfY|AZv=PHxXGcY`q!Wg17?ctt7b%dK;1T#n#(N=t1uQD39Q8s9Xm^ zDYv`9Jt!;D=|t8mTkj>Ze%X2-k@e2j`w4!F+G~A)gfa9%BI}^750Nm1iv59bA?VxS z9mFMJzYE?&TadCs|0pC9_ID&f*xiusZYcX`C=}%`M5u>u$dC{Xf#QB48Vp653K8n2 z+u|hN3Az-CE`u&jLX=H6)UjYrfVL2GJhT;bLz+=u-MW(y<=1URl6(N|MXY>Y1+0pA zVF%s%kO+0&4Sj^*!KS;dMM9Kwx6Mej2NZ2kNKk&=wjq(|&Lr9%x(kUUFT0XR&h17b zxpsFF?Ff}{L4-EkP09tt=R>8uKs*gPl*E@nhmrUk=x`FB1sy@+3!!_G_yXui5~D44 z+l$2KK}V7JBIw>EJ_9g!d|m(0;qU2i`{;^r0Vt zkMSAi+U-*keF^=7L|@@mH(V!#k3!M@gzzaS+Mgir{d8ZLgpWfPCE;Vx#Yl)ecVCW# zPeLmsd<42G37>#&NJ5lLci68Gq8z)!KD&>==c$-7SQ32$MOuXDTj(+*`Wo7kMBhVu zf!?_0S195VqFaU}WydJ>6#guY0kKcKJ!A^P3(dZ14b;+vsJV-K_kC+ASd zJ#dYSp(sPab@#lU)4=7p_bn*uSqRaNd!p`z2zAyIbuC1wtDXo)h+c-?L!#HAsB57( zk2>!u^)BaOPeO#ad%}i<=oRP;63v35tcB<`DC$Wl&Od?Apg51Z=?Nm(N6)87G#C0T z37>{ON1`{O5}%aG8_-WkxFhsi;sVb@@fSoNK~Yz|B3%C&bZruS2pvMAkD((-gz$Qy z+=OVh=k;EkL@z*>CK2kV_x2>3107DHPoal{<5@4gkscvI+I#;@BDA^QzmN#^*c96gHAYcF0?aP6`wzct_C*7 zT1_i-6C!_OF=tcaqc54W8S!C5b2cab!_X~=5Br$2CD;|=NL;%S+l1~;tc14*v8abR zgNcO=%t4*b8HN5~XXxIW#{rDW%TC@uE6B3Pw z{z&5Yp+AuX_s@~ILHrREHY3ElKv9hxcdoH6g`dA#I&d^RMx1PU7vl4(#&9Kx83auM9aP}rgn%V*fE z5GPOvpzd=h%1&^vK|7IH;z2nIF>HM9LL^=o+J(e&PL4smHFOaIU-jPHMG1W5cyr}` z5TkzQE>7T|+nc)ti9P6&Bt8+k6mj1}mnN~K5p6|?CH`fJLw_;Zu3m!vOOo$A z^ZgnHUU(a{3N}J|20`J^1$g0f=;#73yvH-YZCT(&*F(21@S*vl3d|t@3Vo!+Icky!n8~wb9m)h?7c~P&kT}?kPHvc@T zU@6;==Z*1Ju$${=-&@n}t)H#ev{&lqz+29Kp`SxG|bvmM!u-$QzHh2M)l_#wc&3w-UC3~>y{w^MN4*7!UUpT>AYaAzIiRK3jWkCn--y=}bBz*-C3yE@Lyzju3l zme@ui=5a{jf_F1*t0S}_UK>KJ!um$w-=Y7%p)FVz+u^EK+&K>E{NGXf%BmHyNIPemp)&i(=`w;@pVh-n|e8+tmLp)cK_(AxM2sD#~zD zQGZga2O>NPe*n(24Ua2QB_%BB8nHm#?pdT`;sR%+9QR+~cwCX{5h%;?s2>S=b>vE{ zR>BiYnO|#@ab9Xcaz6j;{4$W#mDl{AQnx`7zO)*#yWvIJO52#^ZHKxRoBZDtjQXFr zBvoP+d!qKmvL++7QsNVenBf5%-;eMH;Ty3Vv9=+&uGQP%KcP%0>TR1M?;~-=2!u1K z2uVUX09Oz3cEG2y58bASO==epXMq3ysd_Vt5C8cm!5G6c#y8dkCNz<8CWarI)O0eP z%|h^Jv#?pjENT`ri<>3Pl4dEhG(6laYnC(1n-xq~Q!y=gwskYz&5EXn>1leI-tdpG zvRMT_a8@&`n?7a@Q#F~XnYzhMo9PQ51N}^Yv!+?gtZmjY>zeh<`ep#U%nULe>Tza6 zvk|;_ZDKYxo0-kc7G_JcmD$>CW41NhneELEW=FG=+1c!3b~U@1-OV0muo+^8nqly@ zHp1*_Mw-3MD6_X2ZN}hNxyPCDW`fxVK2s){$z}??^6Y2!HwTyl%|Yhi!b8tt=5TX_ zIno?ujyA`bW6g2qc>GfJiSXQWvN^?^YECm#&FSV0bEY}VoNdlA=bH22)8_(np}ELh zY%VdEnrY@TcwM;yo*=F=SI_g4bA!3j++=Pxx0qYaZRU1!hq=?-W$rfjnCa$TbDz22 zJYXI)51EI}4D*P2)I4S$ho7J)%~S9d^o)7dJZGLaFPInMH|QlZ%e-t}F|Wdd(CcQl zc?14r-ZF2Scg(xyJ@dYqW9FI<%!lS9^RfBFd^+#<$-gpRn{Ujw<~#Gf`N8~XelkCs zU(B!OH}kvs!~ALfGJl(Y%)h<|Pd>hH{lE|X$anDRllZCM$?xnh`K$YV z{5AZlpZPVv?&p4+-`8*V`}zI-HT|{xwf%Mcb>RbPeSd&I(1#xazu`Ci4g3w^cV%OL z6Ms{GGktE+z@896xh+h)FSv?}%?%#o56~D{B+rP)3?%(U*hhG?f z0KQ}%!tadFfai-x;rr=v_;-2If69N_f5v|no}ixhU+`b_XZkPsv;3Fg7wT32HF%ks z4L>t)`fvGf`|tSg`tSMg`*Zxc{s;bt{zv}D{wMyY{%8K@{uln2{#X9j{x|SB_#OPw zh*z4Q{Ga__{9paw{NMdQ{6FEN=5PNWcqH_!vA(r7u%V6Mc`Am_rqp&)|4j?qMeL$> zF}t{3!Y*l-vP;`#?6P(_yS!b&c7->?7TapO+3t2l+r##>y=-s0l3m%ZVpp}R+0|_y zyN0dW%+_q(=C;lDwe7Z_?QhqFf2+0aI(A*Vo?RcFt_Ipc@WIo-FRpK3H-xX8jo~S0 zQ@fen+-?DXiCfvN?KXB>3lFgHp|c}A>FjKGvAf#c?C$WTIM@!cL+vm-+>Wq&+L3lI zJId~DN82%WtQ}{^+X;3bI}!dCC)+9TwX>hyA3k>uv{NTYJ;R=9&$4IRbL_eHJbS*qz+PxCvKQM+?4@=ZJUCts z?}AstgU{9W8u;qE4xW5&us7P9?9KKTc#gQu-fr)(ciOw)!|NV9-QH{Ov-jHv?1T0p z`>>s1AF+?x$L!hYv~%03PM9?!zJ$Mg0D`=Xs`UxG)Vm+dR|Rr{KK-OjdezylY& z(%5(GyY@Z%zMUh!XW)C_qyKtbfJcJ=_(ib4*k9>Y#{OylvVYru?7#5*Vget2Ujle5 zi2@hI;_(IkUOL0$OBeVxStM9=0nabuL8kEj0=^76>>TV8>>BL$Uq2D^{X2-ionS2d@QnYj=Zau|`g#yg z55b|qVZq_S5y6qcQNhu{F#-MYoDiG{FFYp)rv#@4rv+1k(}OdDGlR3}2||281Q!Gs z1{c9&&n5r$dJ$anU*8nLt-)=4p^J(x|@Hu?ad>MQdd>woPFLB=m-v>VgKL$U+bHgw2((qgGd+*KZ&CGwx8yuec*}*$hbx3#!%El^wuarp z?%|4IkFaOhE9@Pv6s{bu60RDq7Oo!l3DR}GAdE&XEU)Vofv+%dK4!l{c z2Y(g=;L~nU*bz3uX1GDPVYpGaakxpiDZKD)4)1nb!Xw|-@TRe?_|t&jj2*+B!kxwQ zMYvnId$>n9I2;lV4Tpup!x7=0;mB~W!r#Vd`1Bh~j~n4W>RV$9y=#a+Ecp35I6MUY zHx3IA5040s437$r4vz_s4UY?t4^I&98}O!g3cUTD7ETRMhtIz=;T!Pm@SN~mc-A{V zydb7lF z-Qhjqba>~x58n75fLDo!;KO4^_(=FD{8c<2J`p|{J{3M4J_GMP&lUcAUJPf3FTr=m z%kbp$Dt!689?ph8pEtv|;DPHMc=dS?{=MeF!`BD!@bQs&{0lz~KMOw(zX-n!zbbrv zd<(x7-xr=gep3GxzljG6c>DM({Cl3q4<9~10(c0DA}3yg;N7BA;oqW5v~aXYv}m+g zw0N{cv}CkYw6u5)5x*DeL!>2Yjk-nMqZOkbQO~GX)H_-!S~*%JS~XfNT0QC$tr1nD zEUHEID398rzEOMBFX|ty8Lbtq9jz0s8?6_u9}S2GMuVb`s1Y@z4WbRBjiQaCO`=Vs z&7#etEut->t>CqA8~85V4t_&+fX|Sf;5lTMXjk!?673NUj)p`-qhZnTXhgJUG&0&N z8U?>*qoXm=Sokg*UwAK@7)^>MM^oU%Y(ID>I{-e)4vG$jXR<@1!{D9li0DZ8D?1uq z%8reWgV(VWq7&hR?Bv1=*=f<#==A6e_-HvRIy*WiIyX8mIzPG~x-hyZx;VNdx-^;= zT^3y)T@hUwT@_s&T?4-)*G1PyH^7_7P4FsmOLQx|irlXLN$!T1k?GOB@LX~~yp}u| zJrq40&4?a}9*rK09*>@go{XM~o{pZ0o{gT1o{wILUW{hKcgZaHEqMhVOkRTrliAT5 z@K^Fy^mg=4^ltQC^nNranj3u(eHeWdeH?ufeHwiheI9)geHncfeI0!heH(oTKPW#$ zKSn=AKS#e59#MXWPn195E9LL#pXgubIpch1UEo6T*WzNAxYTuWo!vsNi(A+&;udv_ zxy9WQZb`S4TiPw-mUYXy<=qOdtE;#c*Xp{t?rufb!}WB%TyM9MTiLDRR&}en)muFdsz?XF+x!^ExQ)^+Q-_1yqB&<%1OuHl+)1Gk~u$ZhO4fydL$+~#fz zx24+(zE8Jt+q&)C_HGBaqua^t>~?Xxy4~FFZVxxu4RJ%=FgM(dfOpi9ZZ9{=?d?Xp zF>b6I=f=AUZXY+%O>&dn6t}P2&+YFHa0j}B+`;Y;cPM;y9S*NtN4lfj(e4;`tUJyf z?@n+hx|85H@f3HeJIzgXr@J%UneHriwmZk2>&|oMy9?Zf?jm=wyTo1Urn$@9*ua^*T8?{b?$n1gS*k)F!>4pS#~Z;2wmpmxtkT z?-BQ?d#v=g=AL%XxM$sS?s@kDJm1ZPufAD@x4u{1YwmS7+r8o5g!j9*;VI@_c!hc2 z&2e+x2kt}nk^9(v;y#5Bna>MPGhex{-8b%A_nrG5-ei7sKf$x#FYZ_OoBQ4U0q=W% z!3*C%?q6BVjQ!ZcM_L$1@SF$tvhYRI34Ui5io3v5%_8tZvlx5;`Xz@GuE4guUY4@Fuu2y!EUauNJQ!_lehlAHyuJ#q~Ic=fS>l zJG>h9kJpUXiq{rjfAE#Gemo!^7!Qg&;Ipp@uZSDMuieJ+Ch?~5-nKb>`E3c$ms`V2 z;kNJ}xP81sykopmyfggm>o|F;<$Kx zJR#l(K7=O4li^8e-*`WGgF65|0uNFzn1{i~;SuqX@N0N9w4FMdCs6VENY zKYldN3*+bU7x9(#Nh4_{8zdVh z8zmbjnnnUL(0OiU&vlS|)s$pOiM3;IG!j!2G7j!KSB zj!BM9j+^f-44%PGg>SH_$?5RrbSAukoeh6r=O*XDuhRwe{RRI{mnPGa%i#6tisZ`w z^}_YPKDd%Qk~@>T#7|Z-T|8qY_a_e|4<-*K4<|E{N0LXA$CAgBCz27m^o~naNAZtmNh7mE_grwdD0=cJfB@X7X0@cJfa0F8qeRpUg?-CLbgpCLbjq zC!ZvrCZEBt3$q&ho$xq48$uG&T$#2Q;$sfs|$zRFe$v?@z zDV#{9ernSo4bv!fX`Ci$ns!P%rwgTB(uLDS(nZt7(#6vy(k0WS(xua7(q+@-(&f_? z(ynPGZAn|xZfW;)#k5D-Gwqf3PFG4-PFG1+O;<}-Py3{6q}4P_YiT{r)3&s4+Mf1H z`=@KBYo%+a>!j!s_b1JZ%%ptK`xq|J1Lbi;I`bmMfBbklURbn|qJbjx(BbnA4R zblY^hbo+FNbjNh3bmw%Jbk}sZboX?RbZ|N(9hweHho>XbJ=2ltUg@ZG?{suJCLNoO zOUI`Z(tXm2>7;aWIwjpV-7nogJs>?WJt#dmJtRFeJuE#uJt93aJt{pqJtjRiJuW>y zJs~|YJt;joJtaLgJuRJ@o}QkOo|&GNo}HePo|~SRo}XTjUYK5#UYuT%UYbrzFH0{^ zuSl;uS>5_Z%A)UZ%S`YZ%J=WZ%c1a??~@V?@I4Z?@6bp_onxy_ook} z52g>L52rKIN76^r$I{2sC(XVPcW=hElX7t$BgndwXEtn}sdmGss0we*jnSPaioqm&kn|_ym zpZ?&cj2l(0<{5QRu3A^Es+Ql^^ti3itT{lm(Wvr#R^|2Cykmx!4eH?a9Sv%O*VXj; znqFV4^_B0l2G3`W0mXIDB7A5O9#pRz(2v&-DAIxRgY>wFCua%1NHua#r@TK zf99*+kKr||46j-3Uxd@FGTdf$pdK^)X0^yq)~qs~X0^%h2kQL;_5Ojy{nc7s!x>nF zgW_iX^Nv!PzGgqYzn|XUPw(re@%PjC`)U0BH2!{N{Kfak7vrf8Vm`Bm)=!h)=N($l zxt0sdALXv)Q{{cNoOY1)V>#s+!>RYDR_A@+F8Qxk2T?P2K)yTAk_4 zSpL;|v&c8>k?G5rf83|_srAvA7ml81d1ehwU&ivQW=+laAlhp+R~=N8Gt#T^4Prf2 z2WfiBa57CtrsbINzAV#vWqDO=WqH+De+}M;^w6#vybs5$&qk)@p!U+Boxonp^O~*L2YiQ18rNy|g2Zx0$IuY5dJh?Mc(o%+#J(E?HB{CC?aMuI)6h zsvUGN-f9^?+Y$0tq`Q{YOZ!lJXlOb1S9@Z-)r{9yvl`n$R@3@wXug_ikNh6pLh(Jc z2nSkx4=vIOE$)LB@j{FGfEM|O7Ud7s_K|CSvEGmlrnAO$q1kA;^E{4=@S&{#jK|en z?LKEeg=5-P&i)E^$o$m%vpni$`&N5t*0g>YF3eWzr$&2d*0g?Vw2Nj<+dJ(R^~L<; zWj*$1zMB0t-2Sw?Ci{h~*Jd1Qd%ktuNSs6}U>z(1F9r5~F+0NA-8`?gz{$)8bU3Kjr>e~M|>zePn=DX~7 z+0SIndJ#WV^Ig|`*EQcAdVh!B-=XE!q2;UXJfCM@MR>5MB0Ojj9<&IrfsRZ1MQG7( zplZLF+AsSFoLBp0e}QAQU$$Qy7wLf(>46sMfolA$cN}Z{nZ{qXo0`T?d&c8hSXFcMWT@T{+5WSpri;(HtigU1TAnZS z&G<4c=UksZIqePOh@xH)E&I(}``Nr~7wu(x(Qve%sOGGnYT1u>u%EBe{$XZpm(>o2 zTkT-KUDa_zR+hi!H*3>&T;{u>>2I*SYkXd#eY1RO+8@-k|EOtyP%G_$;iEi?>$3*K zhiZS!{szxM{hs5ftikj`3p;`q`G;z`YI|;U6zRfwy`SUJtTAt##B|o#A6Dz-xJ&D& z$$l|wvR;Q|+k9dWM}aJxv|o{38SkrReQ77Pay-Q2tkGBNwXf!{uhv^%rZ4C79d=o?pRB>> z70RW!9^ukXGu~gVmE)|wn*P3;{=Qn@?P{-_FCbja2itwMuH{*6W4p)(ltx7vW zx3B#m?Fs2%dNan0W6eL~MYpc;wrhH|U1i$-vJUMZ`24A68h%a3p|x^6$@{ZLyOtN* zIgYj7+BM(o4`A0?o@KvF`@(rGmxh*0Q|m{^-&NWP!qw~ASx(h9O=lT@zv4c0>spWf zG+jEcYH&P?`qK32yr#i+g~tQSBiH#!o%M|CS>D+|+Eukf`!Vg;GUgYLvohW?A9`Gl zA36TY8rmLf+Hchduzu_1_<4Y~W5$~`+sfl2UK|&8fcC|FG#f?waLn>zm^;4Q?P5=b4|yg*+5Mn$33+{`dqE{)pp&`=M3Y+{Fmu#4Agcq zko`e}<%ju&+Ov+&s_gG@UvVC)=_=dV0Oqg3azX!H#;fhFgYBZm@dEDG^k}-OWxqOr z?Xb%B zT&?N2t*X!GD%(Bmm3Etz@#uUx}WxJc`f>mE2}{p7S)m<{{+T*n9byys3w=RGD2nK-%6 zo_h=(GHFCnXw~vztdquuP9hr|1XOG6Bw!?rq^1w~tZd9oJPM;oB<4WcNpKQ^Ig1)k zQ;kl)ukvAvNhS+1XJ?GW7mcx6W4fSvzb<~%wDM}@LtP)<89OP&ua&^`Abc$>p2u+! z9<(q9bWSwJTo>=EeYH~AIlu_C@Y#7F{Nj7uuZ>$LKQ$d3RLj9xqxc^8^P!sQq$-DSTztTNT0i=*Y_Qxg8PjstaBJnDt{gP!q$|^h zLruf2^<}x$^tzf(8Z&m9h))}3*;$p7Ii>^gYW?V6)kUo;+bz~S ziu_|R&2&|@ldr0sR5=){W@?|A+E=EXP^Obj^lF8DVRlw%O((lGcKX$twv!s$368bg zO1mm2@eDs}FnnlHPG~1ZxKJ%$4jvGle$PPyjQ&GEGmWgYjDV(B?5FwS_%Ewfq?$j*EQa zImY_TxQT_yq~=p6k5x{tP=9O>wQ|szWR(YULn__apsUzU|sxX_q)(#E*8V-?P2oSnZ7c434!t%ICQD zt5uz3)^zcwQBK;*Nmv)@?gwr}lUGHzz#xla46>Le#)x?s0uyv6qz$7sClk1-k6a%pP0m5WpQoXNHQ<}4Si z8M3|R|=jrBcRX3IEy17)>NpyYQqA~lSy7q5%odndmiHx}!pIddE>^9YY>*c1ZjzgPl z2iVIg(g~$~HFa^RsqLz%i%d;zUrn|P?3EYsKv@sXa?_$*bkNP~rcT0}9KYgv)?c%1 zkDSzF?#OX)T_?TuvK?vubM0SqE#G|JVs+6UVs5G9+H!oWla`!|XP7&d?Xcpxv!LF+l!&Dvb+JLhIA*8ExjxxSQ;YrmW8O9@rA>uOW&s4V}o zzVu~C|V5qm(aubMsw zYdW5-X+KfZ=XFyz(%Qd3(KFjAg!Y9s7a0 zz9dxFmn!NyE~x9~QeDT_P2IF;>iE0K`4kpWwO#0DMpMV7O)P2F^8>Nu^bn-5JLuQhcOqN(GyrmlZ9 zb^O-U#qcJdC+MHG{PZQICZ8`julA(l@20-A)YQe*CZCg-E@*ko+ayxE<8vA78!Y!+ z*Bf%$3DyGjygmnWeQxFDCaR7Ta~;>^IuFmeI9bg(-at6o4{N>V`tne&^Zs1tbGdG^ z=335eWk0L)>|7U>bKO+U^*NjCB3rKYk?UrDuH)*w++5Z9X;h}DIu6q3V^v=gscN}Y%SB1{M_AS=>H}JgC$T75)`-7xQ22R*GV8g!VeGKGcqNQ=q1c`%pe#v|Mo?>s?#{viK3x3nS z;5T`-xJP z6yMMyxJ{183roXWH)U~X3(#!pYe2Y8djM|B<5*iNTN*4yv(G04b|bV0VN1jJT3p(~ zYTC2bbfm7^X0R}A>9k-Jt6ElFi?KdG6PN}T=8a~m&4a@&Yz1jEW4DT9HOF%Jr^_03 zHXkHTyET1W&D*P)*L`T=aCr=`60^H2w*$&%Qf^P^J51P8VmDOPVNz8eyJfdpE1O$; zgEms1Uw6nT(F_5}`_3my#2=6!>m;ssl2 zJCnmkrY|?>x*2Mz=y#x6Av$iy296s4{940{l0_9m`$tnU4B`0<(2Xr5lnqZ`+{7{a zFuaIWT;5lOBa3Rea>OSGR*;JG4Sk|O%c?J%D`%SJ6HZ^ptRd+|yU>+G95YqAB8>8* zz3Vm`CMd;m)}bq`9lEmJp*vX}tXh0um{!)IBf5@qg_G&VZXHXkrZcJz9TC)YMB2fT zF0L!mgSR<~bV7^#;4NTwcQqZ+)N};fp(EW6eJ!G+oLOlTsOgGwOV$Y9beNu8{fqK!A2A^>FzNi}(UtAB>{z+$89UOV%wT3c0t$&VKvC*aRbL9%htp6%!q6n`@Z`Q%`#rI{r zMR+)0bOF$!9B@7DrCN4Dtlvi2)b+`Xw@_)#RnEY1tmVoXIF7YkIRnSBmMd3iajfOa zktU9{TxmauuMCg&TIEO_*J(L%q=xG>-JBWYxQGW@lnYeLy=+gk51cQ<(ef@|7vKmD z-xuY8x9AHyf)@FO7Uc^q(g)Qpnln8d)1LJ;e;jK%IK#rR)_=JZ$(;|JFVX?ka^TJj zcI`EO&RlV<@pDBB#~Oe6+6HH|IIrcvl^YyuIdH`o$Auk0wH&x(gJX@qTxsXbvQcF_ zhHCt5cR1Ggbw*r{$TQt>(Q-z+E5gMBHS1G%25?-Sr(NpK0nQic!gEyZh$CY>;Vl%b^7NuIA(vOBYM2WQj{-t2H7vvX{YGC`z$>7s*FP($>q=r>SLo{c+HhT8x323-X1#oUL|5kO`dVpSUn{EX zYZY}}8LaDyWnD+abzO<9>u|EJD~5Gl!K~|uysk5~x{hn=I#a2aGZ;QeQNBfaF<<_E`WM_jpu9k3tK*KP0=R{JY= zNVWge*X^-E#dNhd+vF+TPaj(un<4h^ifY4UOm2I#FE8Vv?$6>frb=JN$K_0QR>r2i zb;bc6uGjC`nsKbDcnWErlXB59$UzK!nSd( z5`U;bi|EU_Cx)ha%(XGRVnYjU*THmOwb;IDu>)209UctSn4h}NT(o84y>5n6=iC?X zYnI2%UtOPonU;sH3E(>BQ(GpE^?EG_UDK=Si;_^aB^|kCx;CPFWk?@eUdA51mhp)I zWeaZOURP}(U+BlH2l@on>vRtTTjWfK?q%SZHmiFYIA*$aj{(OFC)X!M&L=S5n`U|E zTHiUJz&Ou*y7zG-sxGUrxhex-r7maCGlK(-$!H4@7W(VVBUNT%el1dPgx|saGi(rADb! za!S!>A|X6#r}U$6l2jkSBTjzmgLtGdG7rX2*-TnXJzYNfCusK&2vQ0aIH=QVz=_~TgP=R63<8o$0lrQ<1_*Z4VZ!m-A$GuTYu)X>(2 zo$4YUtnw81;fx$fZSWjV2z#E$Kt zE_)P%)?pLJPb!DCjf}&+MyA8a8ap|xC$swN^ESAPP6iv=EY@6ybGa^z`ugxh7_@u*?tBq%T6=_y;d?itm~9+aknXv(Z<5itu$W*MFKkQ@);`l9DT52QU9 zO%3PSBh@(5#Vby%%JO+zz8h25y|0?SbDPh5H%zmue@LHc&s8f|DmYF=4^kEjO$;vq zaR$dRINs4I#t~5MY07ZQcK~#ctES_POjnx9ce!fX9KMV39MnuzXE*EnuN6QYUMbT6)6zC)yYk9B>g1p~MuUv+KL^?57hMGuG>TT#AP zA?J0u&hT?CGGp(K%Q zF^?IsXKBmmml#Q2z8J^H1&l!}rQE>Q{-$0Uk8bQ_qcj;e;tvRFY@XJ zXKkKQYoY7LQ(i7Vve6)XeN2@btjs6+DL!uNx^b+N3KVkrxaWQKvj5a{W@<#48c$aC z%lg7$O*PkL-dr0|O(z8yKNOQX{i|!Fqj;>Nzhpht%NMcvm_qq#|F0XLHGM&=RvLNv z;+Fo^a9wvcG966oUk~Ds{j|Jw@SU+4q&j)TbAX*uUH^b2*X83(UxcjbMtohLBbjzmxh~sgy0M#;&jddAP(Ru! zb5e@uWnnjHA4PfL`~Q! z0=7pGMKpp6DCnSwf`ARo4B&xibVWr(85GY&*JCy+4&J)Dvg#_UnEsyX?#yInGF*bk z|1~jmeV0A%jqDJ<#fo)N_Mz+8ni=Bi(SZR zKo2<`KtfIjkdV_cB;+*ngpzNACiAmxm(l;gj<1lTv_ejK6mmLlhwSPL=Q-)-DCLlo zUqZ?J;WX=nlFGs9Kpt|cFCnL6OUS7N;c-}Ml_OK&| zJsmmh>D;fUbHAR>b$i;^<)vyXU(v7J%IB;teZ<;IZ`M}%%G%N^tgUi^wH5!Yt@LJX zm20f6e9hW2npj)qDr>9!W^LtH)>gU0+PS}N_x8wfI_~8-9rtpa7g2I-hbg~1_jfw< z<=FAfap(Sae6#P|-!4ztckXW+o9sLHxAhvnFX#D_&6vq%N!$40cjx)-@{WDy`JImR zInGd4ccEiGQav#6;=gW2EE7y^)++;JD6Q0~;bC?sJTw5-%%bfo^ za**p(7MvF{c$wMOUrvW)_O0`=8}0PWbgJERy<)}+P^z*;j?c) zpJPumIEDS3&ZuYX+t2B|EzQ0i9!IaU@7P;TGjI>58Mv2Ym*?2&IrvyxE(iB=_WRh) zPxNQC?#J4SSJu}3SX=SR+PWWWD_&V!_hW56KWpnbSzF~0Yb$*Im-T=8m08!r47TDCyUxB|Pe)&el5QtQ|MYb9j^iZ8 ztz+9iJso?ar!BXLrF}ie`Hg!gN6bAU$)+jQXDvz0gJyjc=|1Ky>Bq4F(gQ3Xl$%I0 zGmp$+d6Rr6%f+(6a;5wx%eUokS^ii4p5<<3up~2=#Bi1;D5F@Orkum_JY^Qk8dDzUz9IlmLIqX6(jEd$THzdFgux~iutnKsz=OJ#$07G zqwF_inW-MZlCktzwo}`&JX+0WIYcESYK}UL<#8&{#n|{Kvphv*rZmR5pTY7v^?H_b zRGyA8=?R;*Mwowwenny z=604D4PIr*kgzOkGXt}%HP8qrBfYm}`LXt~Br`I21D0*vgqbnFJF@KJ?!vO4o3vvL z?-N*#bdO^>-aVe>MQ&zvV@&QTEE$JeaxoJ3m26+-zM5^u;AXkfT`b9rLCrNi?LF;T z4)vVHa;#@8%WFNeSkCs$W;xe0m*vf#TUp-bxsByQ&q9`r&&={}52?&Z%xhUP7BkCr zp7ktao*2u=J)}NkFF(z4i|1vQuX$dRT%I>PZ?OF)vll9ito%MlKJ|Rc@^j`klo?N% z`0+4?GDm*){4B|gnauoWjFnuQ?YhiiC^JfOCd;PG{VOw8ax;Eqv}Cp!C7ESAFDWa= zM`qd8+nr^|8)Dhp+nePOZw^bwKxTQO_e7R|@&1J?FxD~4G0cA`i}8-x9><)+vKZx< z<)zGTEHkPx@yW=>#3!R0U&Z#d-fP*O>7B{;0x$W65sHahF*Y$v#v*3E@ zQ+GPs*JY48jF)>m%Ox30SU%2-+%CqteTHpDwPpMDjJH_+Gvhs$j9<&~&oVw^$#}Ia z8L!qV4@8+ziy7TUNJ>ubNu#8Clg_z(3UkOY=b$7}YA_EW=P8owJpD3V%Dm`;OQ%RJ zCZBVunBz?RB0jQnCC*}?YsEzJt)pJnwIoeyz`2Ul0mQt4wWURpS6U&xEiIJ(DSalr z#9T7X_Uom^mM&>EWk)LOdj+f+Zu79fS<(IT3S`(!iV~O9Y_=F@Xosh)E zN@pR1N)ItcxRS;A;H#9Lj1C@Cj`2L|*{BQ=a;KcYxYg$=qeQwXr-~7tmD9us&&nB$ z?)`?C}%Rp@{!6}jI!KDIh*m7k51)c%UC%%zz-nw-((Hi{ejdt0+slczOXRQbBiN-FQyv^RNLAtJyRsXi@DUKceWA$HF&&wNC`DoE{d%tM8{au+={af5#9J80ze@#77yw3WbV6SV+ z0mbdROzT>&xP8}M6%s*C$zy|2O?_6nVM-w*KD?=wd(nfGz_@ z4#_#LgS|&Cuax)2o(S1VDIq=S$H_aYrsC>(lcyE0%j4^$z<#Yq)G1y!^2YuDN73?d zx74$(xLr9ab5y1Xqge0#d&(K3GEccwoFkSsNr!!WR5v?rNA-5%*pBHPqi*}_@X0%p zGIw@j?4;!O{;bE|W`9>^tsXJ9Wc3J^A5{K|W8rR-Hj8!YckHf;y>K_qK{Hs0rTtwW zUVn~q&ZzUcjYE%&YXNhLz8GJxxTLsb{D$$FqVyyW0e$Cn-Wcf!Q+|DNET&}c%VqUBxTPMSJ%YQfay zQ`bygH*Mjxc*?Syz0lIP1C*8n@;yz-%iteP9Ip@e)^jErSnTy&zoO*_4hN5 zjgE|t6zdrc_#QhW%J;3JlA@Apy70?c(Qy`6M{k_}z5SiJdw%Jx)?9)8S-X=*#TD&i z^GmO9dVQ;TpUnGYZeniYraCvDyE5nIbLV|>^LTMge36PtE8d;AYrb!O@x1Tn7qiYi z&!#>{@`;2Vt|-YwjxKJ0i*)C|t-OupCA98} zcP*WgvvNdniO_k%9u#_!{KC_c6H3H3d1jrhqr`u08F9+$5y;0Twntb}+P;YYiQH54 z1^;7<9OkUqwlvb}PQ_~|hmjHfQNp$QTX}jOw&?=M#tuIoJA4p!_{-9K=~cduOWU!> zpOD^>K9EYJkEKuf<#WD&$3CASZI>4-r^v&s`5`6dhaAIvmSN>w=CM4Vc^&Ie?_bJn zl*^etF^Aa`cQfzfPt1O}TuHcUDF*W#4pAPUu0LG)h?|O=v;z`uE3bC+2MXm)e#2+P-40JTYTi7BjYe z!;E>~@jZsQ+I~`xWu7)g?ImjDYG3AMtEcwU8f%$qf97Osp$=p&wjTqUTJ4QX8+17fiCotPuKlMayfHpuqi5b@hsd?I9ZLm5*%(|xjg<02f)svZbZMZs8 z%)F+aB4%DwPi5w{k?QHpw05RCnwi$dsAr0q)zq_?S?v;ajCQ$pxq3eHsLfC>U>3D& z)$z=tHdCD-W>8ZnGK1O@brLhAtyE_)GunD}mY4%gEnx1mr`6k-@$4COF|(M()g{ba z_Mv(=Gnaj$E@j@bFV$t*ckbq@!Te%Js?RZ_SO@h*<`fI4FEOiFNPU^v!-lADF+bRe zYMGcFOx?+xV5h4eG9TDk>PO7Jb-(&CvubTozhI`U9qQL&_AB*!_ip!Yb+?E4t?Z_oU{xvhm%&DdfbazU1n=~S!*IZ1X@$(Not}sr{3ROYawd_qJFQnWrm|XtsS!&jn>*TbJ2y`QKC+-brAJIEx=qvv$c+5_93kkvkxuQx-kFH zBCRVk5E)uGQJdF#QICIA>&uKnk8Axz9bN06(JiB!Hh}qdLfSxTg}t;v)C&7+*`l7U z4HfllZJ4BLd1isO)-3QaFTBS?E!d?zCWYCP*pno;*wd~y4Q+GkV(Jsf$4DoZKl4VmpMov$96S%(;018v zL)jypn2ZzaYK^O0t#QiL8gsSAT&*!zYs}RebG61?t#QiL8YizNw_x_|urvgQLJkar zTsRJf!zefvPJ`3o4EQUIhBM(TI2*>mSP!FG5J zUWYf~EqEJB;Zyh=sj3I{p#e06M$i~Cp$RmFX5fS7&;nXQD>wpL!;#>JHqaK@L3=m~ zI>6BofR4}!Izt!e3f-VP1fd6HK?r)nvCs>8Lm%i1{h&V#fPpXwrot644M;cnDj@CT ztKk~B7NT$+%!FA$y2>{I>57jnjCVgQ&xM=dE^-*xRR+Tl7z#Ns3>Ly2un6u1aqePR z0?XkZFkl6&gjG-oMSyHrWf19uw?C}B46nee@OL1+6w*ui2fP8KnerCA4gZ8VyaVsT zd$0rEhceg+AHaw35qu1!vGN&^&dL|?FZdF^f`7x;l!KSTWx(}mKM2!q5O(c>pQW(s z0u?myLTx}!RAfX&MpWcOZ3;eU0j&U;P)Tz<_hGFL)P)i7v=nv|CvNWH<{8{PgPUh? zb3gZPN-@&GLwtIOKhG8@fvxZY5MSQWK-zkVcQ0}7CC{(CalxSVUvHuJS5 z=$JK$0__3T;<}HUQFK!j-4yj+Z$`a0m{F;9+AEAS=WAoYzS2Z(A=`Jrov?(^u3)_q z3Sl*@F^6jRbNm5t?&N;JobO%-4?+oSh3Cxq<#(=^_Rf0=d-*?)Nb}E|w2@{pVJ>#U zJc}?F6Xs&VTuhh^!dy(4iwQF`&%zq>?9`Ycyv2mKnD7=8-eSUAOn8gS@7h*=lQ@10 z-iA{6C&WRd!Ml)>4ssd$JK+QP5I%#?;Y-s{Gn{mqNV;4MmqTiLRf$Q@gUG{r(>Sal zF$jr4NQ^2WF^D6>NmHX*NYY8qB2pqoO2nL$C?F+bq(qFAh>;S@NQoFJ5hEpHq(qFA zn27YG-l>4ph>;pGQX@uc#7K=8sSzuWgZ;Eg5;2b_5!+u2#=M3+ca4;(tR`o!f&1YB zco5dZL+}VZ3V(ygu+JW6{RBJ-o8ei?qq8YjC@Yedqoip;p3kxo6T8YSN7Xu-VhKv; zDqgcdX$BXVx^lO9lk&P5R=#2Vt$CBnYwE5Hn8o(><|5Y(<}%lfY|n-{Fc)run_(Wz zhg;xQD1ZfU8{7^H;SN{?cfwt;7?uF{buEQuupI6I16IIFSOsed$3te=^)STX5qQir zT(6i*T(1(Yzq8)X`Zai+?SH@<@Fu(kZ$qiMOxdiGmxfGg zl^QBxgVz|c{VU-sr>b4TSBekYXM|P@qk(j(p>=<(2lRQAr!mlZy4Kq)(}uF0XZF$Z zC2ytjbT)B(iy71wv0g*ktTp}RR}ogh!C%EIte8q+$TQ!!?oTNz?kR~g^u3;;TGz1a zF+#saCBK|u7Ra@rHq?Q-ST)13W{!tZa5{{EbKqPU2eV+kxkg;gRUb_@6szFqa;qS} zlGkiDF*cW&xFu0WtVD?wQF|&7wI^>O8oY&EJF)&3-*HA_7xu;8?K-t3;12}qUm_ibi8OfUNjvqnvNGu z$BU-pMbq)3>3Gp}yl6UJG#xLRju%bGi>6EXYVo4!c+qscXgXdr9WR=W7fr{DrsGA^ zr7chbTLF(TUNjvqnvNGu$BU-pMbq)3>3Gp}yl6UJG#xLRju%bGi>Bj6)A6F|c+qsc zXgXdr9WR=W7fr{DrsGA^@uKN?(R93MI$ks#FPe@QO~;F-<3-c)qUm_ibi8P~+z#5q zQP2U7h5&SgPS6>;Kv(Dn-604)APYj!6OM&m&>Q+dU+4$@VE_z-K`<4rfN4Ow;YHK& zqUm_ibi8OfUNjvqnvNGu$BU-Rvw(EPi>Bj6)A6F|c+qscXgXdr9WR! z7oH@%YC2vu9j}_M422vR26*f6tm$~xbUbT1o;4lMnvQ2pSC+tXxCaba0V`n@6haXo zFTb>gsgT}y<#fDqI$k*)ubhroPRA>!3HRIymC5TIbGCt@yzLX=5#!Bx{G_@ znWNngi+2y}pCujdoQ`)+$2+IvozwBo>3HXKymLCssnXl1UyX}265}=UU=wqJajr9Ivo$4j)zXiL#N}R)A7*hc<6LIbUGe79S@z3 zhfc>sr{kg1@zCjb=yW`EIvzS551o#OPRBzhWPC8?&JY+1IWP=z;W!u$qu^9H4NiwM z;IDxHk5cT3tYtko5F&ccJUFW}KBNFE)98 zxEq$jGFT4xfB`FDC9Hx%Gpg2shL8#E&8Yi7w8q>Ac>6uib583&wGHxxEMqf=|7>|C zhy4Lso{?}r67I)lE;x*>(7!Kjldcu|Cwyq4l=+#K9~Y>g!5^U|TC0?b!uP_{&`$|_ z*z&}PGOw}ab1IdNN|yh>i}GBwv8|=Fs7Xr%yeDhAL90kl8>Ef7Ep1keHjv_JqY z5U|efYH8Ln)RnIUG%Pjt?UXp%Elt~tb$=KDbD=`>>a+<8ZL8BJsM98>(Vz(-ZH1=Q38k)V#pjhq+gkdH+KkprmZ<4$mwHqQ zwdk#r47KQaviDFEOIA;U`&g%c0$bNregebMxT#N&)VMnKg3!3)DZ}|jsgSwt`;@u#awhE3bU8&>b`^5AnQb9kcJ8pPw^TXmv`;x&&A&x{tw`Zt zCix;nNr@NvwL%W{aycY~)nqZ`$fDK5&9a=+OMW=TwolEg;c7h)k}_J^whte)bzC98 zmVKM@G}Uuf)lz7G%TBvLg`E~P)mntbos^EseJu7~Yf0MuNolE8a}l}H@**Y6(*2R1 z)DVZAQfYtFqIylmu3y<9tt>qir5M_*n)DoOd1;qiHQlLddFiuc^g!~s=n-Srk?a(+ z>qz!dR^ZfvC8*hst4VY{VzDfbe6bj7{w{P>u4AJK=ia=-Lh^n1J+jhj*a zPvJ8Vy`&zi94cXdVO+uv>L5Pp&0Z3|IS zc1V?!mLk#L`j6XEBz#cEBAdOSH}rwN&=2~<02l~^U@BYz)8I#VF@e;S|s+-GL)=mM61;^D%*LLEvAj+wx>YYDM_nb zlr*V+uou~X30{Un(`qEjfrDx_vV3x)oVpaNxoXRiu%v&+mU6+tw;Ji9PM~E_hpGdj zOp;I01crLG=@xQ0!^VA_@FtofR@k-j)2y1B>15X zw1sxi9*%+za5My!UN>)|1I7-H}UJPLn<$6y0I4o|?7fajLpP62v5 z1?cS*ptn1l=~MU|?{Gb+4-KFpG=j#E2~D6WGy@+rhZfKhTEP*}8jb`%w1KwJ4%)*}&;gEy z0Ca>-&>6ZwSLg=aAqYJn3qsHnj)h*(8~Q+B=m-5_01SjdFcq$VX+Zj6#|E%t1K6`PW6ag|)UN0del>)Th1h6>+*qi}u&H$}90a|YYYHdJf z=&2N-r&55PN&$K*1?Z_1pr=wmZ3V~&J(U93ngL4R0JdfT`_gJ{z`hLNzYn;%kDKRl z^E_^z$IU(IsT826Qh=UH0T1!%*#af76-ZAljXRH>`H+x*7zw7xC{tro~a*2S+f+qCZQmKW*pmpCc<_Z4{?iG}Zg=SMBvKO08-m zj3~dqQtPmd&Xm@#{VSDP(wUZr!tq218{72`HfOwuJ4-KFpG=j#E2~D6WGy@+rhgNU|w1!U58M;7M=my;(2t6PRLT~^h3f45% z672-ruwL4L@C&8HI^MyG@dN8r`c>2qQtJiD-f^Ur)guo3Uh=40%{l68@E&|=&hh%8 z4LoYjkzCv<%n?{ z{gRsOHC^6gTD&PzKs}&d}30KRln682T1jxJ~RO0 zMrs6&fjE+yKvQ7cB*_QOp#`*rR&WHgh9kibZJ;f*1L9XY3Oc~i5P*)*2|7a;=nCDS zI|QKzWI+ge!ZC0x^n%{d2l_%k=nn&6APj^4D8^~Mnwmt`VQ{}I?=D*=<*agUo{0)2y-@$+3d-#F+8!)( z3JoC>+F733u$E6LHUYVw0(!#GE`jNcuy8fZNR%NPVPqqWY=n`Guy=u}dlv#;VlQ4| z>5GE1ZKD~fZ8RgbjYe;c)>c9xtcEqPmiw>c-rq6i#(&{^_yKmq|KLaX3HHFx zkN}hS-6W7f0T-yCfg3#F1;#N!cSX@%QFK=n-4#W5MbTYRbXOGJ6-9SN(Opq=R}|e9 zMR!HfT~Tyb6x|g?cSX@%QFK=n-4#W5MbTYRbXOGJ6-9SN(Opq=R}|e9MR!HfN23{S zZZxCKjb^mD(Tp}Xn$hM)GfEXD6Zs_{Nh&~+3RE!?$1!LMVnAw%oEE4<$T^Hg#xL2G zYyx2mFgBIOxKzY#{(jg5#p0?p>c(>z^(sv6$VUPV^*X3oB1A~?kcd1aBCkRs7|Y~p z5M!ELONgRy9n6H;oOhEs4T;!Bh^HYD+X%HpsPmABJR~9yiO54D@{ou;Bq9%q$U`FX zw6&BC-*N7L;d}T2cEkVRNB9Z$z|W8XrXxZcaH4?%E>J-OH+aAc8BhyqLmj9K^`Jg9 zfQHZr8bc;Dfu_(5e9#F^#cNeNON{iL0o_*NSUV_-JiByyaU z+jMOq`*(mCz2;8VXmxD`>y=OltHB=C1_@1k>s|*BLJ4ez=Mvu%aT1|Gb7PGZXgXKE z1lA(A3eg(IKfE4$q2et);XAh4y0rTIrzKMC;-5l7El(|p@jg#-4SOj^o$Kv18zAKd z^Rw;5_naw~>Lkvd3{!v+BvlE$#incbbpp z$(O{K+vQ>6?uIm&d=^F*6sS37OdSTfa2yPW<0kBIaz?l}E!&J4zuEn0E9_(v;I*MVemW#aaKT_6=>f{ zdD?&Bd-wr%!~ft%_zCvF&yWC9%5zH~g90v4K?65A{HXXpZ5p&N9EAoPGN2uXR;@sx8y+h$vGkbFX&F&;00>Db&? zgRs46U7+lVQTD_rdt#J5G0L78WlxNl5I|m8!Jln>Ewf}VFrlN<7A`CUm#Y?oZ1f(CBzfEO~L7Sx71 zP#5Y!eP{p;p%FBOOlSg4p&9s~IkbS5&87G=|&YSTLsHj!Ln7bY^f1N6)al?%T~d%Rj_Oo zEL#Q3R>87Wuxu49TLsHj!Ln7bY!xh91-&>6ZwSLg=afpG_w9*_kg=n2QbvCs>8Lm%i1{h&V#fPs(; z$H8zo9!`K0;Uvg|5%3qFPN|#>BjFSn1*gJka5|g;e}&O-CY%Ll!x#v|IdCqV2j_zr z8+jaD2;*S_OoWSI5=;iXamvMjMWkR6DVM@!a5+R^DqI26;7Yg(ro+`R1FnHt9Dz#fLWI!#b4RxR{ z)Pwra02)FgXbhRq1e!uK@IiBE0WF~w909H2NI))J$b}2Ja3L2iNI2lI5MKB2_!xXp}bhrdAh06eW!~WH=e|79%9s5_u z{?)O6b?jdq`&Y;Q)v3?vSaZ61BP@ZtVHqq3dfQ>4>R6~c7OIYgs$-$*Sg1M{s*Z)K zW1;F;s5%y^j)kgYq3T$uIu@#qg{otr>R6~c7OIYgs$-$*)F0naUuXR`c6=%P6F$c7 z`ix_r!`EoydgdEiBXc`-nX|M^^CLVDZ!rE%Z_Q_xYR%bi!G0_3^&_y{TciJ}S!*X+ zz9VWP2_4^&L7mf}&S_K|6Z$6pGY>>Pb8R9UJ1&GB7s8GUVaJ8A<3iYRA?&yic3cQM zE`%Ky!j21J$Az%tLfCO3?6?qiTnIZZgdG>cjtgPOg|Op7*l{83xDa++2s9T&om z3t`8F+zp^1G=j#E2~D6WGy@+rhgNU|w1!U58M;7M=my;(2t6PRLS_g(y9_|jA(A;(8F)!=r$g z!zKKM8{lzx0-l78@DxzH%cf(Rx2Fu|dFkl6&gjG-o#0lwbklqGqnoX~xY|=EFG|eVWvq{rz z(lnbi%_dE=Nz-i7G@CTdCQY+R(`?c-n>5WPO|wbUY~nTB>i3&L9lH)RgiL5he747G z%_r_ASn()Y7qamnXX8Q6W8!13&{ec)$x8 zPz!299jFWSpguH!hR_HYw}v)?eA)=|X(Py|jUb;kf_&Nt@@XT;r;Q+=HiCSK@{2Zt zeA)=|B|o%*w$KjR!%@%yj)nkqgig>IxOdJ7zv*z(NRVG^TpReJi(eTQMG$78)i1RYyyo@+6vuZ=%(!*Z#LZLn( z>KT>xqUC!?U}89PYYdkZdh7KT?@FKhfFT*SFsy*(s>nF*pwqrDG(w(|W0VQ7nC0~Jef#mfrge7n{ZyB9J zPB@@;Hez%VF*=DDokWa|B}T^*qhpEDvBc6A=5C6i9cq*F5KluSA$lTOK`Q!?q4Ogbf#PRXQ`YNGGo zByvwh+h05OX`dn$bxJ0kl1W#8Kqm|TbG|u=RGmYrhAExqkg8!)mHIgClDyTd&4AgY z^eyOV;Z>$hh!ihxL!^Y!DPeRZU=k8A2?>~l1WclY(J5hc;gscWPjW>u@)Hux=;ZlY z9&L9SuAQ{vm{I}0&wO$@XG!bveF{6mpnlJNIIEFswi)Fbq75cry@;~lTEN>Zu8Ea_ zcN8x)t#w>Gn`>t?@_AT05i21tp);oUF1(?5y3lA%DBaSxVZOt0{0Wp$1|^h%eNu)F ze3`30fo<}M*2DZ(J0|hHdt4&wM$&nH=Fmwn?@=Op$ge6{SDj0lg+~=s(7+8I@InSK zN;qYfL78PxW*L-Ov^YZppbeHX%b?7n%@vveEj^T324$8(nPpIB(e41P;0VCxqRcWV zvkb~CgEGsY%rYpm49YBnGRvUM;yn@Q2%Vrabb+qW4Z1@RdO#M0peGyy$3idY4Sk?5 z^n?B|00zP!$R-V_(=Peufsp! z4S3Uj(*&)hqqTIjmX6la(ONoMOGhSjWI{(KbhMU^*3!{hI$BFdYw2h$9j&EP#v0_i zJn~%uUi7G{nDem=M7wB3|6AIK$%B66>Iw2(0r|*}9Qn~+ezcd4_R>91VykVmMsf6e zwlS~P0!pXbSTAHvyFdA=z`K-&>6ZwSLg=aAqYJn3qsHnj)7yL7xacc&=>kae;5D*VGv}Sr3WEJL8K^% z6a^{SgOuz+O7B=R!pef0Nw&7El8vo}Zq6YrkRXMGFY3I)JjrQ6_k zpuCXofJJa8<=-)K16lMJ84N>UDCEE}$c5uzI1mPzFvx^KCJZuRkO_lK7-YgA69$;21*gJka5|g;e}&O-CY%Ll!x$I~VK@iQ1^hcQ{v8?rj*Ndt#=j%u-;pQ4M7Rhh z!DN^M_;+L-E`dwoGPoQffCWdcJ}}8C;y=Z(8rA^TflQ4|#;%d?hX-ICJP7OIA$S;K z@CZB#e}l(h13V7Y-sC5N+ME0oY=Wm@Gdu&&!WJljt?01lfE=Xw@VoDCTS037&5d#c z1)+!^tobPJvNyDx3zV z!x`{b7!7B_S#UOtfw2&VbAWmUcC;Tm+K(OW$By=6NBgm({n*id>}WrBv>!X#j~(sD zj`m|m`>~__*wKFMXg_weA3NHQ9qq@C_G3r;v7`Oi(SFyv*wJOMGqHe zPv!Yko=@fZRGv@e`BdI8R?ma;;Q|;37s7a$02ARlm5l=D~cp1#X1` zSOB-d?XVE;fJJa8+y#q)xWuOyRUcz}13V5-!qc!Bo`Gj!3zWbM@FKhfFT*SFD*PR` z!<+CH#Ni!y7iiy7KVbb4d;*`sSC%%?H3@581_fx_&}b9FcNoRm31gc_@f}97YNFWY zQH}NtjW!^y6>HitD4G2laY~)0fci{pMBD8#lF_BC}h@|{7R z6a8d_H%Qb)4bsfOhBinm1J9+XeT+qy#?hs5bZH!28b_DL(WP;8X&hY|N0-LYrEzp= z96cIGkH*oXar9^$JsL-k#?hm3^k^JC8b^=D(W7y6XdE3HM~BAIp>cF*932`*hsM#N zadc=L9U4c6#?hg1bZ8tM8b^o5(V=m4XdE3HM~BAIp>cF*932`*hsM#Nadc=L9U4c6 z#?hg1bZ8tM8b^o5(V=m4XdE3HM~BAIp>cF*932`*hsM#Nadc=L9U4c6#?hg1bZ8tM z8b^o5(V=m4XdE3HM~BAIp>cF*932`*hsG)I;*@W3%C|V>S)B4LPCks2598#+c&hZ0 zm*V81IC&`UnG0)S9Xv-oDH5&Wq(cFz@q+n1zLpQo54BIIQGaUQu6<^HrG3u+7t&GM zzs!%dFU_ww?+f!C$?HDS{KVbX{LtObT<&gfe(gSr{SH!F_tEBO?tuBVB)dD9J0#Vr z-_$jWk%AbhKbn5lYbiUTlqa+4r$iY+-v`Ov7y3be7{Cm?16dD(Y#0nfAYYQWs;F}) zEW5Z*GK(dx+SLYFXLaCBQ;kwZ_`k9#S%m*98&64gRr{{8eJd%1Gm6bvpiC#vmr(O6 zq2^UW&8vi(R|z$*5^7#0)VxZld6iJ}Dxv08Ld~m$npX)muM%orCDgo1sCku8^D3d{ zRYJ|Hgql|gHLntCUM1ALN~n32Q1dFG=2b$?tAv_Y2{o@0YF;JOyh^Bfl~D64q2^UW z&8vi(R|z#Q#)*KA&FY#7Y= zJ40Y7wIdCqV2j{~DFb*z+@h|}YK{2d`HLw=$h5O)scmUSHgRmYRf`=gnkHDkwH+T#-z~k@) zJP8}&DcA&0!)AB}o`o$?0$Y_M8Nt)XNI5=6%JIMzO`#e1IJY_L7SIw}!4c3Jjs!onfws^t z5tG}qJ_s$H(Y7K1R>+F?x=V(Q|x^ zp5v2yLm%i1{h&V#fPpXwvdsv3Ac7uY>VGESNR&yJ5x^2|yw#nNP`SJ_!BD@4I!z=JA{N3Dx zr)UqJqCI$u_TVYngQsW@o}x{7FgD@A*n|gT6CR9BcrZ5M!PtZcV-p^XO?WUi;lbF1 z2V)Z+j7@kjHsQh8ga>029*j+RFgD3wNp0kR!`HA2{sZ5@x9}bO7ruudq&AG<;Zp{~ z5Eu$MFbqf!h4fHJ4~6tlNDpN(EP>^44;ZilRsv;|QV5h$3S|`U6#5vc$;U`dK1OQt zF;bI{k(zvr)Z}BNCLbd;`539m$GccQMr!ggQj?F7ntVze-hp@FJ=g*7LmBLZ58y-i z2tEeN8AfXIDU>yg)Z}BNCZF;pd}Q)Es+ex0We&)3`qUC6;MEsj=2v>ZEZGttIER;+!Kmr-%Hrc8v6qHZ(C@J0X#; zohY?t5j9a<(=SPLaLZC^$9INjnY3aBM95VK|5V+3ep!YxJ#*x=|oW+5)%@ zZij_ZrgjIv-wAiIzZjOVy^Qs8xCaba!LgN42u17{!)mtIzU!Joa_(USEgvIj`S5n`VH7PNqiFf?dhYqt z+WXVm!;0H~Yj574)*f1)QteM`4@>J$Ywu5M?@w#*PiyZ_Ywr(X?a>S9O?=gF!P`&@ z|AaW;x2FD9secjmH-q|{LH*63{$@~rGpN5A)Zau)y@9=IV6W2m2M>9WK9@l};X&(N z9Yv!}mKKBp{6PwB>n>1112=fU3mH%gY6GoRcoV~T6T^5D!*~C;$v+F7tkZe3-rKo zT@N?Vino^aV^&K-*!3#=+u=2M%Ut4m8)y-trj?D~nto%nAc^Oftn2lb%=G=xUb7&4&= zG=*m1gXYi*j)2zC2|7a;=nCDSI|QKzWI+g!QujkZ>x-LK8hUAxBFb=PB0L^O!Rast z&Vh4b9L$3C<{BZ%H>X^jw;Ym;Dyr3139Z*&6m~m(!l1?_D8M z;1R9cE?R*#(Tj05@#^$Yq)mW)VWs5N9OWH#S{Ou*@mM21a!<~Va(1?JO+#Fhyta}S z`Bm^1azRfnr^W6TMn71CmtVXqN4~P!tw>>dMjP~uHe^zme_CU%&@y55W0dMyvM8R8 zek44VL1=XPCYI4RQOH*xs}JgIp5_+%+|WydJLD5azm#s?qcBez#s49mHp)A@<>%T+u6y{y(&$RrExAo-l|fjY_F=d6xfIYQA5&Y=3ju4B|F5M>#o!HXiX_ z=NjUQe5-9GJyjtqPP&O)6y2}Ub*#5diSID|e1pWb!8Jwc>9;1x}R?^wYK4AGTLJ@+%!M@ta7JNeg++H>78-^|99`9-W#$e1@ksi|!4s`%YB4lb-h zf|6;^R<@O*L9>pyGBI1%{4Vt_9Fxq>)^EJYz?ke&vCYx%?KN9|d&_Kq6qVUmCTzXU zkCJyu6eQ36C2MoO__rw~_7Y!a2gyDwwC_8a`*BX{RqXqx9yw&&+Yi~54)8+e>*5cI z-FwSX;y3d>bB}$F`Re}M!u;h=jJUd z>&FVWh%Iww)$_PU*glq$R@Q-o%TZOYpWII$r~DdCp0OY6aJq9Ua#rPEt3FTVUw@0e z3VHqIcQ^BrWt4rbDtl;s-m8%u8-cC8eUci3o^sB|yi9DE4?f@_ z@W+r-%kr!Ubeb*mThi}i@q_tcnxCs3+H0v)?d<(OAoOa@XD3T^yCkdmywqPR)%X~Z zBK5Ze+fM%1E-CQIRQe=;wGRrdB|>?yc^EzM3qB$>t&cn1MUwe9&2Ql}$9~mAm1D8m zt!q^~@+%xL-$-|p1ChaW|37@k4mxi}4_}z7UYS1A)jsCmsvSx>V7`*F6HVDZ%$p*& zC;bxGQ0&R;~lgai07r6v?UfWJAXCN`)0PlaV)HGd}bbmympA>GfGRh06B*WSkFUy^e4 z7j7P^wE2a>NdNco=@0+vM=cBUSHG)uLtzZqFu<%GodHP!PI`R1|Cx$&wtmpGox!NPAs z=@hk&<(sX<+WrX7>cM=>4bK0IlUrgRAI@g``2H@_)S}D(TA@$uLy7g}KOMk^txe4i z)bfO9qWp6prBu4O6){?Vu__zs%hM_+RogY6O&&56$t~`pTT)GHvWU(b9^ z{HLl^)>Kxfzh`GtYp@B4_kMVv!u zi_MDvH|0pOOxS8)!*teCDD|4MU+kq1X+=y{&0BaWH;exr*vFQkz15oceZ61y@8tFO zaTPszUirE}Y{|}Ua-ICu+TT?XTjf6K>esRV5>5W|_q&A6cB+P(qa{VKZvdU59i)4F2bGeLp&V9Q130Q=`E*pVM>3j=5i|i`N^ZyAHO2au$avs zlkKI+-zu+b4t>=cgZ;m%wqC{64zk9V&v*n(@PQ zeq`V8puO|ef?HVz?KG-((rGfM?--O5;pFc%ualmsiZl3M1F=`VOtr3URGj&T+R8pWp*7z< z$m^b1`G4k5*8VY-k0$rh+Zf4T_q9%hNlhzFQ_oENn7WtT7P2GC-|C+O3xh2^iS0H2 zi#}A&P4=b@>{B>5J57j;>c^5ts!MP3mp{gO?`6xOdxrFF$cOIwhw4fdeR>Yn6@P!% zsi}ucm9`Sy33+l|@gSSo3_H7e`Id9Q`pwG-@P5g#4n?h{RH=tw*g5vwuNWUh1mgE- zG1-xGsC+r}xX+eqDEALdGE`VNhvs^R?<(m$O9yke)Ha;G|KGN!YC0#Wp~Ua$yvRxI zCTj2KD8o9hd@1%5QE@EY2Rkb}-SNYHtfm}QQ%ux!d|%l;)R3y(&it9O`Lk+=e}4y3Jg&b^;LJDoc^_?`XZ$KBnE&NAO%Co>j9&YP`ItZa zdO`gqwiKiGmVfp`iptMDxQ&|XJqLHS-`Bs=^(X#)h0qSoLA8S(k_S2H{M+e}-2M<< z#N2*}E+i%W@rUYq;yQ^xf_^-d56rcGJBdD!2RUfzKaiUoqI1p9xsW1V2Y0fyLV8|W z#JJU;OHaue%1CL4GKz0NIgM{ePv;##A+RF7+|nxWQmtyAl& zbzSS#25KYMLu#hl()EbiT5avxsJ2yyx}H*V)m+#6>LhiBt4zI4z25bWI!Arf^}YI- z`h?nE-Kg$R1L{uoWA#M!Q}thJzWSB=wR);1X^MJ=mZ4>+XKHn{2I^T_BdxJ|j@C>& zQaw-WrwvdiX@j&u>c!e%ZHTICIa-c-sg|qds+VcQwc+aJ+6Zlg8qxB#k?K_KOzlkd zN^OibM!ia#piNY#Ym>A~)EV04+U4pjZMrr?ySC=>Tdm%s-K#yIuF@XV)~lGk zSbIhLhx(}YrWRK>YVT_As?TaWv=7uR+Q-_b>Nf2Q?O*Ck+E?0F>MPoJ?&j*N?pE&B z>UZuo?l$TV?so2W>TY)jcL()Jm^`k1wD^>9@nxwPkJ_L$9T4QUetPdUiQ4K z4f1^D`C7~NHt{ymhI*TOn`=4VcHVZ{FmE?+cP-aD#G9iH_vU(YwG+L3wUfMg-aIYO zJK8&18{xgsd!hCh??mrKTE6#6@0Hp}?`-dE?G*0-vI zBiDNvx&CIUALCjqlG>41dEXHj=N^UE+mFmcY7*D;9oGo9@h&+rz&Rhyp<*TKm zi}T(ej%W_z`~iS4K5Eo_&_ z%%mW1m7kY7$=jIyv$Onn`3 zex)O`m3CITuV$wp!&X$!W$`Y=2x3ZKY%amnYXE|eK%gSnHjZ{ZjtK27bQ|?zD zV0)diPRdXoR32jcVI{`#M;QI6jP-Xem;}-*>G!69Ou2-}d>v{o_qO`#bBbeVsLX_F8+-%-Tq^UabfHvU(Z( z4e+|1sosEp?S$H)cA(^U;a5AQb}Q`ar{05q?UXvC4uL+7{h*{ep-!MZpQ+E#p3l`O zaK2DqAkE*g8@-x+YuV#g<`Ep3TRZR-r|GFV5~5x3|}-4gWG`fB0mR=SmFtgq2+ z!MR?y6ZQ2Cy1hv08?ghFUw70UQNqpoW^g*`PM~kmgTNoGe2aV-bty{zsjdP&Nk5K#tC#2{B7m{@3E-3ZDbWxk@(SRydX*^A&+F%rceVZv zVqefNpgk|@*FbO9n?Y~YTS336-xB$HyM7xfcWCTVh;e+6sH5N4?~B_oqVI**sr`Dt z=!!A@@1l`DqtBpiU+S}>4vp_3hVk7HQNx6H@EpT7Y++!m_lYbc!^i*~gjaBhu|5|& ze$<60aAnjt>VuyTZ{Xjh(O>k$=${go8wEyzXoPXT2x*FqwjyX;j~%0OjCMvl(apHQ zxIuI$4T0Tip&{Tk92x>Cr($O&ZOk_w1--ynAbJ=-H&%&k<9XwG5rF3Sji_t9V5|{E z&>z1QCB`~qo#7maZ&Sk9fSsF^@rv;(=uO5Z#J*;1M(pd@#Yq}l zu#c0^cmq2*Nn)L4@6UFwtXTKy6phyKVa`C1ugeSr1=>8KQ%QD z8;8Xu#wW%nIQJvQ5#;)l@h8xKHvWurJO;n%VQ9in#U;>%LKK+NRPb;PjVS6uBWA%f zx*z`2uQmhlpnk0xgb($bpd&-#ax=%w5pAF;>xqtL9(<@@Zq|nf^{dS=e5hY*M$8B} zQ8S7(F*7CxnsGBO20^D@BbuA7&DNrYd98UZXy{ka*PGXaZfCXw4J|9KA}tGqmIY!@ zUF;Oo&Fn5hq-EiK4q6uU56vHn_U3SNxM&SsdzZM<9BGaO=f@bQTbtPBUff997c{i5 zXi3^vv?A>boMKKv2~*9f$TiKJCayE5o72Vh<_vQN=m*RPK+l4o_Ill6kIItVw2@rOJu*hxz z!fp_Q$!-us$ZimK!ft3NI#`XYD-jFpLBz>=0K$3z|4D0^2wA_fo)-0BL%a-ngY^pN z-PU`;AWK5nWJAD1JZuQybbGom>>2h9kwcb*s6&Azl!s z{i6M%xST8r5rZWGZDqd-FZ9LsCVLagd<~xH>)4y^&4_&+-stPtTi}mAO4bH;!v3@U zXW`h#Z0v+YHix)`Yz`o74pBrl2M{&~VqtTL0NETOK{f}{z~%rSHV62yIfNo>L$o40 zLo^~g12_-DUjd@=C}tI23Z@jmbD>aZFqKuWIH2^Er~E&5^=U9a$!mQ0anXl`3dkS ztcpywD(bUUVY5}CVO7N8aWVlLLcxYWjp}+>5)zg~KXBj!lWm3jMFX}H6s&~#h<#K& z3QJ-E?1PM2_CYq=2T8UMve`b!VEZ7O?SmxjgViEi{RUP-JGK&%uo7MZXRZ3Jh^Te2 z6*AaX$Yxt130q+UVqa0OAa;{_9k>N{gJioQs@{R!AlYunfZc%6neB!Q^)dF-kZe6@ zSP!3}ou9*2&}=J2)R(XlG^~VHumi{%&}m?$?@he~5Lzgzm2r8LZQk zGwc4VjrIm?O``{Pc+_kG1PrnOv~iztpSaqXY)nQ87g__I*czzA)_~7gja^1cU=zHE zTx(zxWEd|QFQqMmC~Sg_;J*qBAO#CxGx)DVqifdasahI63XQ%C{CAD_gl+6G_JaNZ zT0F&CJR4g4Bjh~*U9OFT(B+zSdB`{fU9O?aKS4W5e`gp+jibm#THME4JcqToq8Tl8 z4C!yl`dhO8E@AzB1?%rNtiPMF{=U+zi(ODk%zS9>64u;Tu;y;Yn)^!D+}&7nw_weE zDQoV=thp~`&E1$a_ob}48=E(pH$s!$WZne4*}NHu*%_<>%#zU_(&Ay(;(qgXXz{vc zU$ZZK!jdlcn}f{3;E+bYhBbOq*658`qhG@sy(w$-)~wN+vPQog8odfu3+Zw{>+(jd z%bT+<{|@W&<|gU#@31ZpvM%?VbIrM^i!{2QHF}UWx}PWkWh1d-y@e-@-b+A$0tk z(E6m^Z)NRX%-X#nwENr8);pl%Z-9>9DH=k%zl)UboA1M>A?@CowR=m}?(JE-cVO+_ z(mZG$goQzR{W{j`x0%PxW1{PMx;$YeM0YD?r9=7ME!)j`aYfIj76B z?B(`y=ylTQnXJWgp~a!6SbrO=zq43>=R$vPN$Y9NdRjRNCn-|U(@jK%b2+rNW^Jum zTl-jBYu47PmbUh#wKY}G%}uc+EOqvX0lR*EQ?)T-NJR-$dU;XnoS| zKGyD<22^NUjGiJ>V8*Nq^-9owYn*kjRc=)|^_=fJeVzW!0AIit^wss{`PRZ3s6e;| zIjaxrx6T=}m2Om2~Xl_%xL(6QIc zPt*;nBUbA=VMcy6R`Tvp?NxvLcg9NIKy|AcjJbFhnu)6)L%Tkr?k2qo-a=@^YG}d@ z>T$JEy{eW%@4ca(RBvLgyiD!UE!FeTUF~!(^wrHeqB~>dFG=%LeG_J*lfj#+SLxen zrm3qj$J_?q+xlI-faa1|4a6)GTI_&6seh?Y>C<`*RtL{wy$|k-^*ZPrTfaLpOkTD43^ryyP<14H%4#Nc3z)E5rGt0Qk8e$DGMp;9xp~g?FVb(BXwDlwFN56x;H{)&lOZ!V>C*51d@9Ew$c9A7)>~c|AJ83U?58`- z_=xT&baT52$6yuEVVc)~1_RaUrHx1u9-^-@St8YHvG2f?V zhVL)F6WHC$j~?F&cSl=<4hZB0v^$Qy5&9tvK^TreZ%mHG9a4@^h2K+QpH_k=zwB-U z&n#tp#To_rY3A?uQS5%=pA8ay=6MU2ufXyhA*DIHQ~p2B#Uy~mBL229jf3bC5M4C1Xv8;RY-9}IVZ_Q}mFLF_(J)4NYh!Hk zg&a5bJbs~&~zj;#f5h;0ULi|qvNiR}j-is5US*ooMwbG5kV7_X_fs87)V#MX^Ra4lRq zRd5D$m!h8k8pcchwe+>iKbJSYC61@I{{l9NH~)v}TgBTVO@}zfp!nE$Ij|}|6*x0K z2RJ{z2)Hzk(Ivhz{sM4ad?T=Xyf?64d?Fo7rbC7nx- zfo_^;0c@RU=jC#1PjpOl0l#OW&pCc#060ey2QP|4Id(*?G{V3O zfzuMRfO8WIfQu7P0+%ON0oNp#0oNxs0c*EyYXY9P6T1`pYVpw${5tvVSkkJ9BzYc| zw7rnSX+U0yRCRNh8Amw3_f63t_bY@AKb~)dS#ru;jg#hL<<}1nl6v=_eGgEWCShtqc{L~`wm!_Wf_^B6C80CN~8D07x z~Y*Q{~K7l-iw#-w(4B^lGB{NQ9)CZUO|kp5EOGts!FDU zZc)%0V++&&2n*VIDGT}(3_#wmVPP&^BNq!g7GV7OdYTJ3J-;bU!%)LKT<v6+KY8l7K|o7Sw!daDYl@Lv7(^*n_|Bq{j`Ev=Tg?HeQv=5l*ZU_0a?NYPg3j! zcp-mz!77x->1$)bnu7HnzhD#BvY_C&$Elt2>$Gc!7i=xqf!f(7Pvh=_ec&AAR-L5M z&Wo*`vKH;y;SJX`r1pFR(tNEp-@D2*7xw6L*S$~`I!Hg-Rd>uD~=nOrynB`hgiSc9_*=he`)`TvMshFseUcQUT1!9(1WR~N2j z&IZQKjBd*Rzwr?1&B8;4`w6*ihxndlx2$8%)R!8ZkLXJma4txLGk|7v=UELf5+VH%#^H=(8Os@~7^gDMWSqk|pK%f6 zD8_#^7uSoLpXS^v8DAizw&5Hh=Nim_EsZ|swb_*bc<7DiV}o_XX|NGtOM~r9?*hHI z!2zPF??If=EN*`- zY;gKQ&ezlYpK*$)VDWWqIG1Z=EV9ngwfX-Ux?y$0X*lQq8bn*#@%h%qeavxj2h+GO zkdl$+8jzujAy9cmvGdU#Cvk7~qm<{c;ijTO&fAEP>cy%l#wZ+lyeYw0;;ydXh&!~X zT~S9Muc9M8YH`tBGjUhiYd;@B+R>E8on01HFkQ_EnS+l$Ry4P00qS$tBzd*L9sP>u z6)!~Ws^UsYS?oth6s;jF9tQsUV%Q$V%?OJ&6>SC0>G2ElDcW7M4|uTXh{tzhYtkI| z_(dm+&VVkXTGDAIQ@v?=2Gg@^ah4P>tBFTesZd-9tabwWjw&FI$?Y;Ck zc0%Y{+zZ&Zc#y|Gzio7OY0k*vF`)m|wyhv}A>V4owU8}8KXOOu^z~v6(ro55wUEZ) zlD))5EJolG9`}oP7Vp9D{mea7d=&gzt7PY`;hZfoC{;-YW00{fV}vlRK~hY|ymH+2 zBK(35EooKKmSRb(y5oOMpX|;2euRi0g7{LVhS$PTh#6Z_4&>P-((rv$GLzG=hIH>5 z7paFM?INVBW&f8vU9u8fe!Ag~4Cys+cV#RsJ?j7IG`!xGUghTbuR9AaIZg9oyt0aW z)1C3*h+M!z#zu@y8Cx(y{)k1J(`#t1g~NIfyb%aI$H8$H0=F5*xd=Rhx%@PTXMdod zL|EQ%70{h$ZECocYr&lY-fqTyj0b^IudhS+8KK>VvCs$FNKmX%k7&G0McAGB-6;*; zlqPIJ99>QvVBdZ7 zMzp?{XkE_qHB7fATJm*N!?^ZGS%%arWg(YeK^*OJdN98gz2~C2Pnwqy z$M`#QCUNX=rum-LyP3Y!q4a&YoJLG@FVYP-eH-E{AJOVMLb;JStvI&dmro$?ATFnj zIOYv!f%-P$$bQ_yTPeNl$9W%OdKS|i7zccbw|ZnB7dcn(OT4Qm?;u)@`V#N^s2$8_ zIqM=S!FZW#$zlF)xy+ZiG?uO85mIuW*FU6Kox`!KIpqMR$5PwmN-pgrada7{ETb~@ zVCHmUPFL$IaE3Fd>sL%WKsoSByy>VHa{A}F%zn&ylsM)HrkfD0VpNWLpK0#l>bp$; zm~yFJMAHBX`j3qJIJO)7g-W@HX!)AsN9@fMD__e1t#9J?JWL#2!6oqhsNEjem-#m_ ze+Z|%g0VmIV@$^=)|f`LF^chV#wnD>n8tY>=B&dWuu`X(<7y>Md>v)vvoqQ`>yhh4 zP8sEt3651M1=19S42&fwO5$ea?+`xtZD5~{Pz zpUGpz{hYoN>Xb$cu9xLwT!y#Rq~XfE9_!H2oN_#uHil@mmO1WyRK%S1gnwT}h_^$5 zPK5G0V~N(w35`}<3+KYuG~n2L?d<-P67S~{=PA;CKGt0JQX#?Ny!Kv-wdzng_727s zoc;l(Co#=;vAu;ke2r|r_w5Hb_HM3a8^^AubF|km&DYU>n(OVzoO`%l)`R$d2Q`|k zRqdar^HLigX8K8zq|JTLUP5E6y@bY6Aig>PQpzQqav{^dB-&gBA3_+J>FOJP;^@;9 zt2Z!xlCcYMgWpm%bg;c-lFq*wn@zP zH^;KwsJjwJvh^ifOeRFIn!O34siJc znA3?lT?vg}69@0h0%d>Z=P})n_>widI>`L4oHtI|3SVF_65lRlx-HeNwh>2hUp1Bx zZE!C_iVTq_4hhx1L+74y)rf&(M#>jNYT&>TW7NpOgcXAa-hHQBKX~w6qtvv)W1C(f zHvyZ;t-veg4q$V+8+eu62W%k^0>2}V09(rAz^moS!DFvzD$f9~P-@7CAO1i&Lq`7i zF6AFOdf;H=_=q159-(p>^BEH(@40KVD*Ey0AtTk;QTU-6-%TxPcJ~iQ4plAhzPsra zstvH2Y7fMl-oWOn>)rPZx?A*PyaHNB>w26F-vMi!gt(FR14JxUtV>=yW;)u{nSu|kqBcEDi9_k%tV-run=LXT5brn8edLr zQrpxnwND)~a@8?)Qk}&+*_QTS@LyMd{XeFQ&iikoTj(~rgYKex!9zqpJyegxH_sJ# zgM6Kyspsm2`0{Bv-Yj3IH{reVU3#BBq>t&NLg>Rn-*A1;gwgoc+d3zV(cN#mHDSC! z*IhI*P5}EGr-1|TEseyNs~NySW(YVK-^t*;VlxH2(`*DBYBmG@z@+z(hvDm0DSpW1 zaPJbAF&$v^GiEWeWd)A`RO9y;hvVBMDf$@0fZsQU0{a^D{xbJl@m)^C_blFUrd&OM ztQYy%3-3G&d^>PCyt-Z~$j-JAYv3C}8{ZOS(ES9SykstX3*eaBMX-i*Hn@Cyg!|n= zRv~gA9%pRO5#K!VF*7gCr#gtgG|ew_^BeTGHSznp<$C#y$?5cyT|Uc=(zUAj&FwJT zqJJJ)ZGbdOFyH=eH|>eJiP{Fr;R)9v+jcH3tT zbC3Ga`A3tk1M=ewVm@Zj^~G=B*ff8%dqnvx7k$kAZBze1I=f9e{-Arzp6?z*P1F26 z>3kLL@0?!l5$EOB*CKr$81uQ_0qzms>GN;B+C5^<$-mX-?$N||IF#NBruhflqn$nv zJtNKEp5`x3ANO;83irz4G^Y-2ILY9~t|ON2zOsEe^MAK#kBL_(xQ zp(qk1q7kRPF0*}R@5~XIm6>zuEY5r~Yo32homF)2Kyg8dw=H0$YRKv#R|a0}+3}K=Z(%Kgm}nK<5k9^r z@GT;~Aqc=8$%YrYx}qM=qdv|gjB|4$;@*!;%H2)`I9tG zdVexB3_g8jsG97=P*<^+5tdzOGJPWznt|&nLo_DK&~RK+*(3rg7e|B;dViBV9C7zB zpK>%oFc2CeAdcd*akqrJ2o>^&LcqR4;oCp*!h$+4+aBtJ#^5_#{W)vBy6LiK3Yq7Q5SeLC6*vbtA8}-;5dxhrl?1OX_(k&BeIBO;I7`x; z+c`}kIP+_9sNEr*3BK6J2Gc;@d(J#5>EE6?D?U! z*_GLyv%6*w$%b6A4+#g?;6{8I+TR>#R`T^w*$2UG4{itZCisQ8IenF&2ZMd-Y_rK$ zC#k7mcd9qLP5QUYt`-JL#((fhZcjL!t}w`}bx3zbnv)kigJ0C-W7i5NxS;lbU_Zwf;GEBjEOkKrK^;_o z#Mk8?tHbIObwvG19aVo;#}MCIWa(b`(yq7up6;W+ulwpdU^DgC1N1;WC|!PE;S07t z|36U4H8e=4XeBa&`4|2N-2PaMGYp9Uz9hZWYHVF*HL)(Ynp#&_&8#b}?^rFZ7FKiX zDzs#{2;i&a0KP_sw=FXl-yqk62a)=?6Ye$3`HNl^Jc=|4Gij#G0<+L;U>2FhW{KI* zY-~<4tIhjNe6byD88{poldQlfE&@lt`5&n^iXcjk;#=l8a$jaPF)zpccDdcuzQX>Y zJ>32gtREHJC$i18=5OJ}qLp=x)!Mq&YGYkzwY9Fd+F2c}o2(nH8?5$7H(%shaVr5| z7%6-=TWB?aCyZjNfhtrDV1F0G-&#ZXS%X(4bs22& z%T-hOTWf~zH=C=gV3~hMwZvDPt<*KDwYnB|I>wan263tRx%pS~r1>}Vl=+2u+WfnD z#{AMeYhqI{OIiv}i44oMEX%eW%V%X+b*xM)%koGogWzt+ncV~9uJ9hx?U3}GT4l(aEhnhdYef+4o0N+?IG#@h;nZGa>;|^Xz z-&W(x>gDhdvfg@`JcQsY<5#Us^p&yoy0yi6!-A)dy2t;i8irl0j*Hij(?K-(cdymA z-+%DRr1BcWBhKUhgq;43v&i#f-1;|r+{MnKM$5^|cZgPjc7YCoPJu3g?txx`K7oFL zL4l!x;enBX(SfmnvOq;cIIuLZEU+9F+v>oYz`DSOz^1^K zz_!4Sz^=faz`no%SZ+rG#{wq;Cj+MgXM-we1v7&FU?^A@V@@oX3Kj`@d2iIbBCsm( zLSSuRePCl?b6{&=dthf^cVKT|f8b!?Fvf!8fzJb{0%u?a8bK$R84LzR4VDKhgVn*Q!5P6> z!8t-+(@tDhe|xsd?vy<_R1%t+7sG9FBs4d7U3NyOd1y)Y%$$Ywhh+OhZL_=Pbjn#7 zI-R#W)H%CXXk~WaTqm?X-^mVT56Ye!>K)pa*Co4dPDW0C_QKq**~3CZavEhX&Dm6c zes+FnRQAZ6zBzkxM%K>=m1mF1X_Z};TN2s}4H?5IEDK=Uvw# zG)eX4Trw5{k5|?3yG6Qvu;OA!3(nOpC*()nEkp1s1Kw5CqR=SJ!CK=utQM~?H2zWW zde-7~1aBXBZEJWcbU4%;-0djKE6>Oqo;N%+Jh=fW={XReb#gg=^%6Ri8_JDzW*+Rd zBxZ21+EhqHS~3r%z@{`Jg?UYqlR;5lBNEKZPmU(L6oLL=J!WqVk*#VG6)j5hRzx9z zkSBrfq|X;)e#nWqnjdmTTABSh@=c8#;WopE9!A8K+31nkyc&rV9l?|2smNaJ9>HT- zIZcHO9s&=?G|#M8M*rDm_-ajNmx1fHa7bb;eh-1=mdZ|r`!H8$XJ%(ca2>Nt@taC$ znQjqm2K41Yk>1R`Ce7^(Zs*|sNIT|Um*(Qy2Db#aL|}EJgd4zB!BzG=%ur9F#W;e? zXJA)>w;!9()}*6F7qwN~;aGlwg$Em4gRfM&Z@vF3uhkaf_PT~bp2IefmI1^3y(s2 zx(}p2NW(odV z23qF3s}eUNMh9Ynn5D4dF#z>5g>rCB@yjp|gWmyZ43|bs7s}&5;y+@dUO#4OE`=EC zx5~fWM6G^!-SKaCX~d+Z9!Lo*kaGY1tMl`?b?uOg+6{vw!2kaF{`vLKa2#qzITij2 zJpGi$`g1gL+{ST({DaUwnRNznX&P~9S@>59+rKjX-@)IZ{*zqlO+sCA2EJ5Yhd-7# z%&qXYwhew*wv%@?^1Fh6_*R4OmEGoh^Mv`C`49E_uzaku?U%oC4@`QlNrK>S=R6px8T>ff`czjdDV?K3Q_0eG{JOTBrB zQ2D*Z)8?9QYT>_Y#hEY3S^wUg;oIht-!_NT!}Z;IJkQKKSq(9BztH|4#&fsH-YlOpAK1fcW_6fMX{>;RF@A)hO(W315kEp*1R8H7d{Y6f zhk(kqY^<3_{E=M5wnTs~MT(h>j{>!vsfXd+9E^_ay%*yYR&VOU_i>3mnCbfPeSE1s z6tt3g>K)X&6#*k-csl}gEmoH1n$O@UQ5PXv;b4@F#>Egv{1=Ybd+t8SB2+>7XTMfJnHL4XMj7O z-agXddErIj*_0N3x!^?)@qUw@vzg&J;fnC&@XT;2mB-%Z1ibfr7b7ixW4?POmc_`= z<0br?ygv%8h;+j$oE@GTE)7?P2ZTq2hlWQZUZd0?3JsaLpzE6ODTz8A{Ve>6bu)XI z-^Hxom5c*FW3Ay|>^kze%{+zH$Z%VfLb3^>g;a{6C- z%#Eyw42@JoT1C1>LXo2Iv4{w-3vUZA4zCDLgGBdldE zwgL7)docQ(C!O6P9T^ZA7Kubs;?n4d=-6mwbXs&ybYb+#=*sBY=%(oQ z=$`1o=&|Ujn2Ke_>c$eWMzQ9xHnEPe?yrx$?8Jibd9D5p`N!G`z1JXjODZbZ&G}bXjy&bX|0FbVqb=^icG8 z^fa6a`(t^rRIG8VMXYVCQ>qr~T0Jc+^r7YO z1Nppq9)2m;s5S84xK6Eu7s~Yt-htU0q=~-uDx6hqR-3Wh{5LzO%LTHW92B9rNJB0QK9S}MqbVBHi&;_9@LU)9o2)z(`BlJP&i_i~Y0Ky=I zAqYbeh9L|`7=bVnVHCn>gfR$X5lRut5XuoM5GoO>5ULR-BTPk@hA;zRCc-R)*$8tG z<|52Pn2)dkVIjgIgvAI;5SAi5iLeaeX@unnD-c#9tU_3g@B+dbgtZ9kM9YMczbJoM z!k@n?e_bLke=|mcz4?dokGtbUB1I!bqA`sX35*uuPT`*6e&J!^(c$v&tnmCq zr|^>S@Ina{oy0w&%@G^S9;i#<+Ae|72K% zGs3y?ZQ)qBB)&V`EZjPNAlxC`HQXmWBs>x$cU5>sT-86DZ^bk6{rPp{q5N2WQEY4d zQ#j9yUn6=}SZfU7F*(6wav_h&4dA16G@gp9*Z^E%g?kxmudxxhswy@b*HgtxaV1r( z0@qOC9-juBhU=zcvv9>!Y%Z>qj-8C1iDPt%!_jaY&n?KiQM@jWx$*pX0>@aqFy08G z-Zi*iugC1HCp_Za51kGFxA0x-hqwF!c_}pOSa_+efOpzQ;P?Jjc%yv_p7H+tjiY3K zE$i(H^D6TeXswy%gYF0pE%h__I)27lW4&a(X>Ai`Y z&v>8sfcVh(i1=vyf(Ie;AH@ELtaxR7a(qU7c6?rZA>!u77sMCGpNub$ufngV$rllM zB8ua=dwfHDb9`HTXM9h5zp&%G1@cTskRQznhNMs~}iMqm0WRj1c zL@WXCK8dFIU6g2?XqIT1Xp?B4=!D;G6CDzr6WtTN6a5lH@Vif9Kw@ZOL;_xR5*5Np zj7gLwDif0vGZM2C^AZce;bUS+Vp(EEVl`GG9QT;mkl38qme`rtgDWcGznicJ<98Wb z6Y6J$@51zn{lW*&Dx(rR$TLskAUyUQPn<-$!-->w&l9JU0uHS+lc8iD(q$xr$-2o% zGLNM_ zg77KREZOp#+s0mJ6z0Hx#aX{$o&;_+{|0;$?-D4?iN658Wu69ZH~$XYVSWjG$2<$% zX?_L#J(eJqx!Zz$XufB`b~X1{u))mtEv$K&d-3|FGC#1ewq@?Ke83N_4B$r=R&dM% zRu=FNSSL~DK`Q|K*n$OOeqzD*zj+k4sxtp<Z!|0OGb;xC zixmN$pl1@0&Oef1-?Rj?S%M++8QA9W^W;dn`fU^I3F-i!r8a+S?y@i!z|-A#u}-3O zFFs3cK5p)}>VV(aJY;3#7&QNgw=XoFZXLFAaI9w@vG4>J&$<3&VXVSj&`sBY&qd>j zCY=rW!cDgxKANA!^`~=?+zSlu1>^<48?G+(0w4E+Ozs6ad=9zX`Xu*)OQ{$AGovv^ zJillHOF02Q>M1)Vn%Z|@{&5AYmIR(*zixicmUkn*J~R)xl&_Hio76p9nvYz@SB&OE zP57!AuvIVTD`(=az_r5@zz*=?e0}kbLn(t{I-(b%mVr zabDap86TQpuLfI>Tn| z4O?7cI=y@Pr;7o}uA+4~9JRDf1~&pM5FkTf?p4c<&}3KH^$Jdvr{6 zh3*)T7?v0XjZt}iY4IVowOo9r$R{6q=kwxo$m<^Y+&ib`;@ji9;(N*a9eb_|CmUfz zY?W+>vATP*4@T)5OQVD?9U7kb(a z;uq*^KM_mV1N(F2TUV@tzx8fnn=#RtEI#0!RsO(U)c?qPr+mqt(9e>WT=5loHpj9C ze40zV?QDE1wdt6SG|1<;G~t!@_tJ9mochus56&`!yv)iv&fU)4GRv9jz^gI*$UY$h z&I)IR%yITO@5@|Yp|42R^)>P}lJ$L8`>vMx@FrU&!@m1`_sOX5cfR#9=G)}kBopLE zRwi*zb^g|S>cDw-Rj+^QzN&2}e$(3vjWBkm-$AIc1iUv|u>O*^Cm+LThuh)@SuGVc-TG+JZ^smJZb+8 z_;>s7KzJR-^C`nIfVSfRGn@=yrjrTu(|0RQwu8NZoU5Iyfwwug0ed>I|KJT9yTLej zI9M-(SM2`4u@2^iPMK2%ta7S=YaPtcoOKS`>AdWqoz6xF-`zN0I9~wIIA`%RQSo;N z4n9 zB;ecb+XejCM>}UgQV8^JTpirO4rU?miaFvvtg*a-*~k>}KD=}PN%o=rF63I>UbmO8 z>DToea3)$7h+XRvyQygI9$&Q9m|>i5pO&TjRtv)9?H-gEXj`_vv^ z+!t5x`;xw-+UqO!6{`F!du?iSIsT30 zs3pl!E0UwuBu5N+kfXLqj@ltPY9Gl_XOJ9q9g?HYBsuCVlB4#M9PyngI$|I8_vm)~&OCrl7FGMy(wnlbE_D7CjMeA(TiH4$) zXi>Cjv=!FQx%o5SU};VrE%_`DAO%BD-SlI0SmOG*N}w;@rd{%n(;4)?rq- zBe55H^*Cm7M$(_m!>p`vvIS;josvBJ}v6JFR&^8~8TsUeFib$VL^E6;v0@ESOiYxZvr6)dlMdwiN6v*jI44;6%Zh zLaQ)Xm|s{}*rc#!VY|Z4g}n*~6b>&OQ&>?rwQzRfg2JVRD+< zU`B+;U`?nxJOgX+3$P;eba+*GZFnPAgm#Aah7X31Vom5w#E4`@apE7jMjL82Ho@+5H!?Bu7# zkFZYroOqbl`^6)8+j57iTlIHk49~f4 zm&JHn@_Vub+JCfc2+Ml1yc#?f2Cv^1`ov zVt-|SrN%gT2~XYQ;N>MXmiDGn<6sR%RVn=P7pV&P;%~0*b6Pqr)dS>_U(JL^{#(^U zPFJU!dc?Wixm`UgTjim0AcJYNUFMY$&zFx!1W@{gOQOtEJ9FXQFz7 ztSa@S^9$z}>M7@O=LxlpY%BF^+OtZnaDL;wsGfD+aJH&d&NgS8TJ7v|cB$Vu?>X-uSmU&F9=K2CRk*bs@Jd=#^vgD+6zOyLDretN;_hx zH)$^nd}H-fUzvK#SMDoUZ~H2I6>5jC(l1AunHegM!eu;8Fw@DJ^R zy)&R){^&cT4$uw(>Y(q4?^Ea)>=2;-WZ!`2dbW)<2YS+mf2H-~ww4(}WAG*BCcrC15sw(eW`HS8$4FnfoA>gVjcqV=cE( z4&IkUUc58;G{?`jUPz~V5pA1|{|8Y!ojv{InV7yI} z7+Oy8)S9hcxiyr>qf~CJmk#Bmc|^JC9z!W^I-(Y!RSw38+0e?5VH|jhp7r9MCd-N1 zOMfm+Q-3{ zt=1_(c#rJl4KWHuR?MI)5jj^WB>HATl&}!$4zNTdKBexd6dhgD4$DF zPM7lX4&>U1H<0UdDKGCpyzk-W7>Jg7l$V!e>hgNj(*J;Q7~>Bahco_& zaRlRCj3XJbM>cYeV*Cjsp6`K!ci@3z81G>m%Q%j)jPYK^QpTS$mNSlL^Z_k9gRu@{ zCL^96fRCpKKs-GF1{t#%@gxD997a4(0DTE#UB-Hhc(wq3ea3u7JX-(<&l7-no&dxX z24I{q!I)%BF%~cuGB#i=VuU4*TqTSR85=QP%Gj9kGDg_#NO?J9Q%0;DfYXfeO2+1l zS24C={0?JFMp!9G4=V+D4P$G@>loWIUdz~q@p{H~gc$c*iT?{vORZi*^AAUqf!-Od;CBS9-WhALL_?^syVt!TDOH0_SLzOY|ZnW(9RQca~*>nDKI;r$1B^>e`S zdL{F}0G8_0#KBH^uC{tGO;MX%9$Li|jf5WM#nPzf@@TyBC@+@ArU&uPG-?;PQdYIV z2;&Tr=xI1F^f8oPv3fvS#56VLH>3%>OH}npS=x zD~vf-72a4@)>OPPiF3!hy@-{#gHQ|ZCaX%~&a%cy+)bzvcN5yspJOp@fP?YGDw7yb ztV)UT1QPxouvESRoG#Y`%jC71nf`RUj5U3i5dabg6tDI8kl|j+b;s(E8n<>RrI;`W;}kewWYd ze?XUthkzAWgG5R(3pi0c3>+tB1FNxKKzGffz$!T(I8~Ai@0SaJhE8X_7dTGs15T$o98&J*vwjA2ss0skqJ9cEUjG_6T`vb#YwGi5dIj)4 zIRRKICjlqOiNGpZ2^=S@fcMJ#fYW6)uo`Q!XoJ2LbgAwJoS<(5R_QLlak?w;UflyY zU3Ujo(^IQV z;Qi_=;5e;;)3pLtYY8mV29>W)fu5v?0!#H>xOVIG4?$Pyk)RJ_-G|QW2f&GXIB=XE z0i3RX1gw@1V^&#;;~X5Ta71rID|Z7Y%J+cdC0)zulCEpDq-$9w=~`Awx|ZegC~%7W z5I9M%0ha2QfD`p^faCRg;B@_4V72}ouuQ)Utki3P<$5b{ie3l25B}Itf}-nPs$Kw2 zz`76h%hkXt^(=5Ac67$|Q_FD9j^ZApsK=D47lD;(C9qskf0~3HvQdtpJ8FV>3s@m` z0ILMmI8nRlV*XrXu;SS|Rj#y;98O>6^}i|xQE;!WTrL1V!z@fzeb9%T>2 zuTnwsoFMK5RtS=Fl_0536@!2i1>}#Bimt`IVlZ&L_z7^jpet1^1^~;%Xkeun0xTDF zji!jZfs@3KfwSPth2${`I6+a#6^hENQdG`VRfg+(T%q6Ee^Kbe_6bFGO;lrm_bc>a z`!hx5+^eXCpA1LN9{e`6+Ocx(7H*jKO(N z#2Mei$1?27NWUw^I6h7ici~u$_z`?8lQa&OO1eHjm2}-E$Y+2RlCETxq^mbkQh&K$ z(iOf};;N#rQBRo;e>}>1Kt2I1lhivZ<z*7An@TdA` zzzO;RV1<4NSf!@}C+hjY`!$V__v(kK2WgC=PKL&~>SXHKK)QA@v%xi&&M+OnK8~9UQ7dSx&ffYIgtkOy9KRQMIM;B24(SGnJ>O$%{ z`V!Fh>Uu002lRNnwnvi52Pa$C2VJc*fn_=Ytkf~^zpn$J%XJiVf1Lw*iVlNbt9_s+ z=?Lg`IuH1=?1QsMkCNZ#W0~yB$4Uv`2;fhWU2!C7^}w+jM|dot9wvM7u?&8&TPr;c8lHe42`tMA`>JP3ryrw8?CRVI)KJp7-sr)<2-z&caejv{R_bHOX z1bG@*A-|&96_4ghfj+K~7k4b`!W}D-W}-ryaj#O_?p0KmU$Mj$wPCu#Svc7$19Y{b z_LM0)mr6y~vRu)*Ou^Ftv{2Dm+^4AhGZn5J#*ql5QUQ5H`B(0^#@9ailt=-Sk-7NdI=zV_m~~IX$!a zZVffChU!s6^{=72)lgnul9pQv){kl^FE7c=i}mu7+*}?>&ZE4%v~K3{yu2hqkLTqj zDY`t8qepppNtPbZ%S-Zfc_dMf^74{YJ)W1BB!r0hFRv$O;(2*JITO#z>&Y3ZYVuMarS=ie%S-*#<9T^| z)YR+AnN|Y5yjZ78=kVlAv0h$J&a`6awVC>^TZboSiuLOCh3WHB&XX5n}7k=YJ=Qz;QVI}EXSMk+JA^ty219!&& diff --git a/documentation/integrations/og/inter-semibold.ttf b/documentation/integrations/og/inter-semibold.ttf deleted file mode 100644 index c6aeeb16a6d7f754258bc7f3f2101d287976e6e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 315756 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
- - { - menuButtonVisible && ( -
- -
- ) - } -
- diff --git a/documentation/src/components/MarkdownStyle.astro b/documentation/src/components/MarkdownStyle.astro deleted file mode 100644 index 76079f194..000000000 --- a/documentation/src/components/MarkdownStyle.astro +++ /dev/null @@ -1,104 +0,0 @@ -
- -
- - diff --git a/documentation/src/components/Search.astro b/documentation/src/components/Search.astro deleted file mode 100644 index 9b11f3335..000000000 --- a/documentation/src/components/Search.astro +++ /dev/null @@ -1,115 +0,0 @@ ---- -import SearchIcon from "@icons/Search.astro"; ---- - - - - - diff --git a/documentation/src/components/SelectLink.astro b/documentation/src/components/SelectLink.astro deleted file mode 100644 index 12a71b642..000000000 --- a/documentation/src/components/SelectLink.astro +++ /dev/null @@ -1,83 +0,0 @@ ---- -import ExpandIcon from "@icons/ExpandIcon.astro"; -type Props = { - title: string; - options: { - htmlTitle: string; - href: string; - }[]; -}; ---- - - -
- - -
-
- - diff --git a/documentation/src/components/menus/MainMenu.astro b/documentation/src/components/menus/MainMenu.astro deleted file mode 100644 index 4448a39b2..000000000 --- a/documentation/src/components/menus/MainMenu.astro +++ /dev/null @@ -1,52 +0,0 @@ ---- -import Menu from "./Menu.astro"; ---- - - diff --git a/documentation/src/components/menus/Menu.astro b/documentation/src/components/menus/Menu.astro deleted file mode 100644 index ac94123ed..000000000 --- a/documentation/src/components/menus/Menu.astro +++ /dev/null @@ -1,125 +0,0 @@ ---- -import { comparePathname } from "@utils/url"; - -type Page = [title: string, href: string]; -type Section = { - title: string; - pages: Page[]; -}; - -type Props = { - id: string; - content: (Section | Page[])[]; - title?: string; -}; - -const parseMarkdownCode = (text: string) => { - return text.replace("`", "").replace("`", ""); -}; ---- - - - - - - diff --git a/documentation/src/components/menus/OAuthMenu.astro b/documentation/src/components/menus/OAuthMenu.astro deleted file mode 100644 index 2a49c33aa..000000000 --- a/documentation/src/components/menus/OAuthMenu.astro +++ /dev/null @@ -1,52 +0,0 @@ ---- -import Menu from "./Menu.astro"; ---- - - diff --git a/documentation/src/components/menus/ReferenceMenu.astro b/documentation/src/components/menus/ReferenceMenu.astro deleted file mode 100644 index f06d277af..000000000 --- a/documentation/src/components/menus/ReferenceMenu.astro +++ /dev/null @@ -1,33 +0,0 @@ ---- -import Menu from "./Menu.astro"; ---- - - diff --git a/documentation/src/env.d.ts b/documentation/src/env.d.ts deleted file mode 100644 index acef35f17..000000000 --- a/documentation/src/env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/documentation/src/icons/ArticleIcon.astro b/documentation/src/icons/ArticleIcon.astro deleted file mode 100644 index dfcb522c6..000000000 --- a/documentation/src/icons/ArticleIcon.astro +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/documentation/src/icons/DiscordIcon.astro b/documentation/src/icons/DiscordIcon.astro deleted file mode 100644 index 693262a38..000000000 --- a/documentation/src/icons/DiscordIcon.astro +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - diff --git a/documentation/src/icons/ExpandIcon.astro b/documentation/src/icons/ExpandIcon.astro deleted file mode 100644 index 550a0240c..000000000 --- a/documentation/src/icons/ExpandIcon.astro +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/documentation/src/icons/GithubIcon.astro b/documentation/src/icons/GithubIcon.astro deleted file mode 100644 index e749e546c..000000000 --- a/documentation/src/icons/GithubIcon.astro +++ /dev/null @@ -1,6 +0,0 @@ - - GitHub - - diff --git a/documentation/src/icons/MenuIcon.astro b/documentation/src/icons/MenuIcon.astro deleted file mode 100644 index a5e0fb3e4..000000000 --- a/documentation/src/icons/MenuIcon.astro +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/documentation/src/icons/MoreIcon.astro b/documentation/src/icons/MoreIcon.astro deleted file mode 100644 index 2f020e5ad..000000000 --- a/documentation/src/icons/MoreIcon.astro +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/documentation/src/icons/Next.astro b/documentation/src/icons/Next.astro deleted file mode 100644 index 6c82caa9d..000000000 --- a/documentation/src/icons/Next.astro +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/documentation/src/icons/NotesIcon.astro b/documentation/src/icons/NotesIcon.astro deleted file mode 100644 index 05a0044bd..000000000 --- a/documentation/src/icons/NotesIcon.astro +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/documentation/src/icons/Search.astro b/documentation/src/icons/Search.astro deleted file mode 100644 index d9e313c72..000000000 --- a/documentation/src/icons/Search.astro +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/documentation/src/layouts/BaseLayout.astro b/documentation/src/layouts/BaseLayout.astro deleted file mode 100644 index a5ce6ed8b..000000000 --- a/documentation/src/layouts/BaseLayout.astro +++ /dev/null @@ -1,99 +0,0 @@ ---- -type Props = { - title: string; - description?: string | null; -}; - -const { title, description } = Astro.props; - -let origin: string; -if (import.meta.env.CF_PAGES_BRANCH === "main") { - origin = "https://lucia-auth.com"; -} else if (import.meta.env.DEV) { - origin = Astro.url.origin; -} else { - origin = import.meta.env.CF_PAGES_URL; -} - -const pathname = Astro.url.pathname; - -const ogUrl = - pathname === "/" - ? new URL("/og/index.jpg", origin) - : new URL("/og/" + pathname.replace(/\/$/, "") + ".jpg", origin); - -const pageTitle = title === "Lucia" ? "Lucia" : `${title} · Lucia`; ---- - - - - - - - - - - - {pageTitle} - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/documentation/src/layouts/MainLayout.astro b/documentation/src/layouts/MainLayout.astro deleted file mode 100644 index 5c186eab0..000000000 --- a/documentation/src/layouts/MainLayout.astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -import Header from "@components/Header.astro"; -import BaseLayout from "./BaseLayout.astro"; -import Menu from "@components/menus/MainMenu.astro"; - -type Props = { - title: string; - description?: string | null; -}; - -const { title, description } = Astro.props; ---- - - -
- -
- -
- - diff --git a/documentation/src/layouts/OAuthLayout.astro b/documentation/src/layouts/OAuthLayout.astro deleted file mode 100644 index 0971d0b19..000000000 --- a/documentation/src/layouts/OAuthLayout.astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -import Menu from "@components/menus/OAuthMenu.astro"; -import Header from "@components/Header.astro"; -import BaseLayout from "./BaseLayout.astro"; - -type Props = { - title: string; - description?: string | null; -}; - -const { title, description } = Astro.props; ---- - - -
- -
- -
- - diff --git a/documentation/src/layouts/ReferenceLayout.astro b/documentation/src/layouts/ReferenceLayout.astro deleted file mode 100644 index 86c7a27c0..000000000 --- a/documentation/src/layouts/ReferenceLayout.astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -import Header from "@components/Header.astro"; -import BaseLayout from "./BaseLayout.astro"; -import Menu from "@components/menus/ReferenceMenu.astro"; - -type Props = { - title: string; - description?: string | null; -}; - -const { title, description } = Astro.props; ---- - - -
- -
- -
- - diff --git a/documentation/src/middleware.ts b/documentation/src/middleware.ts deleted file mode 100644 index 6d6e797ac..000000000 --- a/documentation/src/middleware.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MiddlewareResponseHandler } from "astro"; - -export const onRequest: MiddlewareResponseHandler = async (_, next) => { - const response = await next(); - const html = await response.text(); - const modifiedHtml = html.replaceAll("#C2C3C5", "#a8a8a8"); - return new Response(modifiedHtml, { - headers: response.headers - }); -}; diff --git a/documentation/src/pages/404.astro b/documentation/src/pages/404.astro deleted file mode 100644 index 81bc6e3d9..000000000 --- a/documentation/src/pages/404.astro +++ /dev/null @@ -1,15 +0,0 @@ ---- -import Header from "@components/Header.astro"; -import Layout from "@layouts/BaseLayout.astro"; ---- - - -
-
-
-

404

-

Not found

- Return to main page -
-
- diff --git a/documentation/src/pages/[...slug].astro b/documentation/src/pages/[...slug].astro deleted file mode 100644 index aad464e79..000000000 --- a/documentation/src/pages/[...slug].astro +++ /dev/null @@ -1,62 +0,0 @@ ---- -import { getPages } from "@utils/content"; - -import MainLayout from "@layouts/MainLayout.astro"; -import MarkdownStyle from "@components/MarkdownStyle.astro"; -import SelectLink from "@components/SelectLink.astro"; - -import type { InferGetStaticPropsType } from "astro"; - -export const getStaticPaths = async () => { - const pages = await getPages("main"); - return pages.map((page) => { - return { - params: { - slug: page.href.replace("/", "") - }, - props: { - page - } - }; - }); -}; - -type Props = InferGetStaticPropsType; - -const { page } = Astro.props; ---- - - -
- { - page.versions.length > 0 && ( - { - return { - htmlTitle: version.name, - href: version.href - }; - })} - /> - ) - } -

-

- { - page.versions.length > 0 && ( -

- - Framework and runtime specific versions of this guide are also - available. - -

- ) - } - - - -
diff --git a/documentation/src/pages/blog/[post].astro b/documentation/src/pages/blog/[post].astro deleted file mode 100644 index 93404766d..000000000 --- a/documentation/src/pages/blog/[post].astro +++ /dev/null @@ -1,40 +0,0 @@ ---- -import { getPages } from "@utils/content"; - -import BaseLayout from "@layouts/BaseLayout.astro"; -import MarkdownStyle from "@components/MarkdownStyle.astro"; -import Header from "@components/Header.astro"; - -import type { InferGetStaticPropsType } from "astro"; - -export const getStaticPaths = async () => { - const pages = await getPages("blog"); - return pages.map((page) => { - return { - params: { - post: page.href.replace("/blog/", "") - }, - props: { - page - } - }; - }); -}; - -type Props = InferGetStaticPropsType; -const { page } = Astro.props; ---- - - -
-
-
-
-

-

- - - -
-
- diff --git a/documentation/src/pages/content.txt.ts b/documentation/src/pages/content.txt.ts deleted file mode 100644 index a71c29530..000000000 --- a/documentation/src/pages/content.txt.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getPages } from "@utils/content"; - -import type { APIRoute } from "astro"; - -const blacklist: string[] = ["main/migrate/v2"]; - -export const GET: APIRoute = async () => { - const pages = await getPages(); - return new Response( - pages - .filter((page) => { - return ( - page.frameworkId === null && - page.collectionId !== "blog" && - !page.pathname.startsWith("main/migrate/") - ); - }) - .map((page) => { - let encodedHeadings = ""; - if (page.versions.length === 0 && page.collectionId !== "guidebook") { - encodedHeadings = page.headings - .filter((heading) => heading.depth < 4) - .map((headings) => [headings.slug, headings.text].join(":")) - .join("\\"); - } - return [page.title, page.href, page.description ?? "", encodedHeadings]; - }) - .flat() - .join("|"), - { - headers: { - "Content-Type": "text/plain" - } - } - ); -}; diff --git a/documentation/src/pages/guidebook/[...guidebook].astro b/documentation/src/pages/guidebook/[...guidebook].astro deleted file mode 100644 index 3325b7570..000000000 --- a/documentation/src/pages/guidebook/[...guidebook].astro +++ /dev/null @@ -1,67 +0,0 @@ ---- -import { getPages } from "@utils/content"; - -import MarkdownStyle from "@components/MarkdownStyle.astro"; -import BaseLayout from "@layouts/BaseLayout.astro"; -import SelectLink from "@components/SelectLink.astro"; -import Header from "@components/Header.astro"; - -import type { InferGetStaticPropsType } from "astro"; - -export const getStaticPaths = async () => { - const pages = await getPages("guidebook"); - return pages.map((page) => { - return { - params: { - guidebook: page.href.replace("/guidebook/", "") - }, - props: { - page - } - }; - }); -}; - -type Props = InferGetStaticPropsType; -const { page } = Astro.props; ---- - - -
-
-
-
- { - page.versions.length > 0 && ( - { - return { - htmlTitle: version.name, - href: version.href - }; - })} - /> - ) - } -

-

- { - page.versions.length > 0 && ( -

- - Framework and runtime specific versions of this guide are also - available. - -

- ) - } - - - -
-
- diff --git a/documentation/src/pages/guidebook/index.astro b/documentation/src/pages/guidebook/index.astro deleted file mode 100644 index 0c476ec8f..000000000 --- a/documentation/src/pages/guidebook/index.astro +++ /dev/null @@ -1,54 +0,0 @@ ---- -import BaseLayout from "@layouts/BaseLayout.astro"; -import Header from "@components/Header.astro"; -import ArticleIcon from "@icons/ArticleIcon.astro"; - -import { getPages } from "@utils/content"; - -const allPages = await getPages("guidebook"); -const parentPages = allPages.filter( - (page) => page.pathname.split("/").length === 2 -); ---- - - -
-
- -
- diff --git a/documentation/src/pages/index.astro b/documentation/src/pages/index.astro deleted file mode 100644 index 8a12d7a97..000000000 --- a/documentation/src/pages/index.astro +++ /dev/null @@ -1,259 +0,0 @@ ---- -import BaseLayout from "@layouts/BaseLayout.astro"; -import CodeBlock from "@components/CodeBlock.astro"; -import Header from "@components/Header.astro"; -import Menu from "@components/menus/MainMenu.astro"; -import Next from "@icons/Next.astro"; - -import { getGithubContributors } from "@utils/github"; -import DiscordIcon from "@icons/DiscordIcon.astro"; -import GithubIcon from "@icons/GithubIcon.astro"; - -const code1 = `CREATE TABLE user ( - id TEXT NOT NULL PRIMARY KEY -); - -CREATE TABLE user_key ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id), - hashed_password TEXT -); - -CREATE TABLE user_session ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id), - active_expires INTEGER NOT NULL, - idle_expires INTEGER NOT NULL -);`; - -const code2 = `import { lucia } from "lucia"; -import { express } from "lucia/middleware"; -import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; -import { db } from "./db.js"; - -export const auth = lucia({ - adapter: betterSqlite3(db), - middleware: express(), - env: "DEV" -}); -`; - -const code3 = `import { auth } from "./lucia.js"; - -const user = await auth.createUser({ - // user identified with their username - key: { - providerId: "username", - providerUserId: username, - password - }, - attributes: {} -}); -const session = await auth.createSession({ - userId: user.userId, - attributes: {} -}); -const sessionCookie = auth.createSessionCookie(session);`; - -const contributors = await getGithubContributors(); ---- - - -
- - -
-
- Lucia logo -

- Authentication, simple and clean. -

- -

- Lucia is an auth library for TypeScript that abstracts away - the complexity of handling users and sessions. It works alongside your database - to provide an API that's easy to use, understand, and extend. -

- Get started -
-
-

- Motivation -

-

- Auth providers are expensive and too rigid. Libraries like Auth.js are - too bloated and opinionated. Building your own from scratch is hard and - full of pitfalls. What if you can implement your own auth without - worrying about the small details? -

-
-
-

- It just works -

-

- Forget endless configuration and callbacks - just write code. Works with - all major JS runtimes, including Node.js, Deno, Bun, and Cloudflare - Workers. -

-
-
- -
-
-

Set up your database

-

- Create 3 basic, minimum tables for Lucia. You can add columns to the - user table to store custom attributes. -

-
- -
-
-
- -
-
-

Initialize Lucia

-

- Initialize Lucia using one of the many provided adapters and - middleware to use it with your database and framework of choice. -

-
- -
-
-
- -
-
-

Start building

-

- And that's it! Use Lucia's API to handle basic tasks like creating - and validating sessions, and you always have the option to fallback - to raw database queries when you need more. -

-
- -
-
-
-

- Free and open source -

-

- Lucia is a free and open source project made possible by our - contributors! Thank you to everyone who has helped with the development! -

-
- { - contributors.map((contributor) => { - return ( - - {contributor.username} - - ); - }) - } -
-
-
-

- Let's get started -

-

- But a small disclaimer...what makes - Lucia great is that it doesn't try to do everything. This means Lucia is - intended for a specific audience. If you're looking for something quick - and easy, Lucia might not be for you. Experience with backend - development is a must too! -

-

- Still interested? Dive right in! -

-
-
-
-

Tip: Lucia is pronounced loo-shya

- -
-
-
- diff --git a/documentation/src/pages/oauth/[...slug].astro b/documentation/src/pages/oauth/[...slug].astro deleted file mode 100644 index 917d6b5ee..000000000 --- a/documentation/src/pages/oauth/[...slug].astro +++ /dev/null @@ -1,63 +0,0 @@ ---- -import { getPages } from "@utils/content"; - -import OAuthLayout from "@layouts/OAuthLayout.astro"; -import MarkdownStyle from "@components/MarkdownStyle.astro"; -import SelectLink from "@components/SelectLink.astro"; - -import type { InferGetStaticPropsType } from "astro"; - -export const getStaticPaths = async () => { - const pages = await getPages("oauth"); - return pages.map((page) => { - const slug = - page.href === "/oauth" ? undefined : page.href.replace("/oauth/", ""); - return { - params: { - slug - }, - props: { - page - } - }; - }); -}; - -type Props = InferGetStaticPropsType; -const { page } = Astro.props; ---- - - -
- { - page.versions.length > 0 && ( - { - return { - htmlTitle: version.name, - href: version.href - }; - })} - /> - ) - } -

-

- { - page.versions.length > 0 && ( -

- - Framework and runtime specific versions of this guide are also - available. - -

- ) - } - - - -
diff --git a/documentation/src/pages/reference/[...slug].astro b/documentation/src/pages/reference/[...slug].astro deleted file mode 100644 index 14f054edb..000000000 --- a/documentation/src/pages/reference/[...slug].astro +++ /dev/null @@ -1,71 +0,0 @@ ---- -import { getPages } from "@utils/content"; - -import ReferenceLayout from "@layouts/ReferenceLayout.astro"; -import MarkdownStyle from "@components/MarkdownStyle.astro"; -import SelectLink from "@components/SelectLink.astro"; - -import type { InferGetStaticPropsType } from "astro"; - -export const getStaticPaths = async () => { - const pages = await getPages("reference"); - return pages.map((page) => { - const slug = - page.href === "/reference" - ? undefined - : page.href.replace("/reference/", ""); - return { - params: { - slug - }, - props: { - page - } - }; - }); -}; - -type Props = InferGetStaticPropsType; -const { page } = Astro.props; ---- - - -
- { - page.versions.length > 0 && ( - { - return { - htmlTitle: version.name, - href: version.href - }; - })} - /> - ) - } -

-

- { - page.versions.length > 0 && ( -

- - Framework and runtime specific versions of this guide are also - available. - -

- ) - } - - - -
- - diff --git a/documentation/src/utils/build.ts b/documentation/src/utils/build.ts deleted file mode 100644 index 14ea91e32..000000000 --- a/documentation/src/utils/build.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const getBuildId = (): string => { - let buildId: string | null = null; - if (typeof document === "undefined") { - buildId = import.meta.env.BUILD_ID; - } else { - const element = document.querySelector("[property~=build_id][content]"); - if (element instanceof HTMLMetaElement) { - buildId = element.content; - } - } - if (!buildId) throw new Error("BUILD_ID undefined"); - return buildId; -}; diff --git a/documentation/src/utils/content.ts b/documentation/src/utils/content.ts deleted file mode 100644 index e000b68d5..000000000 --- a/documentation/src/utils/content.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { MarkdownHeading, MarkdownInstance } from "astro"; - -type FrameworkId = keyof typeof frameworkNameDictionary; - -type MarkdownFile = MarkdownInstance<{ - title: string; - description?: string; - hidden?: boolean; -}>; - -const markdownImports = Object.entries( - import.meta.glob("../../content/**/*.md") -).map(([importPath, resolve]) => { - return [ - importPath - .replace("../../content/", "") - .replace(".md", "") - .replace("/index", ""), - resolve as () => Promise - ] as const; -}); - -type FrameworkVersion = { - href: string; - name: string; -}; - -export type Page = { - pathname: string; - href: string; - collectionId: string; - title: string; - htmlTitle: string; - hidden: boolean; - htmlDescription: string | null; - description: string | null; - versions: FrameworkVersion[]; - frameworkId: FrameworkId | null; - Content: MarkdownInstance["Content"]; - headings: MarkdownHeading[]; -}; - -const parseMarkdownCode = (text: string) => { - let result = text; - while (result.includes("`")) { - result = result.replace("`", "").replace("`", ""); - } - return result; -}; - -const removeMarkdownCode = (text: string) => { - return text.replaceAll("`", ""); -}; - -export const getPages = async (collectionId?: string): Promise => { - const targetImports = markdownImports.filter(([pathname]) => { - if (collectionId === undefined) return true; - return pathname.startsWith(collectionId + "/") || pathname === collectionId; - }); - const pages = await Promise.all( - targetImports.map(async ([pathname, resolve]): Promise => { - const resolvedFile = await resolve(); - const rawDescription = resolvedFile.frontmatter.description ?? null; - return { - pathname, - href: getHrefFromContentPathname(pathname), - collectionId: pathname.split("/")[0], - title: removeMarkdownCode(resolvedFile.frontmatter.title), - htmlTitle: parseMarkdownCode(resolvedFile.frontmatter.title), - description: rawDescription ? removeMarkdownCode(rawDescription) : null, - htmlDescription: rawDescription - ? parseMarkdownCode(rawDescription) - : null, - hidden: Boolean(resolvedFile.frontmatter.hidden), - versions: [], - frameworkId: getFrameworkIdFromContentPathname(pathname), - Content: resolvedFile.Content, - headings: resolvedFile.getHeadings() - }; - }) - ); - for (const page of pages) { - page.versions = pages - .filter((maybeNestedPage) => { - return maybeNestedPage.pathname.startsWith(page.pathname + "/$"); - }) - .map((page): FrameworkVersion => { - if (!page.frameworkId) throw new Error("Version not defined"); - return { - name: frameworkNameDictionary[page.frameworkId], - href: page.href - }; - }); - } - return pages; -}; - -const getHrefFromContentPathname = (pathname: string): string => { - if (pathname.startsWith("main/")) { - return pathname.replace("main/", "/").replace("$", ""); - } - return "/" + pathname.replace("$", ""); -}; - -const getFrameworkIdFromContentPathname = ( - pathname: string -): FrameworkId | null => { - const lastPathnameSegment = pathname.split("/").at(-1) ?? null; - if (!lastPathnameSegment) return null; - if (!lastPathnameSegment.startsWith("$")) return null; - const version = lastPathnameSegment.replace("$", ""); - if (!isValidFrameworkVersion(version)) return null; - return version; -}; - -const isValidFrameworkVersion = ( - maybeFrameworkVersion: string -): maybeFrameworkVersion is keyof typeof frameworkNameDictionary => { - return maybeFrameworkVersion in frameworkNameDictionary; -}; - -const frameworkNameDictionary = { - astro: "Astro", - electron: "Electron", - elysia: "Elysia", - expo: "Expo", - express: "Express", - fastify: "Fastify", - hono: "Hono", - "nextjs-app": "Next.js App Router", - "nextjs-pages": "Next.js Pages Router", - nuxt: "Nuxt", - qwik: "Qwik", - remix: "Remix", - solidstart: "SolidStart", - sveltekit: "SvelteKit", - tauri: "Tauri" -} as const; diff --git a/documentation/src/utils/dom.ts b/documentation/src/utils/dom.ts deleted file mode 100644 index b109def39..000000000 --- a/documentation/src/utils/dom.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const setElementVisibility = ( - element: HTMLElement, - visible: boolean -) => { - if (visible) { - element.classList.remove("hidden"); - } else { - element.classList.add("hidden"); - } -}; diff --git a/documentation/src/utils/github.ts b/documentation/src/utils/github.ts deleted file mode 100644 index 37c4e6a39..000000000 --- a/documentation/src/utils/github.ts +++ /dev/null @@ -1,41 +0,0 @@ -type Contributor = { - avatar: string; - profileLink: string; - username: string; -}; - -let contributors: Contributor[]; - -export const getGithubContributors = async (): Promise => { - if (contributors) return contributors; - const contributorsResponse = await fetch( - "https://api.github.com/repos/lucia-auth/lucia/contributors?per_page=100", - { - headers: { - Authorization: `Bearer ${import.meta.env.GITHUB_API_KEY}` - } - } - ); - - if (!contributorsResponse.ok) { - throw new Error("Failed to fetch data from GitHub"); - } - - const contributorsResult = (await contributorsResponse.json()) as { - avatar_url: string; - html_url: string; - login: string; - }[]; - - contributors = contributorsResult.map((val) => { - const url = new URL(val.avatar_url); - url.searchParams.set("s", "128"); // set image size to 128 x 128 - url.searchParams.delete("v"); - return { - avatar: url.href, - profileLink: val.html_url, - username: val.login - }; - }); - return contributors; -}; diff --git a/documentation/src/utils/search.ts b/documentation/src/utils/search.ts deleted file mode 100644 index ab5bfc8b9..000000000 --- a/documentation/src/utils/search.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { getBuildId } from "./build"; - -export const queryContent = async ( - query: string -): Promise => { - const pages = await promise; - const keywords = query.split(" ").filter((val) => Boolean(val)); - const matchedPages: QueryResultPage[] = []; - for (const page of pages) { - const matchedHeadings = page.headings.filter((heading) => { - return match(heading.title, keywords); - }); - const pageTitleMatched = match( - page.title + (page.description ?? ""), - keywords - ); - if (pageTitleMatched || matchedHeadings.length > 0) { - matchedPages.push({ - priority: pageTitleMatched ? 1 : 0, - title: page.title, - description: page.description, - href: page.href, - headings: matchedHeadings - }); - } - } - return matchedPages.sort((a, b) => b.priority - a.priority); -}; - -const getRawContent = async (): Promise => { - const buildId = getBuildId(); - const cacheKey = localStorage.getItem("search:cache_key"); - if (buildId === cacheKey) { - const storedContent = localStorage.getItem("search:content"); - if (storedContent !== null) return storedContent; - localStorage.removeItem("search:cache_key"); - } else { - localStorage.setItem("search:cache_key", buildId); - localStorage.removeItem("search:content"); - } - const response = await fetch("/content.txt"); - if (!response.ok) { - throw new Error(`Server returned status ${response.status}`); - } - const result = await response.text(); - localStorage.setItem("search:content", result); - return result; -}; - -const promise = new Promise(async (resolve, reject) => { - try { - const rawContent = await getRawContent(); - const rawItems = rawContent.split("|"); - const result: QueryResultPage[] = []; - for (let index = 0; index < rawItems.length; index += 4) { - const [title, href, description, rawHeadings] = rawItems.slice( - index, - index + 4 - ); - let headings: QueryResultHeading[] = []; - if (rawHeadings) { - headings = rawHeadings.split("\\").map((rawHeadingItem) => { - const [headingHash, ...headingTitleSections] = - rawHeadingItem.split(":"); - return { - title: headingTitleSections.join(":"), - hash: headingHash - }; - }); - } - result.push({ - priority: 0, - title, - href, - description: description || null, - headings - }); - } - return resolve(result); - } catch (e) { - return reject(e); - } -}); - -const match = (target: string, keywords: string[]): boolean => { - if (keywords.length < 1) return false; - for (const keyword of keywords) { - if (target.toLowerCase().includes(keyword.toLowerCase())) continue; - return false; - } - return true; -}; - -type QueryResultHeading = { - title: string; - hash: string; -}; -type QueryResultPage = { - priority: number; - title: string; - description: string | null; - href: string; - headings: QueryResultHeading[]; -}; diff --git a/documentation/src/utils/state.ts b/documentation/src/utils/state.ts deleted file mode 100644 index b973d66c2..000000000 --- a/documentation/src/utils/state.ts +++ /dev/null @@ -1,35 +0,0 @@ -type Callback = (state: T) => any; - -export const createToggleState = () => { - const [state, onUpdate] = createState(false); - const toggleState = { - value: state.value, - toggle: () => state.set(!state.value()) - } as const; - return [toggleState, onUpdate] as const; -}; - -export const createState = (initialState: T) => { - const callbacks: Callback[] = []; - let internal = initialState; - - const onUpdate = (callback: Callback) => { - callbacks.push(callback); - }; - const state = { - value: () => internal, - set: (value: T) => { - if (value !== undefined) { - internal = value; - for (const callback of callbacks) { - callback(internal); - } - } - return internal; - } - } as const; - return [state, onUpdate] as const; -}; - -export const [menuVisible, onMenuToggle] = createToggleState(); -export const [searchVisible, onSearchVisibilityUpdate] = createState(false); diff --git a/documentation/src/utils/url.ts b/documentation/src/utils/url.ts deleted file mode 100644 index 0342746c3..000000000 --- a/documentation/src/utils/url.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const comparePathname = (path1: string, path2: string) => { - return path1 === path2 || path1 === path2 + "/" || path1 + "/" === path2; -}; diff --git a/documentation/tailwind.config.cjs b/documentation/tailwind.config.cjs deleted file mode 100644 index be9433843..000000000 --- a/documentation/tailwind.config.cjs +++ /dev/null @@ -1,33 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - "./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}", - "./integrations/**/*.ts" - ], - theme: { - extend: { - screens: { - xs: "475px", - "1.5xl": "1400px" - }, - colors: { - main: "#5f57ff", - "main-pastel": "#a7a6ff", - zinc: { - 80: "#f7f7f7" - } - }, - fontSize: { - "code-sm": "0.825rem", - "code-base": "0.925rem", - "code-lg": "1.12rem", - "code-xl": "1.2rem", - "code-2xl": "1.45rem", - "code-3xl": "1.825rem", - "code-4xl": "2.15rem", - "code-5xl": "2.9rem" - } - } - }, - plugins: [] -}; diff --git a/documentation/tsconfig.json b/documentation/tsconfig.json deleted file mode 100644 index b67cd9681..000000000 --- a/documentation/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "astro/tsconfigs/strict", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@layouts/*": ["src/layouts/*"], - "@content": ["src/content/index.ts"], - "@components/*": ["src/components/*"], - "@icons/*": ["src/icons/*"], - "@utils/*": ["src/utils/*"], - "@cela/*": ["integrations/cela/*"] - } - } -} diff --git a/documentation/vercel.json b/documentation/vercel.json deleted file mode 100644 index 77dc2f5f6..000000000 --- a/documentation/vercel.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "git": { - "deploymentEnabled": { - "main": false - } - }, - "redirects": [ - { - "source": "/guidebook/sign-in-with-email-and-password/:path*", - "destination": "/guidebook/email-verification-links/:path*" - }, - { - "source": "/start-here/getting-started/:path*", - "destination": "/getting-started/:path*" - }, - { - "source": "/start-here/migrate-v2/:path*", - "destination": "/migrate/v2/:path*" - }, - { - "source": "/start-here/starter-guides/:path*", - "destination": "/starter-guides/:path*" - }, - { - "source": "/start-here/contributing", - "destination": "/contributing" - }, - { - "source": "/extending-lucia/database-adapters-api", - "destination": "/reference/database-adapter" - }, - { - "source": "/extending-lucia/middleware-api", - "destination": "/reference/middleware" - }, - { - "source": "/reference/lucia/main", - "destination": "/reference/lucia/modules/main" - }, - { - "source": "/reference/lucia/polyfill/node", - "destination": "/reference/lucia/modules/polyfill/node" - }, - { - "source": "/reference/lucia/middleware", - "destination": "/reference/lucia/modules/middleware" - }, - { - "source": "/reference/lucia/utils", - "destination": "/reference/lucia/modules/utils" - }, - { - "source": "/reference/oauth/main", - "destination": "/reference/oauth/modules/main" - }, - { - "source": "/reference/oauth/providers", - "destination": "/reference/oauth/modules/providers" - }, - { - "source": "/oauth/basics/using-supported-providers", - "destination": "/oauth/basics/built-in-providers" - }, - { - "source": "/discord", - "destination": "https://discord.gg/PwrK3kpVR3" - }, - { - "source": "/github", - "destination": "https://github.com/lucia-auth/lucia" - } - ] -} diff --git a/packages/adapter-drizzle/.env.example b/packages/adapter-drizzle/.env.example new file mode 100644 index 000000000..db8c275fe --- /dev/null +++ b/packages/adapter-drizzle/.env.example @@ -0,0 +1,4 @@ +POSTGRES_DATABASE_URL="" + +MYSQL_DATABASE="" +MYSQL_PASSWORD="" \ No newline at end of file diff --git a/packages/oauth/.gitignore b/packages/adapter-drizzle/.gitignore similarity index 89% rename from packages/oauth/.gitignore rename to packages/adapter-drizzle/.gitignore index 2b2359c38..9b5a48152 100644 --- a/packages/oauth/.gitignore +++ b/packages/adapter-drizzle/.gitignore @@ -3,3 +3,4 @@ .DS_Store .env *.tgz +*.tgz \ No newline at end of file diff --git a/packages/adapter-mongoose/.prettierignore b/packages/adapter-drizzle/.prettierignore similarity index 100% rename from packages/adapter-mongoose/.prettierignore rename to packages/adapter-drizzle/.prettierignore diff --git a/packages/adapter-drizzle/CHANGELOG.md b/packages/adapter-drizzle/CHANGELOG.md new file mode 100644 index 000000000..a3579b324 --- /dev/null +++ b/packages/adapter-drizzle/CHANGELOG.md @@ -0,0 +1 @@ +# @lucia-auth/adapter-drizzle diff --git a/packages/adapter-drizzle/README.md b/packages/adapter-drizzle/README.md new file mode 100644 index 000000000..6cebca9e4 --- /dev/null +++ b/packages/adapter-drizzle/README.md @@ -0,0 +1,37 @@ +# `@lucia-auth/adapter-drizzle` + +[Drizzle ORM](https://orm.drizzle.team) adapter for Lucia. + +**[Documentation](https://v3.lucia-auth.com/database/drizzle)** + +**[Lucia documentation](https://v3.lucia-auth.com)** + +**[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-drizzle/CHANGELOG.md)** + +## Installation + +``` +npm install @lucia-auth/adapter-drizzle +pnpm add @lucia-auth/adapter-drizzle +yarn add @lucia-auth/adapter-drizzle +``` + +## Testing + +### MySQL + +``` +pnpm test.mysql +``` + +### PostgreSQL + +``` +pnpm test.postgresql +``` + +### SQLite + +``` +pnpm test.sqlite +``` diff --git a/packages/adapter-drizzle/package.json b/packages/adapter-drizzle/package.json new file mode 100644 index 000000000..e6b355a9c --- /dev/null +++ b/packages/adapter-drizzle/package.json @@ -0,0 +1,52 @@ +{ + "name": "@lucia-auth/adapter-drizzle", + "version": "1.0.0", + "description": "Drizzle ORM adapter for Lucia", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "module": "dist/index.js", + "type": "module", + "files": [ + "/dist/", + "CHANGELOG.md" + ], + "scripts": { + "build": "shx rm -rf ./dist/* && tsc", + "auri.build": "pnpm build", + "test.mysql": "tsx tests/mysql.ts", + "test.postgresql": "tsx tests/postgresql.ts", + "test.sqlite": "tsx tests/sqlite.ts" + }, + "keywords": [ + "lucia", + "auth", + "authentication", + "adapter", + "drizzle", + "drizzle-orm" + ], + "repository": { + "type": "git", + "url": "https://github.com/pilcrowOnPaper/lucia", + "directory": "packages/adapter-drizzle" + }, + "author": "pilcrowonpaper", + "license": "MIT", + "exports": { + ".": "./dist/index.js" + }, + "peerDependencies": { + "lucia": "3.x" + }, + "devDependencies": { + "@lucia-auth/adapter-test": "workspace:*", + "@types/better-sqlite3": "^7.6.3", + "better-sqlite3": "^8.4.0", + "dotenv": "^16.0.3", + "drizzle-orm": "^0.29.0", + "lucia": "workspace:*", + "mysql2": "^3.2.3", + "pg": "^8.8.0", + "tsx": "^3.12.6" + } +} diff --git a/packages/adapter-drizzle/src/drivers/mysql.ts b/packages/adapter-drizzle/src/drivers/mysql.ts new file mode 100644 index 000000000..5d859c4ae --- /dev/null +++ b/packages/adapter-drizzle/src/drivers/mysql.ts @@ -0,0 +1,183 @@ +import { eq, lte } from "drizzle-orm"; + +import type { Adapter, DatabaseSession, DatabaseUser } from "lucia"; +import type { MySqlColumn, MySqlDatabase, MySqlTableWithColumns } from "drizzle-orm/mysql-core"; +import type { InferSelectModel } from "drizzle-orm"; + +export class DrizzleMySQLAdapter implements Adapter { + private db: MySqlDatabase; + + private sessionTable: MySQLSessionTable; + private userTable: MySQLUserTable; + + constructor( + db: MySqlDatabase, + sessionTable: MySQLSessionTable, + userTable: MySQLUserTable + ) { + this.db = db; + this.sessionTable = sessionTable; + this.userTable = userTable; + } + + public async deleteSession(sessionId: string): Promise { + await this.db.delete(this.sessionTable).where(eq(this.sessionTable.id, sessionId)); + } + + public async deleteUserSessions(userId: string): Promise { + await this.db.delete(this.sessionTable).where(eq(this.sessionTable.userId, userId)); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const [databaseSession, databaseUser] = await Promise.all([ + this.getSession(sessionId), + this.getUserFromSessionId(sessionId) + ]); + return [databaseSession, databaseUser]; + } + + public async getUserSessions(userId: string): Promise { + const result = await this.db + .select() + .from(this.sessionTable) + .where(eq(this.sessionTable.userId, userId)); + return result.map((val) => { + return transformIntoDatabaseSession(val); + }); + } + + public async setSession(session: DatabaseSession): Promise { + await this.db.insert(this.sessionTable).values({ + id: session.id, + userId: session.userId, + expiresAt: session.expiresAt, + ...session.attributes + }); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.db + .update(this.sessionTable) + .set({ + expiresAt + }) + .where(eq(this.sessionTable.id, sessionId)); + } + + public async deleteExpiredSessions(): Promise { + await this.db.delete(this.sessionTable).where(lte(this.sessionTable.expiresAt, new Date())); + } + + private async getSession(sessionId: string): Promise { + const result = await this.db + .select() + .from(this.sessionTable) + .where(eq(this.sessionTable.id, sessionId)); + if (result.length !== 1) return null; + return transformIntoDatabaseSession(result[0]); + } + + private async getUserFromSessionId(sessionId: string): Promise { + const { _, $inferInsert, $inferSelect, getSQL, ...userColumns } = this.userTable; + const result = await this.db + .select(userColumns) + .from(this.sessionTable) + .innerJoin(this.userTable, eq(this.sessionTable.userId, this.userTable.id)) + .where(eq(this.sessionTable.id, sessionId)); + if (result.length !== 1) return null; + return transformIntoDatabaseUser(result[0]); + } +} + +export type MySQLUserTable = MySqlTableWithColumns<{ + dialect: "mysql"; + columns: { + id: MySqlColumn< + { + name: any; + tableName: any; + dataType: any; + columnType: any; + data: string; + driverParam: any; + notNull: true; + hasDefault: boolean; // must be boolean instead of any to allow default values + enumValues: any; + baseColumn: any; + }, + object + >; + }; + schema: any; + name: any; +}>; + +export type MySQLSessionTable = MySqlTableWithColumns<{ + dialect: "mysql"; + columns: { + id: MySqlColumn< + { + dataType: any; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: string; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + expiresAt: MySqlColumn< + { + dataType: any; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: Date; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + userId: MySqlColumn< + { + dataType: any; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: string; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + }; + schema: any; + name: any; +}>; + +function transformIntoDatabaseSession(raw: InferSelectModel): DatabaseSession { + const { id, userId, expiresAt, ...attributes } = raw; + return { + userId, + id, + expiresAt, + attributes + }; +} + +function transformIntoDatabaseUser(raw: InferSelectModel): DatabaseUser { + const { id, ...attributes } = raw; + return { + id, + attributes + }; +} diff --git a/packages/adapter-drizzle/src/drivers/postgresql.ts b/packages/adapter-drizzle/src/drivers/postgresql.ts new file mode 100644 index 000000000..19a5aa1b6 --- /dev/null +++ b/packages/adapter-drizzle/src/drivers/postgresql.ts @@ -0,0 +1,185 @@ +import { eq, lte } from "drizzle-orm"; + +import type { Adapter, DatabaseSession, DatabaseUser } from "lucia"; +import type { PgColumn, PgDatabase, PgTableWithColumns } from "drizzle-orm/pg-core"; +import type { InferSelectModel } from "drizzle-orm"; + +export class DrizzlePostgreSQLAdapter implements Adapter { + private db: PgDatabase; + + private sessionTable: PostgreSQLSessionTable; + private userTable: PostgreSQLUserTable; + + constructor( + db: PgDatabase, + sessionTable: PostgreSQLSessionTable, + userTable: PostgreSQLUserTable + ) { + this.db = db; + this.sessionTable = sessionTable; + this.userTable = userTable; + } + + public async deleteSession(sessionId: string): Promise { + await this.db.delete(this.sessionTable).where(eq(this.sessionTable.id, sessionId)); + } + + public async deleteUserSessions(userId: string): Promise { + await this.db.delete(this.sessionTable).where(eq(this.sessionTable.userId, userId)); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const [databaseSession, databaseUser] = await Promise.all([ + this.getSession(sessionId), + this.getUserFromSessionId(sessionId) + ]); + return [databaseSession, databaseUser]; + } + + public async getUserSessions(userId: string): Promise { + const result = await this.db + .select() + .from(this.sessionTable) + .where(eq(this.sessionTable.userId, userId)); + return result.map((val) => { + return transformIntoDatabaseSession(val); + }); + } + + public async setSession(session: DatabaseSession): Promise { + await this.db.insert(this.sessionTable).values({ + id: session.id, + userId: session.userId, + expiresAt: session.expiresAt, + ...session.attributes + }); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.db + .update(this.sessionTable) + .set({ + expiresAt + }) + .where(eq(this.sessionTable.id, sessionId)); + } + + public async deleteExpiredSessions(): Promise { + await this.db.delete(this.sessionTable).where(lte(this.sessionTable.expiresAt, new Date())); + } + + private async getSession(sessionId: string): Promise { + const result = await this.db + .select() + .from(this.sessionTable) + .where(eq(this.sessionTable.id, sessionId)); + if (result.length !== 1) return null; + return transformIntoDatabaseSession(result[0]); + } + + private async getUserFromSessionId(sessionId: string): Promise { + const { _, $inferInsert, $inferSelect, getSQL, ...userColumns } = this.userTable; + const result = await this.db + .select(userColumns) + .from(this.sessionTable) + .innerJoin(this.userTable, eq(this.sessionTable.userId, this.userTable.id)) + .where(eq(this.sessionTable.id, sessionId)); + if (result.length !== 1) return null; + return transformIntoDatabaseUser(result[0]); + } +} + +export type PostgreSQLUserTable = PgTableWithColumns<{ + dialect: "pg"; + columns: { + id: PgColumn< + { + name: any; + tableName: any; + dataType: any; + columnType: any; + data: string; + driverParam: any; + notNull: true; + hasDefault: boolean; // must be boolean instead of any to allow default values + enumValues: any; + baseColumn: any; + }, + object + >; + }; + schema: any; + name: any; +}>; + +export type PostgreSQLSessionTable = PgTableWithColumns<{ + dialect: "pg"; + columns: { + id: PgColumn< + { + dataType: any; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: string; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + expiresAt: PgColumn< + { + dataType: any; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: Date; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + userId: PgColumn< + { + dataType: any; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: string; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + }; + schema: any; + name: any; +}>; + +function transformIntoDatabaseSession( + raw: InferSelectModel +): DatabaseSession { + const { id, userId, expiresAt, ...attributes } = raw; + return { + userId, + id, + expiresAt, + attributes + }; +} + +function transformIntoDatabaseUser(raw: InferSelectModel): DatabaseUser { + const { id, ...attributes } = raw; + return { + id, + attributes + }; +} diff --git a/packages/adapter-drizzle/src/drivers/sqlite.ts b/packages/adapter-drizzle/src/drivers/sqlite.ts new file mode 100644 index 000000000..02a0300e8 --- /dev/null +++ b/packages/adapter-drizzle/src/drivers/sqlite.ts @@ -0,0 +1,196 @@ +import { eq, lte } from "drizzle-orm"; + +import type { Adapter, DatabaseSession, DatabaseUser } from "lucia"; +import type { + SQLiteColumn, + BaseSQLiteDatabase, + SQLiteTableWithColumns +} from "drizzle-orm/sqlite-core"; +import type { InferSelectModel } from "drizzle-orm"; + +export class DrizzleSQLiteAdapter implements Adapter { + private db: BaseSQLiteDatabase<"async" | "sync", {}>; + + private sessionTable: SQLiteSessionTable; + private userTable: SQLiteUserTable; + + constructor( + db: BaseSQLiteDatabase, + sessionTable: SQLiteSessionTable, + userTable: SQLiteUserTable + ) { + this.db = db; + this.sessionTable = sessionTable; + this.userTable = userTable; + } + + public async deleteSession(sessionId: string): Promise { + await this.db.delete(this.sessionTable).where(eq(this.sessionTable.id, sessionId)); + } + + public async deleteUserSessions(userId: string): Promise { + await this.db.delete(this.sessionTable).where(eq(this.sessionTable.userId, userId)); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const [databaseSession, databaseUser] = await Promise.all([ + this.getSession(sessionId), + this.getUserFromSessionId(sessionId) + ]); + return [databaseSession, databaseUser]; + } + + public async getUserSessions(userId: string): Promise { + const result = await this.db + .select() + .from(this.sessionTable) + .where(eq(this.sessionTable.userId, userId)) + .all(); + return result.map((val) => { + return transformIntoDatabaseSession(val); + }); + } + + public async setSession(session: DatabaseSession): Promise { + await this.db + .insert(this.sessionTable) + .values({ + id: session.id, + userId: session.userId, + expiresAt: Math.floor(session.expiresAt.getTime() / 1000), + ...session.attributes + }) + .run(); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.db + .update(this.sessionTable) + .set({ + expiresAt: Math.floor(expiresAt.getTime() / 1000) + }) + .where(eq(this.sessionTable.id, sessionId)) + .run(); + } + + public async deleteExpiredSessions(): Promise { + await this.db + .delete(this.sessionTable) + .where(lte(this.sessionTable.expiresAt, Math.floor(Date.now() / 1000))); + } + + private async getSession(sessionId: string): Promise { + const result = await this.db + .select() + .from(this.sessionTable) + .where(eq(this.sessionTable.id, sessionId)) + .get(); + if (!result) return null; + return transformIntoDatabaseSession(result); + } + + private async getUserFromSessionId(sessionId: string): Promise { + const { _, $inferInsert, $inferSelect, getSQL, ...userColumns } = this.userTable; + const result = await this.db + .select(userColumns) + .from(this.sessionTable) + .innerJoin(this.userTable, eq(this.sessionTable.userId, this.userTable.id)) + .where(eq(this.sessionTable.id, sessionId)) + .get(); + if (!result) return null; + return transformIntoDatabaseUser(result); + } +} + +export type SQLiteUserTable = SQLiteTableWithColumns<{ + dialect: "sqlite"; + columns: { + id: SQLiteColumn< + { + name: any; + tableName: any; + dataType: any; + columnType: any; + data: string; + driverParam: any; + notNull: true; + hasDefault: boolean; // must be boolean instead of any to allow default values + enumValues: any; + baseColumn: any; + }, + object + >; + }; + schema: any; + name: any; +}>; + +export type SQLiteSessionTable = SQLiteTableWithColumns<{ + dialect: any; + columns: { + id: SQLiteColumn< + { + dataType: any; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: string; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + expiresAt: SQLiteColumn< + { + dataType: "number"; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: number; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + userId: SQLiteColumn< + { + dataType: "string"; + notNull: true; + enumValues: any; + tableName: any; + columnType: any; + data: string; + driverParam: any; + hasDefault: false; + name: any; + }, + object + >; + }; + schema: any; + name: any; +}>; + +function transformIntoDatabaseSession(raw: InferSelectModel): DatabaseSession { + const { id, userId, expiresAt: expiresAtUnix, ...attributes } = raw; + return { + userId, + id, + expiresAt: new Date(expiresAtUnix * 1000), + attributes + }; +} + +function transformIntoDatabaseUser(raw: InferSelectModel): DatabaseUser { + const { id, ...attributes } = raw; + return { + id, + attributes + }; +} diff --git a/packages/adapter-drizzle/src/index.ts b/packages/adapter-drizzle/src/index.ts new file mode 100644 index 000000000..40b7bbbe2 --- /dev/null +++ b/packages/adapter-drizzle/src/index.ts @@ -0,0 +1,7 @@ +export { DrizzleMySQLAdapter } from "./drivers/mysql.js"; +export { DrizzlePostgreSQLAdapter } from "./drivers/postgresql.js"; +export { DrizzleSQLiteAdapter } from "./drivers/sqlite.js"; + +export type { MySQLSessionTable, MySQLUserTable } from "./drivers/mysql.js"; +export type { PostgreSQLSessionTable, PostgreSQLUserTable } from "./drivers/postgresql.js"; +export type { SQLiteSessionTable, SQLiteUserTable } from "./drivers/sqlite.js"; diff --git a/packages/adapter-drizzle/tests/mysql.ts b/packages/adapter-drizzle/tests/mysql.ts new file mode 100644 index 000000000..6ca8585b3 --- /dev/null +++ b/packages/adapter-drizzle/tests/mysql.ts @@ -0,0 +1,78 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { DrizzleMySQLAdapter } from "../src/drivers/mysql.js"; +import mysql from "mysql2/promise"; + +import dotenv from "dotenv"; +import { resolve } from "path"; + +import { mysqlTable, varchar, datetime } from "drizzle-orm/mysql-core"; +import { drizzle } from "drizzle-orm/mysql2"; + +dotenv.config({ + path: resolve(".env") +}); + +const connection = await mysql.createConnection({ + host: "localhost", + user: "root", + database: process.env.MYSQL_DATABASE, + password: process.env.MYSQL_PASSWORD +}); + +await connection.execute("DROP TABLE IF EXISTS user_session"); +await connection.execute("DROP TABLE IF EXISTS test_user"); + +await connection.execute(`CREATE TABLE IF NOT EXISTS test_user ( + id VARCHAR(255) PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE +)`); + +await connection.execute(`CREATE TABLE IF NOT EXISTS user_session ( + id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + country VARCHAR(255), + FOREIGN KEY (user_id) REFERENCES test_user(id) +)`); + +await connection.execute("INSERT INTO test_user (id, username) VALUES (?, ?)", [ + databaseUser.id, + databaseUser.attributes.username +]); + +const userTable = mysqlTable("test_user", { + id: varchar("id", { + length: 255 + }).primaryKey(), + username: varchar("username", { + length: 255 + }) + .notNull() + .unique() +}); + +const sessionTable = mysqlTable("user_session", { + id: varchar("id", { + length: 255 + }).primaryKey(), + userId: varchar("user_id", { + length: 255 + }) + .notNull() + .references(() => userTable.id), + expiresAt: datetime("expires_at").notNull(), + country: varchar("country", { + length: 255 + }) +}); + +const db = drizzle(connection); + +const adapter = new DrizzleMySQLAdapter(db, sessionTable, userTable); + +await testAdapter(adapter); + +await connection.execute("DROP TABLE IF EXISTS user_session"); +await connection.execute("DROP TABLE IF EXISTS test_user"); + +process.exit(); diff --git a/packages/adapter-drizzle/tests/postgresql.ts b/packages/adapter-drizzle/tests/postgresql.ts new file mode 100644 index 000000000..7afc99c13 --- /dev/null +++ b/packages/adapter-drizzle/tests/postgresql.ts @@ -0,0 +1,64 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { DrizzlePostgreSQLAdapter } from "../src/drivers/postgresql.js"; +import dotenv from "dotenv"; +import { resolve } from "path"; +import pg from "pg"; +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { drizzle } from "drizzle-orm/node-postgres"; + +dotenv.config({ + path: resolve(".env") +}); + +export const pool = new pg.Pool({ + connectionString: process.env.POSTGRES_DATABASE_URL +}); + +await pool.query("DROP TABLE IF EXISTS public.session"); +await pool.query("DROP TABLE IF EXISTS public.user"); + +await pool.query(` +CREATE TABLE public.user ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE +)`); + +await pool.query(` +CREATE TABLE public.session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES public.user(id), + expires_at TIMESTAMPTZ NOT NULL, + country TEXT NOT NULL +)`); + +await pool.query(`INSERT INTO public.user (id, username) VALUES ($1, $2)`, [ + databaseUser.id, + databaseUser.attributes.username +]); + +const userTable = pgTable("user", { + id: text("id").primaryKey(), + username: text("username").notNull().unique() +}); + +const sessionTable = pgTable("session", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: timestamp("expires_at", { + withTimezone: true + }).notNull(), + country: text("country") +}); + +const db = drizzle(pool); + +const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, userTable); + +await testAdapter(adapter); + +await pool.query("DROP TABLE public.session"); +await pool.query("DROP TABLE public.user"); + +process.exit(); diff --git a/packages/adapter-drizzle/tests/sqlite.ts b/packages/adapter-drizzle/tests/sqlite.ts new file mode 100644 index 000000000..7567f964d --- /dev/null +++ b/packages/adapter-drizzle/tests/sqlite.ts @@ -0,0 +1,44 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { DrizzleSQLiteAdapter } from "../src/drivers/sqlite.js"; +import sqlite from "better-sqlite3"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { drizzle } from "drizzle-orm/better-sqlite3"; + +const sqliteDB = sqlite(":memory:"); + +sqliteDB.exec( + `CREATE TABLE user ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE +)` +).exec(`CREATE TABLE user_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + country TEXT, + FOREIGN KEY (user_id) REFERENCES user(id) +)`); + +sqliteDB + .prepare(`INSERT INTO user (id, username) VALUES (?, ?)`) + .run(databaseUser.id, databaseUser.attributes.username); + +const userTable = sqliteTable("user", { + id: text("id").notNull().primaryKey(), + username: text("username").notNull().unique() +}); + +const sessionTable = sqliteTable("user_session", { + id: text("id").notNull().primaryKey(), + userId: text("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: integer("expires_at").notNull(), + country: text("country") +}); + +const db = drizzle(sqliteDB); + +const adapter = new DrizzleSQLiteAdapter(db, sessionTable, userTable); + +await testAdapter(adapter); diff --git a/packages/adapter-session-unstorage/tsconfig.json b/packages/adapter-drizzle/tsconfig.json similarity index 92% rename from packages/adapter-session-unstorage/tsconfig.json rename to packages/adapter-drizzle/tsconfig.json index f2fb9daed..5cb3f5468 100644 --- a/packages/adapter-session-unstorage/tsconfig.json +++ b/packages/adapter-drizzle/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/adapter-mongoose/.env.example b/packages/adapter-mongodb/.env.example similarity index 100% rename from packages/adapter-mongoose/.env.example rename to packages/adapter-mongodb/.env.example diff --git a/packages/adapter-mongoose/.gitignore b/packages/adapter-mongodb/.gitignore similarity index 100% rename from packages/adapter-mongoose/.gitignore rename to packages/adapter-mongodb/.gitignore diff --git a/packages/adapter-session-redis/.prettierignore b/packages/adapter-mongodb/.prettierignore similarity index 100% rename from packages/adapter-session-redis/.prettierignore rename to packages/adapter-mongodb/.prettierignore diff --git a/packages/adapter-mongodb/CHANGELOG.md b/packages/adapter-mongodb/CHANGELOG.md new file mode 100644 index 000000000..7b9b7558a --- /dev/null +++ b/packages/adapter-mongodb/CHANGELOG.md @@ -0,0 +1 @@ +# @lucia-auth/adapter-mongodb diff --git a/packages/adapter-mongodb/README.md b/packages/adapter-mongodb/README.md new file mode 100644 index 000000000..78f8d0166 --- /dev/null +++ b/packages/adapter-mongodb/README.md @@ -0,0 +1,25 @@ +# `@lucia-auth/adapter-mongodb` + +[MongoDB](https://mongodb.com) adapter for Lucia. Can also be used with [Mongoose](https://github.com/Automattic/mongoose). + +**[Documentation](https://v3.lucia-auth.com/database/mongodb)** + +**[Lucia documentation](https://v3.lucia-auth.com)** + +**[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-mongodb/CHANGELOG.md)** + +## Installation + +``` +npm install @lucia-auth/adapter-mongodb +pnpm add @lucia-auth/adapter-mongodb +yarn add @lucia-auth/adapter-mongodb +``` + +## Testing + +Add MongoDB url to `MONGODB_URL` env var. + +``` +pnpm test +``` diff --git a/packages/adapter-mongoose/package.json b/packages/adapter-mongodb/package.json similarity index 60% rename from packages/adapter-mongoose/package.json rename to packages/adapter-mongodb/package.json index 8e052dc9e..bfaa6bf2b 100644 --- a/packages/adapter-mongoose/package.json +++ b/packages/adapter-mongodb/package.json @@ -1,7 +1,7 @@ { - "name": "@lucia-auth/adapter-mongoose", - "version": "3.0.1", - "description": "Mongoose (MongoDB) adapter for Lucia", + "name": "@lucia-auth/adapter-mongodb", + "version": "1.0.0", + "description": "MongoDB adapter for Lucia", "main": "dist/index.js", "types": "dist/index.d.ts", "module": "dist/index.js", @@ -12,7 +12,8 @@ ], "scripts": { "build": "shx rm -rf ./dist/* && tsc", - "test": "tsx test/index.ts", + "test.mongodb": "tsx tests/mongodb.ts", + "test.mongoose": "tsx tests/mongoose.ts", "auri.build": "pnpm build" }, "keywords": [ @@ -22,13 +23,12 @@ "authentication", "adapter", "mongodb", - "mongo", - "mongoose" + "mongo" ], "repository": { "type": "git", "url": "https://github.com/pilcrowOnPaper/lucia", - "directory": "packages/adapter-mongoose" + "directory": "packages/adapter-mongodb" }, "author": "pilcrowonpaper", "license": "MIT", @@ -36,14 +36,15 @@ ".": "./dist/index.js" }, "peerDependencies": { - "lucia": "^2.0.0", - "mongoose": "6.x - 8.x" + "lucia": "3.x", + "mongodb": "4.x | 6.x" }, "devDependencies": { - "@lucia-auth/adapter-test": "latest", + "@lucia-auth/adapter-test": "workspace:*", "dotenv": "^16.0.3", "tsx": "^3.12.6", - "mongoose": "^6.6.1", - "lucia": "latest" + "mongodb": "^4.1.1", + "lucia": "workspace:*", + "mongoose": "^6.0.11" } } diff --git a/packages/adapter-mongodb/src/index.ts b/packages/adapter-mongodb/src/index.ts new file mode 100644 index 000000000..9f66815c0 --- /dev/null +++ b/packages/adapter-mongodb/src/index.ts @@ -0,0 +1,125 @@ +import type { + Adapter, + DatabaseSession, + RegisteredDatabaseSessionAttributes, + DatabaseUser, + RegisteredDatabaseUserAttributes +} from "lucia"; +import { Collection } from "mongodb"; + +interface UserDoc extends RegisteredDatabaseUserAttributes { + _id: string; + __v?: any; +} + +interface SessionDoc extends RegisteredDatabaseSessionAttributes { + _id: string; + __v?: any; + user_id: string; + expires_at: Date; +} + +export class MongodbAdapter implements Adapter { + private Session: Collection; + private User: Collection; + + constructor(Session: Collection, User: Collection) { + this.Session = Session; + this.User = User; + } + + public async deleteSession(sessionId: string): Promise { + await this.Session.findOneAndDelete({ _id: sessionId }); + } + + public async deleteUserSessions(userId: string): Promise { + await this.Session.deleteMany({ user_id: userId }); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const sessionUsers = await this.Session.aggregate([ + { $match: { _id: sessionId } }, + { + $lookup: { + from: this.User.collectionName, + localField: "user_id", + // relies on _id being a String, not ObjectId. + foreignField: "_id", + as: "userDocs" + } + } + ]).toArray(); + + const sessionUser = sessionUsers?.at(0) ?? null; + if (!sessionUser) return [null, null]; + + const { userDocs, ...sessionDoc } = sessionUser; + const userDoc = userDocs?.at(0) ?? null; + if (!userDoc) return [null, null]; + + const session = transformIntoDatabaseSession(sessionDoc as SessionDoc); + const user = transformIntoDatabaseUser(userDoc); + return [session, user]; + } + + public async getUserSessions(userId: string): Promise { + const sessions = await this.Session.find( + { user_id: userId }, + { + projection: { + // MongoDB driver doesn't use the extra fields that Mongoose does + // But, if the dev is passing in mongoose.connection, these fields will be there + __v: 0, + _doc: 0 + } + } + ).toArray(); + + return sessions.map((val) => transformIntoDatabaseSession(val)); + } + + public async setSession(session: DatabaseSession): Promise { + const value: SessionDoc = { + _id: session.id, + user_id: session.userId, + expires_at: session.expiresAt, + ...session.attributes + }; + + await this.Session.insertOne(value); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.Session.findOneAndUpdate({ _id: sessionId }, { $set: { expires_at: expiresAt } }); + } + + public async deleteExpiredSessions(): Promise { + await this.Session.deleteMany({ + expires_at: { + $lte: new Date() + } + }); + } +} + +function transformIntoDatabaseUser(value: UserDoc): DatabaseUser { + delete value.__v; + const { _id: id, ...attributes } = value; + return { + id, + attributes + }; +} + +function transformIntoDatabaseSession(value: SessionDoc): DatabaseSession { + delete value.__v; + const { _id: id, user_id: userId, expires_at: expiresAt, ...attributes } = value; + return { + id, + userId, + expiresAt, + attributes + }; +} diff --git a/packages/adapter-mongodb/tests/mongodb.ts b/packages/adapter-mongodb/tests/mongodb.ts new file mode 100644 index 000000000..d1dbce75a --- /dev/null +++ b/packages/adapter-mongodb/tests/mongodb.ts @@ -0,0 +1,33 @@ +import { databaseUser, testAdapter } from "@lucia-auth/adapter-test"; +import dotenv from "dotenv"; +import { Collection, MongoClient } from "mongodb"; +import { resolve } from "path"; +import { MongodbAdapter } from "../src/index.js"; + +dotenv.config({ path: `${resolve()}/.env` }); + +const client = new MongoClient(process.env.MONGODB_URL!); + +await client.connect(); + +const db = client.db("lucia-test"); + +const User = db.collection("users") as Collection; +const Session = db.collection("sessions") as Collection; + +const adapter = new MongodbAdapter(Session, User); + +await User.deleteMany({}); +await Session.deleteMany({}); + +await User.insertOne({ + _id: databaseUser.id, + username: databaseUser.attributes.username +}); + +await testAdapter(adapter); + +await User.deleteMany({}); +await Session.deleteMany({}); + +process.exit(0); diff --git a/packages/adapter-mongodb/tests/mongoose.ts b/packages/adapter-mongodb/tests/mongoose.ts new file mode 100644 index 000000000..a6fe56d56 --- /dev/null +++ b/packages/adapter-mongodb/tests/mongoose.ts @@ -0,0 +1,72 @@ +import { databaseUser, testAdapter } from "@lucia-auth/adapter-test"; +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import { resolve } from "path"; +import { MongodbAdapter } from "../src/index.js"; + +dotenv.config({ path: `${resolve()}/.env` }); + +await mongoose.connect(process.env.MONGODB_URL!); + +const User = mongoose.model( + "User", + new mongoose.Schema( + { + _id: { + type: String, + required: true + }, + username: { + unique: true, + type: String, + required: true + } + } as const, + { _id: false } + ) +); + +const Session = mongoose.model( + "Session", + new mongoose.Schema( + { + _id: { + type: String, + required: true + }, + user_id: { + type: String, + required: true + }, + expires_at: { + type: Date, + required: true + }, + country: { + type: String, + required: true + } + } as const, + { _id: false } + ) +); + +const adapter = new MongodbAdapter( + mongoose.connection.collection("sessions"), + mongoose.connection.collection("users") +); + +await User.deleteMany(); +await Session.deleteMany(); + +await new User({ + _id: databaseUser.id, + username: databaseUser.attributes.username +}).save(); + +await testAdapter(adapter); + +await User.deleteMany(); +await Session.deleteMany(); + +process.exit(0); diff --git a/packages/oauth/tsconfig.json b/packages/adapter-mongodb/tsconfig.json similarity index 92% rename from packages/oauth/tsconfig.json rename to packages/adapter-mongodb/tsconfig.json index f2fb9daed..5cb3f5468 100644 --- a/packages/oauth/tsconfig.json +++ b/packages/adapter-mongodb/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/adapter-mongoose/CHANGELOG.md b/packages/adapter-mongoose/CHANGELOG.md deleted file mode 100644 index 31e511a04..000000000 --- a/packages/adapter-mongoose/CHANGELOG.md +++ /dev/null @@ -1,173 +0,0 @@ -# @lucia-auth/adapter-mongoose - -## 3.0.1 - -### Patch changes - -- [#1248](https://github.com/lucia-auth/lucia/pull/1248) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependencies - -## 3.0.0 - -### Major changes - -- [#885](https://github.com/pilcrowOnPaper/lucia/pull/885) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update version and peer dependency - -### Minor changes - -- [#875](https://github.com/pilcrowOnPaper/lucia/pull/875) by [@SkepticMystic](https://github.com/SkepticMystic) : Add a `getSessionAndUserBySessionId` method to `mongoose()` adapter, using a lookup (join) instead of two separate db calls. - -## 3.0.0-beta.7 - -### Minor changes - -- [#867](https://github.com/pilcrowOnPaper/lucia/pull/867) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 3.0.0-beta.6 - -### Minor changes - -- [#842](https://github.com/pilcrowOnPaper/lucia/pull/842) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 3.0.0-beta.5 - -### Minor changes - -- [#815](https://github.com/pilcrowOnPaper/lucia/pull/815) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Make `Session` model params optional - -- [#812](https://github.com/pilcrowOnPaper/lucia/pull/812) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 3.0.0-beta.4 - -### Patch changes - -- [#803](https://github.com/pilcrowOnPaper/lucia/pull/803) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 3.0.0-beta.3 - -### Major changes - -- [#788](https://github.com/pilcrowOnPaper/lucia/pull/790) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Require `lucia@2.0.0-beta.3` - -## 3.0.0-beta.2 - -### Patch changes - -- [#768](https://github.com/pilcrowOnPaper/lucia/pull/768) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 3.0.0-beta.1 - -### Patch changes - -- [#756](https://github.com/pilcrowOnPaper/lucia/pull/756) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix peer dependency version - -## 3.0.0-beta.0 - -### Major changes - -- [#682](https://github.com/pilcrowOnPaper/lucia/pull/682) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Require `lucia@^2.0.0` - - - Export adapter as named exports (`mongoose()`) - - - Update adapter params - -## 2.0.0 - -### Major changes - -- [#529](https://github.com/pilcrowOnPaper/lucia/pull/529) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : [Breaking] Requires `lucia-auth@^1.3.0` - - - Update to new specifications - -### Patch changes - -- [#532](https://github.com/pilcrowOnPaper/lucia/pull/532) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Use projection to fetch data - -- [#528](https://github.com/pilcrowOnPaper/lucia/pull/528) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix bugs - - - Fix `Adapter.deleteNonPrimaryKey()` deleting non-primary keys - - - Fix `Adapter.updateUserAttributes()` returning old data - -## 1.0.0 - -### Major changes - -- [#443](https://github.com/pilcrowOnPaper/lucia/pull/443) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Release version 1.0! - -## 0.7.0 - -### Minor changes - -- [#430](https://github.com/pilcrowOnPaper/lucia/pull/430) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : [Breaking] Require `lucia-auth` 0.11.0 - - - Update schema - -## 0.6.1 - -### Patch changes - -- [#424](https://github.com/pilcrowOnPaper/lucia/pull/424) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : - Update dependencies - -## 0.6.0 - -### Minor changes - -- [#398](https://github.com/pilcrowOnPaper/lucia/pull/398) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Require `lucia-auth@0.9.0` - -## 0.5.3 - -### Patch changes - -- [#392](https://github.com/pilcrowOnPaper/lucia/pull/392) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 0.5.2 - -### Patch changes - -- [#388](https://github.com/pilcrowOnPaper/lucia/pull/388) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : remove unnecessary code - -## 0.5.1 - -### Patch changes - -- [#381](https://github.com/pilcrowOnPaper/lucia/pull/381) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update links in README and package.json - -## 0.5.0 - -- [Breaking] Require minimum `lucia-auth` 0.7.0 - -## 0.4.0 - -- [Breaking] Require minimum `lucia-auth` 0.6.0 - -## 0.3.0 - -- [Breaking] Require minimum `lucia-auth` 0.5.0 - -## 0.2.1 - -- [Fix] Fix error handling error - -## 0.2.0 - -- [Breaking] Require minimum `lucia-auth` 0.4.0 - -- [Breaking] Remove global error handler - -- Generates a new `ObjectId` and uses its 24-character hexadecimal representation as the user id if none is provided - -## 0.1.5 - -- Update peer dependency - -## 0.1.4 - -- [Fix] Remove `instance of` check for error [#213](https://github.com/pilcrowOnPaper/lucia/issues/213) - -## 0.1.3 - -- Update dependencies - -## 0.1.2 - -- Update peer dependency diff --git a/packages/adapter-mongoose/README.md b/packages/adapter-mongoose/README.md deleted file mode 100644 index 773941689..000000000 --- a/packages/adapter-mongoose/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# `@lucia-auth/adapter-mongoose` - -[Mongoose](https://mongoosejs.com) (MongoDB) adapter for Lucia v2. - -**[Documentation](https://lucia-auth.com/reference#lucia-authadapter-mongoose)** - -**[Lucia documentation](https://lucia-auth.com)** - -**[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-mongoose/CHANGELOG.md)** - -## Installation - -``` -npm install @lucia-auth/adapter-mongoose -pnpm install @lucia-auth/adapter-mongoose -yarn add @lucia-auth/adapter-mongoose -``` - -## Testing - -Add MongoDB url to `MONGODB_URL` env var. - -``` -pnpm test -``` diff --git a/packages/adapter-mongoose/src/docs.ts b/packages/adapter-mongoose/src/docs.ts deleted file mode 100644 index bb199f31e..000000000 --- a/packages/adapter-mongoose/src/docs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { - GlobalDatabaseUserAttributes, - GlobalDatabaseSessionAttributes -} from "lucia"; - -export type UserDoc = { - _id: string; - __v?: any; -} & GlobalDatabaseUserAttributes; - -export type SessionDoc = { - _id: string; - __v?: any; - active_expires: number; - user_id: string; - idle_expires: number; -} & GlobalDatabaseSessionAttributes; - -export type KeyDoc = { - _id: string; - __v?: any; - user_id: string; - hashed_password?: string; -}; diff --git a/packages/adapter-mongoose/src/index.ts b/packages/adapter-mongoose/src/index.ts deleted file mode 100644 index 1c21fefc6..000000000 --- a/packages/adapter-mongoose/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { mongooseAdapter as mongoose } from "./mongoose.js"; diff --git a/packages/adapter-mongoose/src/lucia.d.ts b/packages/adapter-mongoose/src/lucia.d.ts deleted file mode 100644 index 8026ca988..000000000 --- a/packages/adapter-mongoose/src/lucia.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -declare namespace Lucia { - type Auth = any; - type DatabaseUserAttributes = any; - type DatabaseSessionAttributes = any; -} diff --git a/packages/adapter-mongoose/src/mongoose.ts b/packages/adapter-mongoose/src/mongoose.ts deleted file mode 100644 index 1e6753148..000000000 --- a/packages/adapter-mongoose/src/mongoose.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { - Adapter, - InitializeAdapter, - KeySchema, - SessionSchema, - UserSchema -} from "lucia"; -import type { Model } from "mongoose"; -import type { KeyDoc, SessionDoc, UserDoc } from "./docs.js"; - -export const DEFAULT_PROJECTION = { - $__: 0, - __v: 0, - _doc: 0 -}; - -export const mongooseAdapter = (models: { - User: Model; - Session: Model | null; - Key: Model; -}): InitializeAdapter => { - const { User, Session, Key } = models; - return (LuciaError) => { - return { - getUser: async (userId: string) => { - const userDoc = await User.findById(userId, DEFAULT_PROJECTION).lean(); - if (!userDoc) return null; - return transformUserDoc(userDoc); - }, - setUser: async (user, key) => { - if (key) { - const refKeyDoc = await Key.findById(key.id, DEFAULT_PROJECTION); - if (refKeyDoc) throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - const userDoc = new User(createMongoValues(user)); - await userDoc.save(); - if (!key) return; - try { - const keyDoc = new Key(createMongoValues(key)); - await keyDoc.save(); - } catch (error) { - await Key.findByIdAndDelete(user.id); - if ( - error instanceof Error && - error.message.includes("E11000") && - error.message.includes("id") - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw error; - } - }, - deleteUser: async (userId: string) => { - await User.findByIdAndDelete(userId); - }, - updateUser: async (userId, partialUser) => { - await User.findByIdAndUpdate(userId, partialUser, { - new: true, - projection: DEFAULT_PROJECTION - }).lean(); - }, - - getSession: async (sessionId) => { - if (!Session) { - throw new Error("Session model not defined"); - } - const session = await Session.findById( - sessionId, - DEFAULT_PROJECTION - ).lean(); - if (!session) return null; - return transformSessionDoc(session); - }, - getSessionsByUserId: async (userId) => { - if (!Session) { - throw new Error("Session model not defined"); - } - const sessions = await Session.find( - { - user_id: userId - }, - DEFAULT_PROJECTION - ).lean(); - return sessions.map((val) => transformSessionDoc(val)); - }, - getSessionAndUserBySessionId: async (sessionId: string) => { - if (!Session) { - throw new Error("Session model not defined"); - } - - const sessionUsers = await Session.aggregate([ - { $match: { _id: sessionId } }, - { - $lookup: { - from: User.collection.name, - localField: "user_id", - // Relies on _id being a String, not ObjectId. - // But this assumption is used elsewhere, as well - foreignField: "_id", - as: "userDocs" - } - } - ]).exec(); - - const sessionUser = sessionUsers?.at(0) ?? null; - if (!sessionUser) return null; - - const { userDocs, ...sessionDoc } = sessionUser; - const userDoc = userDocs?.at(0) ?? null; - if (!userDoc) return null; - - return { - user: transformUserDoc(userDoc), - session: transformSessionDoc(sessionDoc) - }; - }, - setSession: async (session) => { - if (!Session) { - throw new Error("Session model not defined"); - } - const sessionDoc = new Session(createMongoValues(session)); - await sessionDoc.save(); - }, - deleteSession: async (sessionId) => { - if (!Session) { - throw new Error("Session model not defined"); - } - await Session.findByIdAndDelete(sessionId); - }, - deleteSessionsByUserId: async (userId) => { - if (!Session) { - throw new Error("Session model not defined"); - } - await Session.deleteMany({ - user_id: userId - }); - }, - updateSession: async (sessionId, partialUser) => { - if (!Session) { - throw new Error("Session model not defined"); - } - await Session.findByIdAndUpdate(sessionId, partialUser, { - new: true, - projection: DEFAULT_PROJECTION - }).lean(); - }, - - getKey: async (keyId) => { - const keyDoc = await Key.findById(keyId, DEFAULT_PROJECTION).lean(); - if (!keyDoc) return null; - return transformKeyDoc(keyDoc); - }, - setKey: async (key) => { - try { - const keyDoc = new Key(createMongoValues(key)); - await Key.create(keyDoc); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("E11000") && - error.message.includes("id") - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw error; - } - }, - getKeysByUserId: async (userId) => { - const keyDocs = await Key.find( - { - user_id: userId - }, - DEFAULT_PROJECTION - ).lean(); - return keyDocs.map((val) => transformKeyDoc(val)); - }, - deleteKey: async (keyId) => { - await Key.findByIdAndDelete(keyId); - }, - deleteKeysByUserId: async (userId) => { - await Key.deleteMany({ - user_id: userId - }); - }, - updateKey: async (keyId, partialKey) => { - await Key.findByIdAndUpdate(keyId, partialKey, { - new: true, - projection: DEFAULT_PROJECTION - }).lean(); - } - }; - }; -}; - -export const createMongoValues = (object: Record) => { - return Object.fromEntries( - Object.entries(object).map(([key, value]) => { - if (key === "id") return ["_id", value]; - return [key, value]; - }) - ); -}; - -export const transformUserDoc = (row: UserDoc): UserSchema => { - delete row.__v; - const { _id: id, ...attributes } = row; - return { - id, - ...attributes - }; -}; - -export const transformSessionDoc = (row: SessionDoc): SessionSchema => { - delete row.__v; - const { _id: id, ...attributes } = row; - return { - id, - ...attributes - }; -}; - -export const transformKeyDoc = (row: KeyDoc): KeySchema => { - return { - id: row._id, - user_id: row.user_id, - hashed_password: row.hashed_password ?? null - }; -}; diff --git a/packages/adapter-mongoose/test/db.ts b/packages/adapter-mongoose/test/db.ts deleted file mode 100644 index 1d0b87657..000000000 --- a/packages/adapter-mongoose/test/db.ts +++ /dev/null @@ -1,76 +0,0 @@ -import mongodb from "mongoose"; -import dotenv from "dotenv"; -import { resolve } from "path"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -export const User = mongodb.model( - "User", - new mongodb.Schema( - { - _id: { - type: String, - required: true - }, - username: { - unique: true, - type: String, - required: true - } - } as const, - { _id: false } - ) -); - -export const Session = mongodb.model( - "Session", - new mongodb.Schema( - { - _id: { - type: String, - required: true - }, - user_id: { - type: String, - required: true - }, - active_expires: { - type: Number, - required: true - }, - idle_expires: { - type: Number, - required: true - }, - country: { - type: String, - required: true - } - } as const, - { _id: false } - ) -); - -export const Key = mongodb.model( - "Key", - new mongodb.Schema( - { - _id: { - type: String, - required: true - }, - user_id: { - type: String, - required: true - }, - hashed_password: String - } as const, - { _id: false } - ) -); - -export const connect = async () => { - await mongodb.connect(process.env.MONGODB_URL as any); -}; diff --git a/packages/adapter-mongoose/test/index.ts b/packages/adapter-mongoose/test/index.ts deleted file mode 100644 index e2125469c..000000000 --- a/packages/adapter-mongoose/test/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Model } from "mongoose"; -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; - -import { mongoose } from "../src/index.js"; -import { - createMongoValues, - transformKeyDoc, - transformSessionDoc, - transformUserDoc -} from "../src/mongoose.js"; -import { User, Key, Session, connect } from "./db.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; - -const createPartialTableQueryHandler = ( - Model: Model -): Pick => { - return { - insert: async (value) => { - const sessionDoc = new Model(createMongoValues(value)); - await sessionDoc.save(); - }, - clear: async () => { - await Model.deleteMany(); - } - }; -}; - -const queryHandler: QueryHandler = { - user: { - get: async () => { - const userDocs = await User.find().lean(); - return userDocs.map((doc) => transformUserDoc(doc) as any); - }, - ...createPartialTableQueryHandler(User) - }, - session: { - get: async () => { - const sessionDocs = await Session.find().lean(); - return sessionDocs.map((doc) => transformSessionDoc(doc)); - }, - ...createPartialTableQueryHandler(Session) - }, - key: { - get: async () => { - const keyDocs = await Key.find().lean(); - return keyDocs.map((doc) => transformKeyDoc(doc)); - }, - ...createPartialTableQueryHandler(Key) - } -}; - -const adapter = mongoose({ - User, - Session, - Key -})(LuciaError); - -await connect(); -await testAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-mongoose/tsconfig.json b/packages/adapter-mongoose/tsconfig.json deleted file mode 100644 index f2fb9daed..000000000 --- a/packages/adapter-mongoose/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "target": "ES2022", - "outDir": "./dist", - "declaration": true, - - "noImplicitAny": true, - "allowSyntheticDefaultImports": true - }, - "include": ["src"], - "exclude": ["node_modules/", "**/*.test.ts"] -} diff --git a/packages/adapter-mysql/CHANGELOG.md b/packages/adapter-mysql/CHANGELOG.md index 62c18f5bf..d29c0af25 100644 --- a/packages/adapter-mysql/CHANGELOG.md +++ b/packages/adapter-mysql/CHANGELOG.md @@ -1,5 +1,9 @@ # @lucia-auth/adapter-mysql +## 3.0.0 + +See the [migration guide](https://v3.lucia-auth.com/upgrade-v3/mysql). + ## 2.1.0 ### Minor changes diff --git a/packages/adapter-mysql/README.md b/packages/adapter-mysql/README.md index 894734e1a..6c03d2018 100644 --- a/packages/adapter-mysql/README.md +++ b/packages/adapter-mysql/README.md @@ -1,16 +1,17 @@ # `@lucia-auth/adapter-mysql` -MySQL adapter for Lucia v2. +MySQL adapter for Lucia. -**[Documentation](https://lucia-auth.com/reference#lucia-authadapter-mysql)** +**[Documentation](https://v3.lucia-auth.com/database/mysql#schema)** -**[Lucia documentation](https://lucia-auth.com)** +**[Lucia documentation](https://v3.lucia-auth.com)** **[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-mysql/CHANGELOG.md)** ## Supported drivers - [`mysql2`](https://github.com/sidorares/node-mysql2) +- [Planetscale serverless driver](https://github.com/planetscale/database-js) ## Installation diff --git a/packages/adapter-mysql/package.json b/packages/adapter-mysql/package.json index 99c83fb18..06289e9ce 100644 --- a/packages/adapter-mysql/package.json +++ b/packages/adapter-mysql/package.json @@ -1,6 +1,6 @@ { "name": "@lucia-auth/adapter-mysql", - "version": "2.1.0", + "version": "3.0.0", "description": "MySQL adapter for Lucia", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -12,10 +12,8 @@ ], "scripts": { "build": "shx rm -rf ./dist/* && tsc", - "test.mysql2": "tsx test/mysql2/index.ts", - "test.planetscale": "tsx test/planetscale/index.ts", - "test-setup.planetscale": "tsx test/planetscale/setup.ts", - "test-setup.mysql2": "tsx test/mysql2/setup.ts", + "test.mysql2": "tsx tests/mysql2.ts", + "test.planetscale": "tsx tests/planetscale.ts", "auri.build": "pnpm build" }, "keywords": [ @@ -39,7 +37,7 @@ ".": "./dist/index.js" }, "peerDependencies": { - "lucia": "^2.0.0", + "lucia": "3.x", "mysql2": "^3.0.0", "@planetscale/database": "^1.0.0" }, @@ -52,10 +50,10 @@ } }, "devDependencies": { - "@lucia-auth/adapter-test": "latest", + "@lucia-auth/adapter-test": "workspace:*", "@planetscale/database": "^1.8.0", "dotenv": "^16.0.3", - "lucia": "latest", + "lucia": "workspace:*", "mysql2": "^3.2.3", "tsx": "^3.12.6" } diff --git a/packages/adapter-mysql/src/base.ts b/packages/adapter-mysql/src/base.ts new file mode 100644 index 000000000..5dd84ee89 --- /dev/null +++ b/packages/adapter-mysql/src/base.ts @@ -0,0 +1,147 @@ +import type { + Adapter, + DatabaseSession, + RegisteredDatabaseSessionAttributes, + DatabaseUser, + RegisteredDatabaseUserAttributes +} from "lucia"; + +export class MySQLAdapter implements Adapter { + private controller: Controller; + + private escapedUserTableName: string; + private escapedSessionTableName: string; + + constructor(controller: Controller, tableNames: TableNames) { + this.controller = controller; + this.escapedSessionTableName = escapeName(tableNames.session); + this.escapedUserTableName = escapeName(tableNames.user); + } + + public async deleteSession(sessionId: string): Promise { + await this.controller.execute(`DELETE FROM ${this.escapedSessionTableName} WHERE id = ?`, [ + sessionId + ]); + } + + public async deleteUserSessions(userId: string): Promise { + await this.controller.execute(`DELETE FROM ${this.escapedSessionTableName} WHERE user_id = ?`, [ + userId + ]); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const [databaseSession, databaseUser] = await Promise.all([ + this.getSession(sessionId), + this.getUserFromSessionId(sessionId) + ]); + return [databaseSession, databaseUser]; + } + + public async getUserSessions(userId: string): Promise { + const result = await this.controller.getAll( + `SELECT * FROM ${this.escapedSessionTableName} WHERE user_id = ?`, + [userId] + ); + return result.map((val) => { + return transformIntoDatabaseSession(val); + }); + } + + public async setSession(databaseSession: DatabaseSession): Promise { + const value: SessionSchema = { + id: databaseSession.id, + user_id: databaseSession.userId, + expires_at: databaseSession.expiresAt, + ...databaseSession.attributes + }; + const entries = Object.entries(value).filter(([_, v]) => v !== undefined); + const columns = entries.map(([k]) => escapeName(k)); + const placeholders = Array(columns.length).fill("?"); + const values = entries.map(([_, v]) => v); + await this.controller.execute( + `INSERT INTO ${this.escapedSessionTableName} (${columns.join( + ", " + )}) VALUES (${placeholders.join(", ")})`, + values + ); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.controller.execute( + `UPDATE ${this.escapedSessionTableName} SET expires_at = ? WHERE id = ?`, + [expiresAt, sessionId] + ); + } + + public async deleteExpiredSessions(): Promise { + await this.controller.execute( + `DELETE FROM ${this.escapedSessionTableName} WHERE expires_at <= ?`, + [new Date()] + ); + } + + private async getSession(sessionId: string): Promise { + const result = await this.controller.get( + `SELECT * FROM ${this.escapedSessionTableName} WHERE id = ?`, + [sessionId] + ); + if (!result) return null; + return transformIntoDatabaseSession(result); + } + + private async getUserFromSessionId(sessionId: string): Promise { + const result = await this.controller.get( + `SELECT ${this.escapedUserTableName}.* FROM ${this.escapedSessionTableName} INNER JOIN ${this.escapedUserTableName} ON ${this.escapedUserTableName}.id = ${this.escapedSessionTableName}.user_id WHERE ${this.escapedSessionTableName}.id = ?`, + [sessionId] + ); + if (!result) return null; + return transformIntoDatabaseUser(result); + } +} + +export interface TableNames { + user: string; + session: string; +} + +export interface Controller { + execute(sql: string, args?: any[]): Promise; + get(sql: string, args?: any[]): Promise; + getAll(sql: string, args?: any[]): Promise; +} + +interface SessionSchema extends RegisteredDatabaseSessionAttributes { + id: string; + user_id: string; + expires_at: Date | string; +} + +interface UserSchema extends RegisteredDatabaseUserAttributes { + id: string; +} + +function transformIntoDatabaseSession(raw: SessionSchema): DatabaseSession { + const { id, user_id: userId, expires_at: expiresAtResult, ...attributes } = raw; + return { + userId, + id, + expiresAt: + expiresAtResult instanceof Date ? expiresAtResult : new Date(expiresAtResult + " GMT"), + attributes + }; +} + +function transformIntoDatabaseUser(raw: UserSchema): DatabaseUser { + const { id, ...attributes } = raw; + return { + id, + attributes + }; +} + +function escapeName(val: string): string { + return "`" + val + "`"; +} diff --git a/packages/adapter-mysql/src/drivers/mysql2.ts b/packages/adapter-mysql/src/drivers/mysql2.ts index 8be8c518f..f09544dd2 100644 --- a/packages/adapter-mysql/src/drivers/mysql2.ts +++ b/packages/adapter-mysql/src/drivers/mysql2.ts @@ -1,339 +1,31 @@ -import { helper, getSetArgs, escapeName } from "../utils.js"; +import { MySQLAdapter } from "../base.js"; -import type { - SessionSchema, - Adapter, - InitializeAdapter, - UserSchema, - KeySchema -} from "lucia"; -import type { - Pool, - QueryError, - RowDataPacket, - OkPacket, - ResultSetHeader, - PoolConnection, - Connection -} from "mysql2/promise"; +import type { Controller, TableNames } from "../base.js"; +import type { Pool, Connection } from "mysql2/promise"; -export const mysql2Adapter = ( - db: Pool | Connection, - tables: { - user: string; - session: string | null; - key: string; +export class Mysql2Adapter extends MySQLAdapter { + constructor(connection: Pool | Connection, tableNames: TableNames) { + super(new Mysql2Controller(connection), tableNames); } -): InitializeAdapter => { - const ESCAPED_USER_TABLE_NAME = escapeName(tables.user); - const ESCAPED_SESSION_TABLE_NAME = tables.session - ? escapeName(tables.session) - : null; - const ESCAPED_KEY_TABLE_NAME = escapeName(tables.key); - return (LuciaError) => { - return { - getUser: async (userId) => { - const result = await get( - db.query(`SELECT * FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`, [ - userId - ]) - ); - return result; - }, - setUser: async (user, key) => { - if (!key) { - const [userFields, userValues, userArgs] = helper(user); - await db.execute( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - return; - } - try { - await transaction(db, async (connection) => { - const [userFields, userValues, userArgs] = helper(user); - await connection.execute( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - const [keyFields, keyValues, keyArgs] = helper(key); - await connection.execute( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${keyFields} ) VALUES ( ${keyValues} )`, - keyArgs - ); - }); - } catch (e) { - const error = e as Partial; - if ( - error.code === "ER_DUP_ENTRY" && - error.message?.includes("PRIMARY") && - error.message?.includes(tables.key) - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteUser: async (userId) => { - await db.query(`DELETE FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`, [ - userId - ]); - }, - updateUser: async (userId, partialUser) => { - const [fields, values, args] = helper(partialUser); - await db.execute( - `UPDATE ${ESCAPED_USER_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?`, - [...args, userId] - ); - }, +} - getSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await get( - db.query(`SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, [ - sessionId - ]) - ); - return result; - }, - getSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await getAll( - db.query( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?`, - [userId] - ) - ); - return result; - }, - setSession: async (session) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - try { - const [fields, values, args] = helper(session); - await db.execute( - `INSERT INTO ${ESCAPED_SESSION_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - } catch (e) { - const error = e as Partial; - if (error.errno === 1452 && error.message?.includes("(`user_id`)")) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - throw e; - } - }, - deleteSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await db.execute( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, - [sessionId] - ); - }, - deleteSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await db.execute( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?`, - [userId] - ); - }, - updateSession: async (sessionId, partialSession) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(partialSession); - await db.execute( - `UPDATE ${ESCAPED_SESSION_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?`, - [...args, sessionId] - ); - }, - - getKey: async (keyId) => { - const result = await get( - db.query(`SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`, [ - keyId - ]) - ); - return result; - }, - getKeysByUserId: async (userId) => { - const result = getAll( - db.execute( - `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`, - [userId] - ) - ); - return result; - }, - setKey: async (key) => { - try { - const [fields, values, args] = helper(key); - await db.execute( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - } catch (e) { - const error = e as Partial; - if (error.errno === 1452 && error.message?.includes("(`user_id`)")) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - if ( - error.code === "ER_DUP_ENTRY" && - error.message?.includes("PRIMARY") && - error.message?.includes(tables.key) - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteKey: async (keyId) => { - await db.execute(`DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`, [ - keyId - ]); - }, - deleteKeysByUserId: async (userId) => { - await db.execute( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`, - [userId] - ); - }, - updateKey: async (keyId, partialKey) => { - const [fields, values, args] = helper(partialKey); - await db.execute( - `UPDATE ${ESCAPED_KEY_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?`, - [...args, keyId] - ); - }, - - getSessionAndUser: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const getSessionPromise = get( - db.query(`SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, [ - sessionId - ]) - ); - const getUserFromJoinPromise = get< - UserSchema & { - __session_id: string; - } - >( - db.query( - `SELECT ${ESCAPED_USER_TABLE_NAME}.*, ${ESCAPED_SESSION_TABLE_NAME}.id as __session_id FROM ${ESCAPED_SESSION_TABLE_NAME} INNER JOIN ${ESCAPED_USER_TABLE_NAME} ON ${ESCAPED_USER_TABLE_NAME}.id = ${ESCAPED_SESSION_TABLE_NAME}.user_id WHERE ${ESCAPED_SESSION_TABLE_NAME}.id = ?`, - [sessionId] - ) - ); - const [sessionResult, userFromJoinResult] = await Promise.all([ - getSessionPromise, - getUserFromJoinPromise - ]); - if (!sessionResult || !userFromJoinResult) return [null, null]; - const { __session_id: _, ...userResult } = userFromJoinResult; - return [sessionResult, userResult]; - } - }; - }; -}; - -const isPacketArray = ( - maybeRowDataPacketArray: RowDataPacket[] | RowDataPacket[][] | OkPacket[] -): maybeRowDataPacketArray is RowDataPacket[] => { - const firstVal = maybeRowDataPacketArray.at(0) ?? null; - if (!firstVal) return true; - if (!Array.isArray(firstVal)) return true; - return false; -}; -export const get = async ( - queryPromise: Promise< - [ - ( - | RowDataPacket[] - | RowDataPacket[][] - | OkPacket - | OkPacket[] - | ResultSetHeader - ), - any - ] - > -): Promise => { - const [rows] = await queryPromise; - if (!Array.isArray(rows)) return null; - const result = rows.at(0) ?? null; - if (!result || Array.isArray(result)) return null; - return result as any; -}; +class Mysql2Controller implements Controller { + private connection: Pool | Connection; + constructor(connection: Pool | Connection) { + this.connection = connection; + } -export const getAll = async ( - queryPromise: Promise< - [ - ( - | RowDataPacket[] - | RowDataPacket[][] - | OkPacket - | OkPacket[] - | ResultSetHeader - ), - any - ] - > -): Promise => { - const [rows] = await queryPromise; - if (!Array.isArray(rows)) return []; - if (!isPacketArray(rows)) return []; - return rows as any; -}; + public async get(sql: string, args: any[]): Promise { + const [rows] = await this.connection.query(sql, args); + return (rows as T[]).at(0) ?? null; + } -const transaction = async < - _Execute extends (connection: Connection | PoolConnection) => Promise ->( - db: Pool | Connection, - execute: _Execute -) => { - if (isPool(db)) { - const connection = await db.getConnection(); - try { - await connection.beginTransaction(); - await execute(connection); - await connection.commit(); - connection.release(); - } catch (e) { - await connection.rollback(); - connection.release(); - throw e; - } - } else { - try { - await db.beginTransaction(); - await execute(db); - await db.commit(); - } catch (e) { - await db.rollback(); - throw e; - } + public async getAll(sql: string, args: any[]): Promise { + const [rows] = await this.connection.query(sql, args); + return rows as T[]; } -}; -const isPool = (db: Pool | Connection): db is Pool => { - return "getConnection" in db; -}; + public async execute(sql: string, args: any[]): Promise { + await this.connection.execute(sql, args); + } +} diff --git a/packages/adapter-mysql/src/drivers/planetscale.ts b/packages/adapter-mysql/src/drivers/planetscale.ts index 9f0c09cf9..dade67bf6 100644 --- a/packages/adapter-mysql/src/drivers/planetscale.ts +++ b/packages/adapter-mysql/src/drivers/planetscale.ts @@ -1,279 +1,31 @@ -import { escapeName, getSetArgs, helper } from "../utils.js"; +import { MySQLAdapter } from "../base.js"; -import type { - Connection, - DatabaseError, - ExecutedQuery -} from "@planetscale/database"; -import type { - Adapter, - InitializeAdapter, - UserSchema, - SessionSchema, - KeySchema -} from "lucia"; +import type { Controller, TableNames } from "../base.js"; +import type { Connection } from "@planetscale/database"; -export const planetscaleAdapter = ( - connection: Pick, - tables: { - user: string; - session: string | null; - key: string; +export class PlanetScaleAdapter extends MySQLAdapter { + constructor(connection: Connection, tableNames: TableNames) { + super(new PlanetScaleController(connection), tableNames); } -): InitializeAdapter => { - const ESCAPED_USER_TABLE_NAME = escapeName(tables.user); - const ESCAPED_SESSION_TABLE_NAME = tables.session - ? escapeName(tables.session) - : null; - const ESCAPED_KEY_TABLE_NAME = escapeName(tables.key); +} - return (LuciaError) => { - return { - getUser: async (userId) => { - const result = await get( - connection.execute( - `SELECT * FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`, - [userId] - ) - ); - return result; - }, - setUser: async (user, key) => { - if (!key) { - const [userFields, userValues, userArgs] = helper(user); - await connection.execute( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - return; - } - try { - await connection.transaction(async (tx) => { - const [userFields, userValues, userArgs] = helper(user); - await tx.execute( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - const [keyFields, keyValues, keyArgs] = helper(key); - await tx.execute( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${keyFields} ) VALUES ( ${keyValues} )`, - keyArgs - ); - }); - } catch (e) { - const error = e as Partial; - if ( - error.body?.message.includes("AlreadyExists") && - error.body?.message.includes("PRIMARY") && - error.body?.message.includes(`${tables.key}`) - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteUser: async (userId) => { - await connection.execute( - `DELETE FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`, - [userId] - ); - }, - updateUser: async (userId, partialUser) => { - const [fields, values, args] = helper(partialUser); - await connection.execute( - `UPDATE ${ESCAPED_USER_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?`, - [...args, userId] - ); - }, - - getSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await get( - connection.execute( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, - [sessionId] - ) - ); - return result ? transformPlanetscaleSession(result) : null; - }, - getSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await getAll( - connection.execute( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?`, - [userId] - ) - ); - return result.map((val) => transformPlanetscaleSession(val)); - }, - setSession: async (session) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(session); - await connection.execute( - `INSERT INTO ${ESCAPED_SESSION_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - }, - deleteSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await connection.execute( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, - [sessionId] - ); - }, - deleteSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await connection.execute( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?`, - [userId] - ); - }, - updateSession: async (sessionId, partialSession) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(partialSession); - await connection.execute( - `UPDATE ${ESCAPED_SESSION_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?`, - [...args, sessionId] - ); - }, - - getKey: async (keyId) => { - const result = await get( - connection.execute( - `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`, - [keyId] - ) - ); - return result; - }, - getKeysByUserId: async (userId) => { - const result = getAll( - connection.execute( - `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`, - [userId] - ) - ); - return result; - }, - setKey: async (key) => { - try { - const [fields, values, args] = helper(key); - await connection.execute( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - } catch (e) { - const error = e as Partial; - if ( - error.body?.message.includes("AlreadyExists") && - error.body?.message.includes("PRIMARY") && - error.body?.message.includes(`${tables.key}`) - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteKey: async (keyId) => { - await connection.execute( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`, - [keyId] - ); - }, - deleteKeysByUserId: async (userId) => { - await connection.execute( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`, - [userId] - ); - }, - updateKey: async (keyId, partialKey) => { - const [fields, values, args] = helper(partialKey); - await connection.execute( - `UPDATE ${ESCAPED_KEY_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?`, - [...args, keyId] - ); - }, - - getSessionAndUser: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [sessionResult, userFromJoinResult] = await Promise.all([ - get( - connection.execute( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, - [sessionId] - ) - ), - get< - UserSchema & { - __session_id: string; - } - >( - connection.execute( - `SELECT ${ESCAPED_USER_TABLE_NAME}.*, ${ESCAPED_SESSION_TABLE_NAME}.id as __session_id FROM ${ESCAPED_SESSION_TABLE_NAME} INNER JOIN ${ESCAPED_USER_TABLE_NAME} ON ${ESCAPED_USER_TABLE_NAME}.id = ${ESCAPED_SESSION_TABLE_NAME}.user_id WHERE ${ESCAPED_SESSION_TABLE_NAME}.id = ?`, - [sessionId] - ) - ) - ]); - if (!sessionResult || !userFromJoinResult) return [null, null]; - const { __session_id: _, ...userResult } = userFromJoinResult; - return [transformPlanetscaleSession(sessionResult), userResult]; - } - }; - }; -}; - -export const get = async ( - queryPromise: Promise -): Promise => { - const { rows } = await queryPromise; - const result = rows.at(0) ?? null; - return result as any; -}; +class PlanetScaleController implements Controller { + private connection: Connection; + constructor(connection: Connection) { + this.connection = connection; + } -export const getAll = async ( - queryPromise: Promise -): Promise => { - const { rows } = await queryPromise; - return rows as any; -}; + public async get(sql: string, args: any[]): Promise { + const { rows } = await this.connection.execute(sql, args); + return (rows as T[]).at(0) ?? null; + } -export type PlanetscaleSession = Omit< - SessionSchema, - "active_expires" | "idle_expires" -> & { - active_expires: BigInt; - idle_expires: BigInt; -}; + public async getAll(sql: string, args: any[]): Promise { + const { rows } = await this.connection.execute(sql, args); + return rows as T[]; + } -export const transformPlanetscaleSession = ( - session: PlanetscaleSession -): SessionSchema => { - return { - ...session, - active_expires: Number(session.active_expires), - idle_expires: Number(session.idle_expires) - }; -}; + public async execute(sql: string, args: any[]): Promise { + await this.connection.execute(sql, args); + } +} diff --git a/packages/adapter-mysql/src/index.ts b/packages/adapter-mysql/src/index.ts index e5a27e374..358dd6a2c 100644 --- a/packages/adapter-mysql/src/index.ts +++ b/packages/adapter-mysql/src/index.ts @@ -1,2 +1,2 @@ -export { mysql2Adapter as mysql2 } from "./drivers/mysql2.js"; -export { planetscaleAdapter as planetscale } from "./drivers/planetscale.js"; +export { Mysql2Adapter } from "./drivers/mysql2.js"; +export { PlanetScaleAdapter } from "./drivers/planetscale.js"; diff --git a/packages/adapter-mysql/src/lucia.d.ts b/packages/adapter-mysql/src/lucia.d.ts deleted file mode 100644 index 97a71b7b4..000000000 --- a/packages/adapter-mysql/src/lucia.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -declare namespace Lucia { - type Auth = any; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} diff --git a/packages/adapter-mysql/src/utils.ts b/packages/adapter-mysql/src/utils.ts deleted file mode 100644 index 7a1d88d73..000000000 --- a/packages/adapter-mysql/src/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -const createPreparedStatementHelper = ( - placeholder: (index: number) => string -) => { - const helper = ( - values: Record - ): readonly [fields: string[], placeholders: string[], arguments: any[]] => { - const keys = Object.keys(values); - return [ - keys.map((k) => escapeName(k)), - keys.map((_, i) => placeholder(i)), - keys.map((k) => values[k]) - ] as const; - }; - return helper; -}; - -const ESCAPE_CHAR = "`"; - -export const escapeName = (val: string) => { - return `${ESCAPE_CHAR}${val}${ESCAPE_CHAR}`; -}; - -export const helper = createPreparedStatementHelper(() => "?"); - -export const getSetArgs = (fields: string[], placeholders: string[]) => { - return fields - .map((field, i) => [field, placeholders[i]].join(" = ")) - .join(","); -}; diff --git a/packages/adapter-mysql/test/mysql2/db.ts b/packages/adapter-mysql/test/mysql2/db.ts deleted file mode 100644 index 1ac9963aa..000000000 --- a/packages/adapter-mysql/test/mysql2/db.ts +++ /dev/null @@ -1,14 +0,0 @@ -import mysql from "mysql2/promise"; -import dotenv from "dotenv"; -import { resolve } from "path"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -export const pool = mysql.createPool({ - host: "localhost", - user: "root", - database: process.env.MYSQL2_DATABASE, - password: process.env.MYSQL2_PASSWORD -}); diff --git a/packages/adapter-mysql/test/mysql2/index.ts b/packages/adapter-mysql/test/mysql2/index.ts deleted file mode 100644 index 50cbd85aa..000000000 --- a/packages/adapter-mysql/test/mysql2/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; - -import { pool } from "./db.js"; -import { escapeName, helper } from "../../src/utils.js"; -import { getAll, mysql2Adapter } from "../../src/drivers/mysql2.js"; -import { TABLE_NAMES } from "../shared.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; - -const createTableQueryHandler = (tableName: string): TableQueryHandler => { - const ESCAPED_TABLE_NAME = escapeName(tableName); - return { - get: async () => { - return await getAll(pool.query(`SELECT * FROM ${ESCAPED_TABLE_NAME}`)); - }, - insert: async (value: any) => { - const [fields, placeholders, args] = helper(value); - await pool.execute( - `INSERT INTO ${ESCAPED_TABLE_NAME} ( ${fields} ) VALUES ( ${placeholders} )`, - args - ); - }, - clear: async () => { - await pool.execute(`DELETE FROM ${ESCAPED_TABLE_NAME}`); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(TABLE_NAMES.user), - session: createTableQueryHandler(TABLE_NAMES.session), - key: createTableQueryHandler(TABLE_NAMES.key) -}; - -const adapter = mysql2Adapter(pool, TABLE_NAMES)(LuciaError); - -await testAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-mysql/test/mysql2/setup.ts b/packages/adapter-mysql/test/mysql2/setup.ts deleted file mode 100644 index 3da560c0d..000000000 --- a/packages/adapter-mysql/test/mysql2/setup.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { pool } from "./db.js"; -import { - ESCAPED_USER_TABLE_NAME, - ESCAPED_SESSION_TABLE_NAME, - ESCAPED_KEY_TABLE_NAME -} from "../shared.js"; - -await pool.execute(` -CREATE TABLE IF NOT EXISTS ${ESCAPED_USER_TABLE_NAME} ( - id VARCHAR(15) PRIMARY KEY, - username VARCHAR(15) NOT NULL UNIQUE -) -`); - -await pool.execute(` -CREATE TABLE IF NOT EXISTS ${ESCAPED_SESSION_TABLE_NAME} ( - id VARCHAR(127) PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - active_expires BIGINT UNSIGNED NOT NULL, - idle_expires BIGINT UNSIGNED NOT NULL, - country VARCHAR(2) NOT NULL, - - FOREIGN KEY (user_id) REFERENCES ${ESCAPED_USER_TABLE_NAME}(id) -) -`); - -await pool.execute(` -CREATE TABLE IF NOT EXISTS ${ESCAPED_KEY_TABLE_NAME} ( - id VARCHAR(255) PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - hashed_password VARCHAR(255), - - FOREIGN KEY (user_id) REFERENCES ${ESCAPED_USER_TABLE_NAME}(id) -) -`); - -process.exit(0); diff --git a/packages/adapter-mysql/test/planetscale/db.ts b/packages/adapter-mysql/test/planetscale/db.ts deleted file mode 100644 index a14e38f5c..000000000 --- a/packages/adapter-mysql/test/planetscale/db.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from "@planetscale/database"; -import dotenv from "dotenv"; -import { resolve } from "path"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -export const connection = connect({ - host: process.env.PLANETSCALE_HOST, - username: process.env.PLANETSCALE_USERNAME, - password: process.env.PLANETSCALE_PASSWORD -}); diff --git a/packages/adapter-mysql/test/planetscale/index.ts b/packages/adapter-mysql/test/planetscale/index.ts deleted file mode 100644 index c09b34d57..000000000 --- a/packages/adapter-mysql/test/planetscale/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; - -import { connection } from "./db.js"; -import { helper, escapeName } from "../../src/utils.js"; -import { - getAll, - planetscaleAdapter, - transformPlanetscaleSession -} from "../../src/drivers/planetscale.js"; -import { TABLE_NAMES, ESCAPED_SESSION_TABLE_NAME } from "../shared.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; -import type { PlanetscaleSession } from "../../src/drivers/planetscale.js"; - -const createTableQueryHandler = (tableName: string): TableQueryHandler => { - const ESCAPED_TABLE_NAME = escapeName(tableName); - return { - get: async () => { - return await getAll( - connection.execute(`SELECT * FROM ${ESCAPED_TABLE_NAME}`) - ); - }, - insert: async (value: any) => { - const [fields, placeholders, args] = helper(value); - await connection.execute( - `INSERT INTO ${ESCAPED_TABLE_NAME} ( ${fields} ) VALUES ( ${placeholders} )`, - args - ); - }, - clear: async () => { - await connection.execute(`DELETE FROM ${ESCAPED_TABLE_NAME}`); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(TABLE_NAMES.user), - session: { - ...createTableQueryHandler(TABLE_NAMES.session), - get: async () => { - const result = await getAll( - connection.execute(`SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME}`) - ); - return result.map((val) => transformPlanetscaleSession(val)); - } - }, - key: createTableQueryHandler(TABLE_NAMES.key) -}; - -const adapter = planetscaleAdapter(connection, TABLE_NAMES)(LuciaError); - -await testAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-mysql/test/planetscale/setup.ts b/packages/adapter-mysql/test/planetscale/setup.ts deleted file mode 100644 index 19206d889..000000000 --- a/packages/adapter-mysql/test/planetscale/setup.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { connection } from "./db.js"; -import { - ESCAPED_USER_TABLE_NAME, - ESCAPED_SESSION_TABLE_NAME, - ESCAPED_KEY_TABLE_NAME -} from "../shared.js"; - -await connection.execute(` -CREATE TABLE IF NOT EXISTS ${ESCAPED_USER_TABLE_NAME} ( - id VARCHAR(15) PRIMARY KEY, - username VARCHAR(15) NOT NULL UNIQUE -) -`); - -await connection.execute(` -CREATE TABLE IF NOT EXISTS ${ESCAPED_SESSION_TABLE_NAME} ( - id VARCHAR(127) PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - active_expires BIGINT UNSIGNED NOT NULL, - idle_expires BIGINT UNSIGNED NOT NULL, - country VARCHAR(2) NOT NULL -) -`); - -await connection.execute(` -CREATE TABLE IF NOT EXISTS ${ESCAPED_KEY_TABLE_NAME} ( - id VARCHAR(255) PRIMARY KEY, - user_id VARCHAR(15) NOT NULL, - hashed_password VARCHAR(255) -) -`); - -process.exit(0); diff --git a/packages/adapter-mysql/test/shared.ts b/packages/adapter-mysql/test/shared.ts deleted file mode 100644 index b745122fc..000000000 --- a/packages/adapter-mysql/test/shared.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { escapeName } from "../src/utils"; - -export const TABLE_NAMES = { - user: "test_user", - session: "user_session", - key: "user_key" -}; - -export const ESCAPED_USER_TABLE_NAME = escapeName(TABLE_NAMES.user); -export const ESCAPED_SESSION_TABLE_NAME = escapeName(TABLE_NAMES.session); -export const ESCAPED_KEY_TABLE_NAME = escapeName(TABLE_NAMES.key); diff --git a/packages/adapter-mysql/tests/mysql2.ts b/packages/adapter-mysql/tests/mysql2.ts new file mode 100644 index 000000000..164cdb106 --- /dev/null +++ b/packages/adapter-mysql/tests/mysql2.ts @@ -0,0 +1,50 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { Mysql2Adapter } from "../src/drivers/mysql2.js"; +import mysql from "mysql2/promise"; + +import dotenv from "dotenv"; +import { resolve } from "path"; + +dotenv.config({ + path: resolve(".env") +}); + +const connection = await mysql.createConnection({ + host: "localhost", + user: "root", + database: process.env.MYSQL2_DATABASE, + password: process.env.MYSQL2_PASSWORD +}); + +await connection.execute("DROP TABLE IF EXISTS user_session"); +await connection.execute("DROP TABLE IF EXISTS test_user"); + +await connection.execute(`CREATE TABLE IF NOT EXISTS test_user ( + id VARCHAR(255) PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE +)`); + +await connection.execute(`CREATE TABLE IF NOT EXISTS user_session ( + id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + country VARCHAR(255), + FOREIGN KEY (user_id) REFERENCES test_user(id) +)`); + +await connection.execute("INSERT INTO test_user (id, username) VALUES (?, ?)", [ + databaseUser.id, + databaseUser.attributes.username +]); + +const adapter = new Mysql2Adapter(connection, { + user: "test_user", + session: "user_session" +}); + +await testAdapter(adapter); + +await connection.execute("DROP TABLE IF EXISTS user_session"); +await connection.execute("DROP TABLE IF EXISTS test_user"); + +process.exit(); diff --git a/packages/adapter-mysql/tests/planetscale.ts b/packages/adapter-mysql/tests/planetscale.ts new file mode 100644 index 000000000..4574e4fd5 --- /dev/null +++ b/packages/adapter-mysql/tests/planetscale.ts @@ -0,0 +1,48 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { PlanetScaleAdapter } from "../src/drivers/planetscale.js"; +import { connect } from "@planetscale/database"; + +import dotenv from "dotenv"; +import { resolve } from "path"; + +dotenv.config({ + path: resolve(".env") +}); + +const connection = connect({ + host: process.env.PLANETSCALE_HOST, + username: process.env.PLANETSCALE_USERNAME, + password: process.env.PLANETSCALE_PASSWORD +}); + +await connection.execute("DROP TABLE IF EXISTS user_session"); +await connection.execute("DROP TABLE IF EXISTS test_user"); + +await connection.execute(`CREATE TABLE IF NOT EXISTS test_user ( + id VARCHAR(255) PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE +)`); + +await connection.execute(`CREATE TABLE IF NOT EXISTS user_session ( + id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + country VARCHAR(255) +)`); + +await connection.execute("INSERT INTO test_user (id, username) VALUES (?, ?)", [ + databaseUser.id, + databaseUser.attributes.username +]); + +const adapter = new PlanetScaleAdapter(connection, { + user: "test_user", + session: "user_session" +}); + +await testAdapter(adapter); + +await connection.execute("DROP TABLE IF EXISTS user_session"); +await connection.execute("DROP TABLE IF EXISTS test_user"); + +process.exit(); diff --git a/packages/adapter-mysql/tsconfig.json b/packages/adapter-mysql/tsconfig.json index f2fb9daed..5cb3f5468 100644 --- a/packages/adapter-mysql/tsconfig.json +++ b/packages/adapter-mysql/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/adapter-postgresql/.env.example b/packages/adapter-postgresql/.env.example index 203c24137..09c725348 100644 --- a/packages/adapter-postgresql/.env.example +++ b/packages/adapter-postgresql/.env.example @@ -1 +1 @@ -PSQL_DATABASE_URL="" \ No newline at end of file +POSTGRES_DATABASE_URL="" \ No newline at end of file diff --git a/packages/adapter-postgresql/CHANGELOG.md b/packages/adapter-postgresql/CHANGELOG.md index 36a2228cb..3e09af6d8 100644 --- a/packages/adapter-postgresql/CHANGELOG.md +++ b/packages/adapter-postgresql/CHANGELOG.md @@ -1,5 +1,9 @@ # @lucia-auth/adapter-postgresql +## 3.0.0 + +See the [migration guide](https://v3.lucia-auth.com/upgrade-v3/postgresql). + ## 2.0.2 ### Patch changes diff --git a/packages/adapter-postgresql/README.md b/packages/adapter-postgresql/README.md index 71b86c70d..15e566b20 100644 --- a/packages/adapter-postgresql/README.md +++ b/packages/adapter-postgresql/README.md @@ -1,17 +1,17 @@ # `@lucia-auth/adapter-postgresql` -PostgreSQL adapter for Lucia v2. +PostgreSQL adapter for Lucia. -**[Documentation](https://lucia-auth.com/reference#lucia-authadapter-postgresql)** +**[Documentation](https://v3.lucia-auth.com/database/postgresql)** -**[Lucia documentation](https://lucia-auth.com)** +**[Lucia documentation](https://v3.lucia-auth.com)** **[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-postgresql/CHANGELOG.md)** ## Supported drivers -- [`pg`](https://github.com/brianc/node-postgres) -- [`postgres`](https://github.com/porsager/postgres) +- [node-postgres (`pg`)](https://github.com/brianc/node-postgres) +- [Postgres.js (`postgres`)](https://github.com/porsager/postgres) ## Installation @@ -26,17 +26,17 @@ yarn add @lucia-auth/adapter-postgresql Set PostgreSQL database connection url in `.env`: ```bash -PSQL_DATABASE_URL="postgresql://localhost/lucia" +POSTGRES_DATABASE_URL="postgresql://localhost/lucia" ``` -### `pg` +### node-postgres ``` -pnpm test.pg +pnpm test.node-postgres ``` -### `postgres` +### Postgres.js ``` -pnpm test.postgres +pnpm test.postgresjs ``` diff --git a/packages/adapter-postgresql/package.json b/packages/adapter-postgresql/package.json index 2851e1616..9768b6a4f 100644 --- a/packages/adapter-postgresql/package.json +++ b/packages/adapter-postgresql/package.json @@ -1,6 +1,6 @@ { "name": "@lucia-auth/adapter-postgresql", - "version": "2.0.2", + "version": "3.0.0", "description": "PostgreSQL adapter for Lucia", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -12,10 +12,8 @@ ], "scripts": { "build": "shx rm -rf ./dist/* && tsc", - "test.pg": "tsx test/pg/index.ts", - "test-setup.pg": "tsx test/pg/setup.ts", - "test.postgres": "tsx test/postgres/index.ts", - "test-setup.postgres": "tsx test/postgres/setup.ts", + "test.node-postgres": "tsx tests/node-postgres.ts", + "test.postgresjs": "tsx tests/postgresjs.ts", "auri.build": "pnpm build" }, "keywords": [ @@ -41,7 +39,7 @@ ".": "./dist/index.js" }, "peerDependencies": { - "lucia": "^2.0.0", + "lucia": "3.x", "pg": "^8.8.0", "postgres": "^3.3.0" }, @@ -54,10 +52,10 @@ } }, "devDependencies": { - "@lucia-auth/adapter-test": "latest", + "@lucia-auth/adapter-test": "workspace:*", "@types/pg": "^8.6.5", "dotenv": "^16.0.3", - "lucia": "latest", + "lucia": "workspace:*", "tsx": "^3.12.6" }, "dependencies": { diff --git a/packages/adapter-postgresql/src/base.ts b/packages/adapter-postgresql/src/base.ts new file mode 100644 index 000000000..42d4b6ed5 --- /dev/null +++ b/packages/adapter-postgresql/src/base.ts @@ -0,0 +1,150 @@ +import type { + Adapter, + DatabaseSession, + RegisteredDatabaseSessionAttributes, + DatabaseUser, + RegisteredDatabaseUserAttributes +} from "lucia"; + +export class PostgreSQLAdapter implements Adapter { + private controller: Controller; + + private escapedUserTableName: string; + private escapedSessionTableName: string; + + constructor(controller: Controller, tableNames: TableNames) { + this.controller = controller; + this.escapedSessionTableName = escapeName(tableNames.session); + this.escapedUserTableName = escapeName(tableNames.user); + } + + public async deleteSession(sessionId: string): Promise { + await this.controller.execute(`DELETE FROM ${this.escapedSessionTableName} WHERE id = $1`, [ + sessionId + ]); + } + + public async deleteUserSessions(userId: string): Promise { + await this.controller.execute( + `DELETE FROM ${this.escapedSessionTableName} WHERE user_id = $1`, + [userId] + ); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const [databaseSession, databaseUser] = await Promise.all([ + this.getSession(sessionId), + this.getUserFromSessionId(sessionId) + ]); + return [databaseSession, databaseUser]; + } + + public async getUserSessions(userId: string): Promise { + const result = await this.controller.getAll( + `SELECT * FROM ${this.escapedSessionTableName} WHERE user_id = $1`, + [userId] + ); + return result.map((val) => { + return transformIntoDatabaseSession(val); + }); + } + + public async setSession(databaseSession: DatabaseSession): Promise { + const value: SessionSchema = { + id: databaseSession.id, + user_id: databaseSession.userId, + expires_at: databaseSession.expiresAt, + ...databaseSession.attributes + }; + const entries = Object.entries(value).filter(([_, v]) => v !== undefined); + const columns = entries.map(([k]) => escapeName(k)); + const placeholders = Array(columns.length) + .fill(null) + .map((_, i) => `$${i + 1}`); + const values = entries.map(([_, v]) => v); + await this.controller.execute( + `INSERT INTO ${this.escapedSessionTableName} (${columns.join( + ", " + )}) VALUES (${placeholders.join(", ")})`, + values + ); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.controller.execute( + `UPDATE ${this.escapedSessionTableName} SET expires_at = $1 WHERE id = $2`, + [expiresAt, sessionId] + ); + } + + public async deleteExpiredSessions(): Promise { + await this.controller.execute( + `DELETE FROM ${this.escapedSessionTableName} WHERE expires_at <= $1`, + [new Date()] + ); + } + + private async getSession(sessionId: string): Promise { + const result = await this.controller.get( + `SELECT * FROM ${this.escapedSessionTableName} WHERE id = $1`, + [sessionId] + ); + if (!result) return null; + return transformIntoDatabaseSession(result); + } + + private async getUserFromSessionId(sessionId: string): Promise { + const result = await this.controller.get( + `SELECT ${this.escapedUserTableName}.* FROM ${this.escapedSessionTableName} INNER JOIN ${this.escapedUserTableName} ON ${this.escapedUserTableName}.id = ${this.escapedSessionTableName}.user_id WHERE ${this.escapedSessionTableName}.id = $1`, + [sessionId] + ); + if (!result) return null; + return transformIntoDatabaseUser(result); + } +} + +export interface TableNames { + user: string; + session: string; +} + +export interface Controller { + execute(sql: string, args?: any[]): Promise; + get(sql: string, args?: any[]): Promise; + getAll(sql: string, args?: any[]): Promise; +} + +interface SessionSchema extends RegisteredDatabaseSessionAttributes { + id: string; + user_id: string; + expires_at: Date; +} + +interface UserSchema extends RegisteredDatabaseUserAttributes { + id: string; +} + +function transformIntoDatabaseSession(raw: SessionSchema): DatabaseSession { + const { id, user_id: userId, expires_at: expiresAt, ...attributes } = raw; + return { + userId, + id, + expiresAt, + attributes + }; +} + +function transformIntoDatabaseUser(raw: UserSchema): DatabaseUser { + const { id, ...attributes } = raw; + return { + id, + attributes + }; +} + +function escapeName(val: string): string { + if (val.includes(".")) return val; + return `"` + val + `"`; +} diff --git a/packages/adapter-postgresql/src/drivers/node-postgres.ts b/packages/adapter-postgresql/src/drivers/node-postgres.ts new file mode 100644 index 000000000..f1b8de626 --- /dev/null +++ b/packages/adapter-postgresql/src/drivers/node-postgres.ts @@ -0,0 +1,31 @@ +import { PostgreSQLAdapter } from "../base.js"; + +import type { Controller, TableNames } from "../base.js"; +import type { Pool, Client } from "pg"; + +export class NodePostgresAdapter extends PostgreSQLAdapter { + constructor(client: Client | Pool, tableNames: TableNames) { + super(new NodePostgresController(client), tableNames); + } +} + +class NodePostgresController implements Controller { + private client: Client | Pool; + constructor(client: Client | Pool) { + this.client = client; + } + + public async get(sql: string, args: any[]): Promise { + const { rows } = await this.client.query(sql, args); + return rows.at(0) ?? null; + } + + public async getAll(sql: string, args: any[]): Promise { + const { rows } = await this.client.query(sql, args); + return rows; + } + + public async execute(sql: string, args: any[]): Promise { + await this.client.query(sql, args); + } +} diff --git a/packages/adapter-postgresql/src/drivers/pg.ts b/packages/adapter-postgresql/src/drivers/pg.ts deleted file mode 100644 index 008600439..000000000 --- a/packages/adapter-postgresql/src/drivers/pg.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { - helper, - getSetArgs, - escapeName, - transformDatabaseSession -} from "../utils.js"; - -import type { DatabaseSession } from "../utils.js"; -import type { Adapter, InitializeAdapter, UserSchema, KeySchema } from "lucia"; -import type { - QueryResult, - DatabaseError, - Pool, - PoolClient, - QueryResultRow -} from "pg"; - -export const pgAdapter = ( - pool: Pool, - tables: { - user: string; - session: string | null; - key: string; - } -): InitializeAdapter => { - const transaction = async ( - execute: (connection: PoolClient) => Promise - ): Promise => { - const connection = await pool.connect(); - try { - await connection.query("BEGIN"); - await execute(connection); - await connection.query("COMMIT"); - } catch (e) { - connection.query("ROLLBACK"); - throw e; - } finally { - connection.release(); - } - }; - - const ESCAPED_USER_TABLE_NAME = escapeName(tables.user); - const ESCAPED_SESSION_TABLE_NAME = tables.session - ? escapeName(tables.session) - : null; - const ESCAPED_KEY_TABLE_NAME = escapeName(tables.key); - - return (LuciaError) => { - return { - getUser: async (userId) => { - const result = await get( - pool.query(`SELECT * FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = $1`, [ - userId - ]) - ); - return result; - }, - setUser: async (user, key) => { - if (!key) { - const [userFields, userValues, userArgs] = helper(user); - await pool.query( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - return; - } - try { - await transaction(async (tx) => { - const [userFields, userValues, userArgs] = helper(user); - await tx.query( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - const [keyFields, keyValues, keyArgs] = helper(key); - await tx.query( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${keyFields} ) VALUES ( ${keyValues} )`, - keyArgs - ); - }); - } catch (e) { - const error = e as Partial; - if (error.code === "23505" && error.detail?.includes("Key (id)")) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteUser: async (userId) => { - await pool.query( - `DELETE FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = $1`, - [userId] - ); - }, - updateUser: async (userId, partialUser) => { - const [fields, values, args] = helper(partialUser); - await pool.query( - `UPDATE ${ESCAPED_USER_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = $${fields.length + 1}`, - [...args, userId] - ); - }, - - getSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await get( - pool.query( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = $1`, - [sessionId] - ) - ); - return result ? transformDatabaseSession(result) : null; - }, - getSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await getAll( - pool.query( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = $1`, - [userId] - ) - ); - return result.map((val) => transformDatabaseSession(val)); - }, - setSession: async (session) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - try { - const [fields, values, args] = helper(session); - await pool.query( - `INSERT INTO ${ESCAPED_SESSION_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - } catch (e) { - const error = e as Partial; - if ( - error.code === "23503" && - error.detail?.includes("Key (user_id)") - ) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - throw e; - } - }, - deleteSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await pool.query( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = $1`, - [sessionId] - ); - }, - deleteSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await pool.query( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = $1`, - [userId] - ); - }, - updateSession: async (sessionId, partialSession) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(partialSession); - await pool.query( - `UPDATE ${ESCAPED_SESSION_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = $${fields.length + 1}`, - [...args, sessionId] - ); - }, - - getKey: async (keyId) => { - const result = await get( - pool.query( - `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = $1`, - [keyId] - ) - ); - return result; - }, - getKeysByUserId: async (userId) => { - const result = getAll( - pool.query( - `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = $1`, - [userId] - ) - ); - return result; - }, - setKey: async (key) => { - try { - const [fields, values, args] = helper(key); - await pool.query( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - } catch (e) { - const error = e as Partial; - if ( - error.code === "23503" && - error.detail?.includes("Key (user_id)") - ) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - if (error.code === "23505" && error.detail?.includes("Key (id)")) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteKey: async (keyId) => { - await pool.query( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = $1`, - [keyId] - ); - }, - deleteKeysByUserId: async (userId) => { - await pool.query( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = $1`, - [userId] - ); - }, - updateKey: async (keyId, partialKey) => { - const [fields, values, args] = helper(partialKey); - await pool.query( - `UPDATE ${ESCAPED_KEY_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = $${fields.length + 1}`, - [...args, keyId] - ); - }, - - getSessionAndUser: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const getSessionPromise = get( - pool.query( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = $1`, - [sessionId] - ) - ); - const getUserFromJoinPromise = get( - pool.query< - UserSchema & { - __session_id: string; - } - >( - `SELECT ${ESCAPED_USER_TABLE_NAME}.*, ${ESCAPED_SESSION_TABLE_NAME}.id as __session_id FROM ${ESCAPED_SESSION_TABLE_NAME} INNER JOIN ${ESCAPED_USER_TABLE_NAME} ON ${ESCAPED_USER_TABLE_NAME}.id = ${ESCAPED_SESSION_TABLE_NAME}.user_id WHERE ${ESCAPED_SESSION_TABLE_NAME}.id = $1`, - [sessionId] - ) - ); - const [sessionResult, userFromJoinResult] = await Promise.all([ - getSessionPromise, - getUserFromJoinPromise - ]); - if (!sessionResult || !userFromJoinResult) return [null, null]; - const { __session_id: _, ...userResult } = userFromJoinResult; - return [transformDatabaseSession(sessionResult), userResult]; - } - }; - }; -}; - -export const get = async <_Schema extends QueryResultRow>( - queryPromise: Promise> -): Promise<_Schema | null> => { - const { rows } = await queryPromise; - const result = rows.at(0) ?? null; - return result; -}; - -export const getAll = async <_Schema extends QueryResultRow>( - queryPromise: Promise> -): Promise<_Schema[]> => { - const { rows } = await queryPromise; - return rows; -}; diff --git a/packages/adapter-postgresql/src/drivers/postgres.ts b/packages/adapter-postgresql/src/drivers/postgres.ts deleted file mode 100644 index 966d003f6..000000000 --- a/packages/adapter-postgresql/src/drivers/postgres.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { - helper, - getSetArgs, - escapeName, - transformDatabaseSession -} from "../utils.js"; - -import type { DatabaseSession } from "../utils.js"; -import type { Adapter, InitializeAdapter, UserSchema, KeySchema } from "lucia"; -import type { Sql, PostgresError, PendingQuery } from "postgres"; - -export const postgresAdapter = ( - sql: Sql, - tables: { - user: string; - session: string | null; - key: string; - } -): InitializeAdapter => { - const ESCAPED_USER_TABLE_NAME = escapeName(tables.user); - const ESCAPED_SESSION_TABLE_NAME = tables.session - ? escapeName(tables.session) - : null; - const ESCAPED_KEY_TABLE_NAME = escapeName(tables.key); - - return (LuciaError) => { - return { - getUser: async (userId) => { - return await get( - sql.unsafe(`SELECT * FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = $1`, [ - userId - ]) - ); - }, - setUser: async (user, key) => { - if (!key) { - const [userFields, userValues, userArgs] = helper(user); - await sql.unsafe( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - return; - } - try { - await sql.begin(async (sql) => { - const [userFields, userValues, userArgs] = helper(user); - await sql.unsafe( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - userArgs - ); - const [keyFields, keyValues, keyArgs] = helper(key); - await sql.unsafe( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${keyFields} ) VALUES ( ${keyValues} )`, - keyArgs - ); - }); - } catch (e) { - const error = processException(e); - if (error.code === "23505" && error.detail?.includes("Key (id)")) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteUser: async (userId) => { - await sql.unsafe( - `DELETE FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = $1`, - [userId] - ); - }, - updateUser: async (userId, partialUser) => { - const [fields, values, args] = helper(partialUser); - await sql.unsafe( - `UPDATE ${ESCAPED_USER_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = $${fields.length + 1}`, - [...args, userId] - ); - }, - - getSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await get( - sql.unsafe( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = $1`, - [sessionId] - ) - ); - return result ? transformDatabaseSession(result) : null; - }, - getSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await getAll( - sql.unsafe( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = $1`, - [userId] - ) - ); - return result.map((val) => transformDatabaseSession(val)); - }, - setSession: async (session) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - try { - const [fields, values, args] = helper(session); - await sql.unsafe( - `INSERT INTO ${ESCAPED_SESSION_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - } catch (e) { - const error = processException(e); - if ( - error.code === "23503" && - error.detail?.includes("Key (user_id)") - ) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - throw e; - } - }, - deleteSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await sql.unsafe( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = $1`, - [sessionId] - ); - }, - deleteSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await sql.unsafe( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = $1`, - [userId] - ); - }, - updateSession: async (sessionId, partialSession) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(partialSession); - await sql.unsafe( - `UPDATE ${ESCAPED_SESSION_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = $${fields.length + 1}`, - [...args, sessionId] - ); - }, - - getKey: async (keyId) => { - const result = await get( - sql.unsafe(`SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = $1`, [ - keyId - ]) - ); - return result; - }, - getKeysByUserId: async (userId) => { - const result = getAll( - sql.unsafe( - `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = $1`, - [userId] - ) - ); - return result; - }, - setKey: async (key) => { - try { - const [fields, values, args] = helper(key); - await sql.unsafe( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - ); - } catch (e) { - const error = processException(e); - if ( - error.code === "23503" && - error.detail?.includes("Key (user_id)") - ) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - if (error.code === "23505" && error.detail?.includes("Key (id)")) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteKey: async (keyId) => { - await sql.unsafe( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = $1`, - [keyId] - ); - }, - deleteKeysByUserId: async (userId) => { - await sql.unsafe( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = $1`, - [userId] - ); - }, - updateKey: async (keyId, partialKey) => { - const [fields, values, args] = helper(partialKey); - await sql.unsafe( - `UPDATE ${ESCAPED_KEY_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = $${fields.length + 1}`, - [...args, keyId] - ); - }, - - getSessionAndUser: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const getSessionPromise = get( - sql.unsafe( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = $1`, - [sessionId] - ) - ); - const getUserFromJoinPromise = get< - UserSchema & { - __session_id: string; - } - >( - sql.unsafe( - `SELECT ${ESCAPED_USER_TABLE_NAME}.*, ${ESCAPED_SESSION_TABLE_NAME}.id as __session_id FROM ${ESCAPED_SESSION_TABLE_NAME} INNER JOIN ${ESCAPED_USER_TABLE_NAME} ON ${ESCAPED_USER_TABLE_NAME}.id = ${ESCAPED_SESSION_TABLE_NAME}.user_id WHERE ${ESCAPED_SESSION_TABLE_NAME}.id = $1`, - [sessionId] - ) - ); - const [sessionResult, userFromJoinResult] = await Promise.all([ - getSessionPromise, - getUserFromJoinPromise - ]); - if (!sessionResult || !userFromJoinResult) return [null, null]; - const { __session_id: _, ...userResult } = userFromJoinResult; - return [transformDatabaseSession(sessionResult), userResult]; - } - }; - }; -}; - -export async function get<_Schema extends {}>( - queryPromise: PendingQuery<_Schema[]> -) { - const result = await queryPromise; - return result.at(0) ?? null; -} - -export async function getAll<_Schema extends {}>( - queryPromise: PendingQuery<_Schema[]> -) { - return Array.from(await queryPromise); -} - -function processException(e: any) { - return e as Partial; -} diff --git a/packages/adapter-postgresql/src/drivers/postgresjs.ts b/packages/adapter-postgresql/src/drivers/postgresjs.ts new file mode 100644 index 000000000..53caf90e2 --- /dev/null +++ b/packages/adapter-postgresql/src/drivers/postgresjs.ts @@ -0,0 +1,31 @@ +import { PostgreSQLAdapter } from "../base.js"; + +import type { Controller, TableNames } from "../base.js"; +import type { Sql } from "postgres"; + +export class PostgresJsAdapter extends PostgreSQLAdapter { + constructor(sql: Sql, tableNames: TableNames) { + super(new PostgresJsController(sql), tableNames); + } +} + +class PostgresJsController implements Controller { + private sql: Sql; + constructor(sql: Sql) { + this.sql = sql; + } + + public async get(sql: string, args: any[]): Promise { + const result: T[] = await this.sql.unsafe(sql, args); + return result.at(0) ?? null; + } + + public async getAll(sql: string, args: any[]): Promise { + const result: T[] = await this.sql.unsafe(sql, args); + return result; + } + + public async execute(sql: string, args: any[]): Promise { + await this.sql.unsafe(sql, args); + } +} diff --git a/packages/adapter-postgresql/src/index.ts b/packages/adapter-postgresql/src/index.ts index 6cbd877ac..b25031db8 100644 --- a/packages/adapter-postgresql/src/index.ts +++ b/packages/adapter-postgresql/src/index.ts @@ -1,2 +1,2 @@ -export { pgAdapter as pg } from "./drivers/pg.js"; -export { postgresAdapter as postgres } from "./drivers/postgres.js"; +export { NodePostgresAdapter } from "./drivers/node-postgres.js"; +export { PostgresJsAdapter } from "./drivers/postgresjs.js"; diff --git a/packages/adapter-postgresql/src/lucia.d.ts b/packages/adapter-postgresql/src/lucia.d.ts deleted file mode 100644 index 97a71b7b4..000000000 --- a/packages/adapter-postgresql/src/lucia.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -declare namespace Lucia { - type Auth = any; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} diff --git a/packages/adapter-postgresql/src/utils.ts b/packages/adapter-postgresql/src/utils.ts deleted file mode 100644 index a35057ec8..000000000 --- a/packages/adapter-postgresql/src/utils.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { SessionSchema } from "lucia"; - -const createPreparedStatementHelper = ( - placeholder: (index: number) => string -) => { - const helper = ( - values: Record - ): readonly [fields: string[], placeholders: string[], arguments: any[]] => { - const keys = Object.keys(values); - return [ - keys.map((k) => escapeName(k)), - keys.map((_, i) => placeholder(i)), - keys.map((k) => values[k]) - ] as const; - }; - return helper; -}; - -const ESCAPE_CHAR = `"`; - -export const escapeName = (val: string) => { - if (val.includes(".")) return val; - return `${ESCAPE_CHAR}${val}${ESCAPE_CHAR}`; -}; - -export const helper = createPreparedStatementHelper((i) => `$${i + 1}`); - -export const getSetArgs = (fields: string[], placeholders: string[]) => { - return fields - .map((field, i) => [field, placeholders[i]].join(" = ")) - .join(","); -}; - -export type DatabaseSession = Omit< - SessionSchema, - "active_expires" | "idle_expires" -> & { - active_expires: BigInt; - idle_expires: BigInt; -}; - -export const transformDatabaseSession = ( - session: DatabaseSession -): SessionSchema => { - return { - ...session, - active_expires: Number(session.active_expires), - idle_expires: Number(session.idle_expires) - }; -}; diff --git a/packages/adapter-postgresql/test/pg/db.ts b/packages/adapter-postgresql/test/pg/db.ts deleted file mode 100644 index e68ff2f66..000000000 --- a/packages/adapter-postgresql/test/pg/db.ts +++ /dev/null @@ -1,11 +0,0 @@ -import dotenv from "dotenv"; -import { resolve } from "path"; -import pg from "pg"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -export const pool = new pg.Pool({ - connectionString: process.env.PSQL_DATABASE_URL -}); diff --git a/packages/adapter-postgresql/test/pg/index.ts b/packages/adapter-postgresql/test/pg/index.ts deleted file mode 100644 index 47844962c..000000000 --- a/packages/adapter-postgresql/test/pg/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; - -import { pool } from "./db.js"; -import { - escapeName, - helper, - transformDatabaseSession -} from "../../src/utils.js"; -import { getAll, pgAdapter } from "../../src/drivers/pg.js"; -import { ESCAPED_SESSION_TABLE_NAME, TABLE_NAMES } from "../shared.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; -import type { DatabaseSession } from "../../src/utils.js"; - -const createTableQueryHandler = (tableName: string): TableQueryHandler => { - const ESCAPED_TABLE_NAME = escapeName(tableName); - return { - get: async () => { - return await getAll(pool.query(`SELECT * FROM ${ESCAPED_TABLE_NAME}`)); - }, - insert: async (value: any) => { - const [fields, placeholders, args] = helper(value); - await pool.query( - `INSERT INTO ${ESCAPED_TABLE_NAME} ( ${fields} ) VALUES ( ${placeholders} )`, - args - ); - }, - clear: async () => { - await pool.query(`DELETE FROM ${ESCAPED_TABLE_NAME}`); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(TABLE_NAMES.user), - session: { - ...createTableQueryHandler(TABLE_NAMES.session), - get: async () => { - const result = await getAll( - pool.query( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME}` - ) - ); - return result.map((val) => transformDatabaseSession(val)); - } - }, - key: createTableQueryHandler(TABLE_NAMES.key) -}; - -const adapter = pgAdapter(pool, TABLE_NAMES)(LuciaError); - -await testAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-postgresql/test/pg/setup.ts b/packages/adapter-postgresql/test/pg/setup.ts deleted file mode 100644 index 923868311..000000000 --- a/packages/adapter-postgresql/test/pg/setup.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - ESCAPED_KEY_TABLE_NAME, - ESCAPED_SESSION_TABLE_NAME, - ESCAPED_USER_TABLE_NAME -} from "../shared.js"; -import { pool } from "./db.js"; - -await pool.query(` -CREATE TABLE ${ESCAPED_USER_TABLE_NAME} ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE -) -`); - -await pool.query(` -CREATE TABLE ${ESCAPED_SESSION_TABLE_NAME} ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES ${ESCAPED_USER_TABLE_NAME}(id), - active_expires BIGINT NOT NULL, - idle_expires BIGINT NOT NULL, - country TEXT NOT NULL -) -`); - -await pool.query(` -CREATE TABLE ${ESCAPED_KEY_TABLE_NAME} ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES ${ESCAPED_USER_TABLE_NAME}(id), - hashed_password VARCHAR(255) -) -`); - -process.exit(0); diff --git a/packages/adapter-postgresql/test/postgres/db.ts b/packages/adapter-postgresql/test/postgres/db.ts deleted file mode 100644 index 5aee91926..000000000 --- a/packages/adapter-postgresql/test/postgres/db.ts +++ /dev/null @@ -1,9 +0,0 @@ -import dotenv from "dotenv"; -import { resolve } from "path"; -import postgres from "postgres"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -export const sql = postgres(process.env.PSQL_DATABASE_URL ?? ""); diff --git a/packages/adapter-postgresql/test/postgres/index.ts b/packages/adapter-postgresql/test/postgres/index.ts deleted file mode 100644 index 1f0242016..000000000 --- a/packages/adapter-postgresql/test/postgres/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; - -import { sql } from "./db.js"; -import { - escapeName, - helper, - DatabaseSession, - transformDatabaseSession -} from "../../src/utils.js"; -import { postgresAdapter, getAll } from "../../src/drivers/postgres.js"; -import { ESCAPED_SESSION_TABLE_NAME, TABLE_NAMES } from "../shared.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; - -const createTableQueryHandler = (tableName: string): TableQueryHandler => { - const ESCAPED_TABLE_NAME = escapeName(tableName); - return { - get: async () => { - return await getAll(sql.unsafe(`SELECT * FROM ${ESCAPED_TABLE_NAME}`)); - }, - insert: async (value: any) => { - const [fields, placeholders, args] = helper(value); - await sql.unsafe( - `INSERT INTO ${ESCAPED_TABLE_NAME} ( ${fields} ) VALUES ( ${placeholders} )`, - args - ); - }, - clear: async () => { - await sql.unsafe(`DELETE FROM ${ESCAPED_TABLE_NAME}`); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(TABLE_NAMES.user), - session: { - ...createTableQueryHandler(TABLE_NAMES.session), - get: async () => { - const result = await getAll( - sql.unsafe(`SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME}`) - ); - return result.map((val) => transformDatabaseSession(val)); - } - }, - key: createTableQueryHandler(TABLE_NAMES.key) -}; - -const adapter = postgresAdapter(sql, TABLE_NAMES)(LuciaError); - -await testAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-postgresql/test/postgres/setup.ts b/packages/adapter-postgresql/test/postgres/setup.ts deleted file mode 100644 index 7720b04dc..000000000 --- a/packages/adapter-postgresql/test/postgres/setup.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - ESCAPED_KEY_TABLE_NAME, - ESCAPED_SESSION_TABLE_NAME, - ESCAPED_USER_TABLE_NAME -} from "../shared.js"; -import { sql } from "./db.js"; - -await sql.unsafe(` -CREATE TABLE ${ESCAPED_USER_TABLE_NAME} ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE -) -`); - -await sql.unsafe(` -CREATE TABLE ${ESCAPED_SESSION_TABLE_NAME} ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES ${ESCAPED_USER_TABLE_NAME}(id), - active_expires BIGINT NOT NULL, - idle_expires BIGINT NOT NULL, - country TEXT NOT NULL -) -`); - -await sql.unsafe(` -CREATE TABLE ${ESCAPED_KEY_TABLE_NAME} ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES ${ESCAPED_USER_TABLE_NAME}(id), - hashed_password VARCHAR(255) -) -`); - -process.exit(0); diff --git a/packages/adapter-postgresql/test/shared.ts b/packages/adapter-postgresql/test/shared.ts deleted file mode 100644 index a13fb1dd1..000000000 --- a/packages/adapter-postgresql/test/shared.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { escapeName } from "../src/utils.js"; - -export const TABLE_NAMES = { - user: "test_user", - session: "user_session", - key: "user_key" -}; - -export const ESCAPED_USER_TABLE_NAME = escapeName(TABLE_NAMES.user); -export const ESCAPED_SESSION_TABLE_NAME = escapeName(TABLE_NAMES.session); -export const ESCAPED_KEY_TABLE_NAME = escapeName(TABLE_NAMES.key); diff --git a/packages/adapter-postgresql/tests/node-postgres.ts b/packages/adapter-postgresql/tests/node-postgres.ts new file mode 100644 index 000000000..9e692c74f --- /dev/null +++ b/packages/adapter-postgresql/tests/node-postgres.ts @@ -0,0 +1,47 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { NodePostgresAdapter } from "../src/drivers/node-postgres.js"; +import dotenv from "dotenv"; +import { resolve } from "path"; +import pg from "pg"; + +dotenv.config({ + path: resolve(".env") +}); + +export const pool = new pg.Pool({ + connectionString: process.env.POSTGRES_DATABASE_URL +}); + +await pool.query("DROP TABLE IF EXISTS public.session"); +await pool.query("DROP TABLE IF EXISTS public.user"); + +await pool.query(` +CREATE TABLE public.user ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE +)`); + +await pool.query(` +CREATE TABLE public.session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES public.user(id), + expires_at TIMESTAMPTZ NOT NULL, + country TEXT NOT NULL +)`); + +await pool.query(`INSERT INTO public.user (id, username) VALUES ($1, $2)`, [ + databaseUser.id, + databaseUser.attributes.username +]); + +const adapter = new NodePostgresAdapter(pool, { + user: "public.user", + session: "public.session" +}); + +await testAdapter(adapter); + +await pool.query("DROP TABLE public.session"); +await pool.query("DROP TABLE public.user"); + +process.exit(); diff --git a/packages/adapter-postgresql/tests/postgresjs.ts b/packages/adapter-postgresql/tests/postgresjs.ts new file mode 100644 index 000000000..3fd435324 --- /dev/null +++ b/packages/adapter-postgresql/tests/postgresjs.ts @@ -0,0 +1,40 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { PostgresJsAdapter } from "../src/drivers/postgresjs.js"; +import dotenv from "dotenv"; +import { resolve } from "path"; +import postgres from "postgres"; + +dotenv.config({ + path: resolve(".env") +}); + +export const sql = postgres(process.env.POSTGRES_DATABASE_URL ?? ""); + +await sql`DROP TABLE IF EXISTS public.session`; +await sql`DROP TABLE IF EXISTS public.user`; + +await sql`CREATE TABLE public.user ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE +)`; + +await sql`CREATE TABLE public.session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES public.user(id), + expires_at TIMESTAMPTZ NOT NULL, + country TEXT NOT NULL +)`; + +await sql`INSERT INTO public.user (id, username) VALUES (${databaseUser.id}, ${databaseUser.attributes.username})`; + +const adapter = new PostgresJsAdapter(sql, { + user: "public.user", + session: "public.session" +}); + +await testAdapter(adapter); + +await sql`DROP TABLE public.session`; +await sql`DROP TABLE public.user`; + +process.exit(); diff --git a/packages/adapter-postgresql/tsconfig.json b/packages/adapter-postgresql/tsconfig.json index f2fb9daed..5cb3f5468 100644 --- a/packages/adapter-postgresql/tsconfig.json +++ b/packages/adapter-postgresql/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/adapter-prisma/CHANGELOG.md b/packages/adapter-prisma/CHANGELOG.md index 566586f77..a89bd27af 100644 --- a/packages/adapter-prisma/CHANGELOG.md +++ b/packages/adapter-prisma/CHANGELOG.md @@ -1,5 +1,9 @@ # @lucia-auth/adapter-prisma +## 4.0.0 + +See the [migration guide](https://v3.lucia-auth.com/upgrade-v3/prisma). + ## 3.0.2 ### Patch changes diff --git a/packages/adapter-prisma/README.md b/packages/adapter-prisma/README.md index 3900b390b..51a311c18 100644 --- a/packages/adapter-prisma/README.md +++ b/packages/adapter-prisma/README.md @@ -1,10 +1,10 @@ # `@lucia-auth/adapter-prisma` -[Prisma](https://www.prisma.io) adapter for Lucia v2. +[Prisma](https://www.prisma.io) adapter for Lucia. -**[Documentation](https://lucia-auth.com/reference#lucia-authadapter-prisma)** +**[Documentation](https://v3.lucia-auth.com/database/prisma)** -**[Lucia documentation](https://lucia-auth.com)** +**[Lucia documentation](https://v3.lucia-auth.com)** **[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-prisma/CHANGELOG.md)** @@ -18,7 +18,7 @@ yarn add @lucia-auth/adapter-prisma ## Testing -```bash +``` pnpm test-setup pnpm test ``` diff --git a/packages/adapter-prisma/package.json b/packages/adapter-prisma/package.json index 13e7fe7bb..5e6c53797 100644 --- a/packages/adapter-prisma/package.json +++ b/packages/adapter-prisma/package.json @@ -1,6 +1,6 @@ { "name": "@lucia-auth/adapter-prisma", - "version": "3.0.2", + "version": "4.0.0", "description": "Prisma adapter for Lucia", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -12,7 +12,7 @@ ], "scripts": { "build": "shx rm -rf ./dist/* && tsc", - "test": "tsx test/index.ts", + "test": "tsx tests/prisma.ts", "test-setup": "prisma db push", "auri.build": "pnpm build" }, @@ -41,11 +41,11 @@ }, "peerDependencies": { "@prisma/client": "^4.2.0 || ^5.0.0", - "lucia": "^2.0.0" + "lucia": "3.x" }, "devDependencies": { - "lucia": "latest", - "@lucia-auth/adapter-test": "latest", + "lucia": "workspace:*", + "@lucia-auth/adapter-test": "workspace:*", "@prisma/client": "^5.0.0", "prisma": "^4.9.0", "tsx": "^3.12.6" diff --git a/packages/adapter-prisma/prisma/migrations/20231105134245_init/migration.sql b/packages/adapter-prisma/prisma/migrations/20231105134245_init/migration.sql new file mode 100644 index 000000000..df1b33889 --- /dev/null +++ b/packages/adapter-prisma/prisma/migrations/20231105134245_init/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "expires" DATETIME NOT NULL, + "country" TEXT NOT NULL, + CONSTRAINT "Session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); + +-- CreateIndex +CREATE INDEX "Session_user_id_idx" ON "Session"("user_id"); diff --git a/packages/adapter-prisma/prisma/migrations/20231105134918_init/migration.sql b/packages/adapter-prisma/prisma/migrations/20231105134918_init/migration.sql new file mode 100644 index 000000000..46e973e16 --- /dev/null +++ b/packages/adapter-prisma/prisma/migrations/20231105134918_init/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost. + - You are about to drop the column `user_id` on the `Session` table. All the data in the column will be lost. + - Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `Session` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "country" TEXT NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Session" ("country", "id") SELECT "country", "id" FROM "Session"; +DROP TABLE "Session"; +ALTER TABLE "new_Session" RENAME TO "Session"; +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/packages/adapter-prisma/prisma/migrations/migration_lock.toml b/packages/adapter-prisma/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..e5e5c4705 --- /dev/null +++ b/packages/adapter-prisma/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/packages/adapter-prisma/prisma/schema.prisma b/packages/adapter-prisma/prisma/schema.prisma index 9064263e2..17f967b71 100644 --- a/packages/adapter-prisma/prisma/schema.prisma +++ b/packages/adapter-prisma/prisma/schema.prisma @@ -11,28 +11,17 @@ datasource db { } model User { - id String @id @unique + id String @id username String @unique auth_session Session[] - auth_key Key[] } model Session { - id String @id @unique - user_id String - active_expires BigInt - idle_expires BigInt - country String - user User @relation(references: [id], fields: [user_id], onDelete: Cascade) + id String @id + userId String + expiresAt DateTime + country String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) - @@index([user_id]) -} - -model Key { - id String @id @unique - hashed_password String? - user_id String - user User @relation(references: [id], fields: [user_id], onDelete: Cascade) - - @@index([user_id]) + @@index([userId]) } diff --git a/packages/adapter-prisma/src/index.ts b/packages/adapter-prisma/src/index.ts index 047e36aeb..2030527fa 100644 --- a/packages/adapter-prisma/src/index.ts +++ b/packages/adapter-prisma/src/index.ts @@ -1 +1,160 @@ -export { prismaAdapter as prisma } from "./prisma.js"; +import type { + Adapter, + DatabaseSession, + RegisteredDatabaseSessionAttributes, + DatabaseUser, + RegisteredDatabaseUserAttributes +} from "lucia"; + +export class PrismaAdapter<_PrismaClient extends PrismaClient> implements Adapter { + private sessionModel: PrismaModel; + private userModel: PrismaModel; + + constructor(sessionModel: BasicPrismaModel, userModel: BasicPrismaModel) { + this.sessionModel = sessionModel as any as PrismaModel; + this.userModel = userModel as any as PrismaModel; + } + + public async deleteSession(sessionId: string): Promise { + try { + await this.sessionModel.delete({ + where: { + id: sessionId + } + }); + } catch { + // ignore if session id is invalid + } + } + + public async deleteUserSessions(userId: string): Promise { + await this.sessionModel.deleteMany({ + where: { + userId + } + }); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const userModelKey = this.userModel.name[0].toLowerCase() + this.userModel.name.slice(1); + const result = await this.sessionModel.findUnique<{ + // this is a lie to make TS shut up + user: UserSchema; + }>({ + where: { + id: sessionId + }, + include: { + [userModelKey]: true + } + }); + if (!result) return [null, null]; + const userResult: UserSchema = result[userModelKey as "user"]; + delete result[userModelKey as keyof typeof result]; + return [transformIntoDatabaseSession(result), transformIntoDatabaseUser(userResult)]; + } + + public async getUserSessions(userId: string): Promise { + const result = await this.sessionModel.findMany({ + where: { + userId + } + }); + return result.map(transformIntoDatabaseSession); + } + + public async setSession(value: DatabaseSession): Promise { + await this.sessionModel.create({ + data: { + id: value.id, + userId: value.userId, + expiresAt: value.expiresAt, + ...value.attributes + } + }); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.sessionModel.update({ + where: { + id: sessionId + }, + data: { + expiresAt + } + }); + } + + public async deleteExpiredSessions(): Promise { + await this.sessionModel.deleteMany({ + where: { + expiresAt: { + lte: new Date() + } + } + }); + } +} + +function transformIntoDatabaseSession(raw: SessionSchema): DatabaseSession { + const { id, userId, expiresAt, ...attributes } = raw; + return { + id, + userId, + expiresAt, + attributes + }; +} + +function transformIntoDatabaseUser(raw: UserSchema): DatabaseUser { + const { id, ...attributes } = raw; + return { + id, + attributes + }; +} + +interface PrismaClient { + [K: string]: any; + $connect: any; + $transaction: any; +} + +interface UserSchema extends RegisteredDatabaseUserAttributes { + id: string; +} + +interface SessionSchema extends RegisteredDatabaseSessionAttributes { + id: string; + userId: string; + expiresAt: Date; +} + +interface BasicPrismaModel { + fields: any; + findUnique: any; + findMany: any; +} + +type PrismaWhere<_Schema extends {}> = { + [K in keyof _Schema]?: + | _Schema[K] + | { + lte?: _Schema[K]; + }; +}; + +interface PrismaModel<_Schema extends {}> { + name: string; + findUnique: <_Included = {}>(options: { + where: PrismaWhere<_Schema>; + include?: Record; + }) => Promise; + findMany: (options?: { where: PrismaWhere<_Schema> }) => Promise<_Schema[]>; + create: (options: { data: _Schema }) => Promise<_Schema>; + delete: (options: { where: PrismaWhere<_Schema> }) => Promise; + deleteMany: (options?: { where: PrismaWhere<_Schema> }) => Promise; + update: (options: { data: Partial<_Schema>; where: PrismaWhere<_Schema> }) => Promise<_Schema>; +} diff --git a/packages/adapter-prisma/src/lucia.d.ts b/packages/adapter-prisma/src/lucia.d.ts deleted file mode 100644 index 8026ca988..000000000 --- a/packages/adapter-prisma/src/lucia.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -declare namespace Lucia { - type Auth = any; - type DatabaseUserAttributes = any; - type DatabaseSessionAttributes = any; -} diff --git a/packages/adapter-prisma/src/prisma.ts b/packages/adapter-prisma/src/prisma.ts deleted file mode 100644 index 996b60b26..000000000 --- a/packages/adapter-prisma/src/prisma.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { - Adapter, - InitializeAdapter, - KeySchema, - SessionSchema, - UserSchema -} from "lucia"; - -type PossiblePrismaError = { - code: string; - message: string; -}; - -type ExtractModelNames<_PrismaClient extends PrismaClient> = Exclude< - keyof _PrismaClient, - `$${string}` ->; - -export const prismaAdapter = <_PrismaClient extends PrismaClient>( - client: _PrismaClient, - modelNames?: { - user: ExtractModelNames<_PrismaClient>; - session: ExtractModelNames<_PrismaClient> | null; - key: ExtractModelNames<_PrismaClient>; - } -): InitializeAdapter => { - const getModels = () => { - if (!modelNames) { - return { - User: client["user"] as SmartPrismaModel, - Session: (client["session"] as SmartPrismaModel) ?? null, - Key: client["key"] as SmartPrismaModel - }; - } - return { - User: client[modelNames.user] as SmartPrismaModel, - Session: modelNames.session - ? (client[modelNames.session] as SmartPrismaModel) - : null, - Key: client[modelNames.key] as SmartPrismaModel - }; - }; - const { User, Session, Key } = getModels(); - - return (LuciaError) => { - return { - getUser: async (userId) => { - return await User.findUnique({ - where: { - id: userId - } - }); - }, - setUser: async (user, key) => { - if (!key) { - await User.create({ - data: user - }); - return; - } - try { - await client.$transaction([ - User.create({ - data: user - }), - Key.create({ - data: key - }) - ]); - } catch (e) { - const error = e as Partial; - if (error.code === "P2002" && error.message?.includes("`id`")) - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - throw error; - } - }, - deleteUser: async (userId) => { - try { - await User.delete({ - where: { - id: userId - } - }); - } catch (e) { - const error = e as Partial; - if (error.code === "P2025") { - // user does not exist - return; - } - throw e; - } - }, - updateUser: async (userId, partialUser) => { - await User.update({ - data: partialUser, - where: { - id: userId - } - }); - }, - getSession: async (sessionId) => { - if (!Session) { - throw new Error("Session table not defined"); - } - const result = await Session.findUnique({ - where: { - id: sessionId - } - }); - if (!result) return null; - return transformPrismaSession(result); - }, - getSessionsByUserId: async (userId) => { - if (!Session) { - throw new Error("Session table not defined"); - } - const sessions = await Session.findMany({ - where: { - user_id: userId - } - }); - return sessions.map((session) => transformPrismaSession(session)); - }, - setSession: async (session) => { - if (!Session) { - throw new Error("Session table not defined"); - } - try { - await Session.create({ - data: session - }); - } catch (e) { - const error = e as Partial; - if (error.code === "P2003") { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - - throw error; - } - }, - deleteSession: async (sessionId) => { - if (!Session) { - throw new Error("Session table not defined"); - } - try { - await Session.delete({ - where: { - id: sessionId - } - }); - } catch (e) { - const error = e as Partial; - if (error.code === "P2025") { - // session does not exist - return; - } - throw e; - } - }, - deleteSessionsByUserId: async (userId) => { - if (!Session) { - throw new Error("Session table not defined"); - } - await Session.deleteMany({ - where: { - user_id: userId - } - }); - }, - updateSession: async (userId, partialSession) => { - if (!Session) { - throw new Error("Session table not defined"); - } - await Session.update({ - data: partialSession, - where: { - id: userId - } - }); - }, - - getKey: async (keyId) => { - return await Key.findUnique({ - where: { - id: keyId - } - }); - }, - getKeysByUserId: async (userId) => { - return await Key.findMany({ - where: { - user_id: userId - } - }); - }, - - setKey: async (key) => { - try { - await Key.create({ - data: key - }); - } catch (e) { - const error = e as Partial; - if (error.code === "P2003") { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - if (error.code === "P2002" && error.message?.includes("`id`")) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw error; - } - }, - deleteKey: async (keyId) => { - try { - await Key.delete({ - where: { - id: keyId - } - }); - } catch (e) { - const error = e as Partial; - if (error.code === "P2025") { - // key does not exist - return; - } - throw e; - } - }, - deleteKeysByUserId: async (userId) => { - await Key.deleteMany({ - where: { - user_id: userId - } - }); - }, - updateKey: async (keyId, partialKey) => { - await Key.update({ - data: partialKey, - where: { - id: keyId - } - }); - } - }; - }; -}; - -export const transformPrismaSession = ( - sessionData: PrismaSession -): SessionSchema => { - const { active_expires, idle_expires: idleExpires, ...data } = sessionData; - return { - ...data, - active_expires: Number(active_expires), - idle_expires: Number(idleExpires) - }; -}; - -type PrismaClient = { - $transaction: (...args: any) => any; -} & { [K: string]: any }; - -export type PrismaSession = Omit< - SessionSchema, - "active_expires" | "idle_expires" -> & { - active_expires: BigInt | number; - idle_expires: BigInt | number; -}; - -export type SmartPrismaModel<_Schema = any> = { - findUnique: <_Included = {}>(options: { - where: Partial<_Schema>; - include?: Partial>; - }) => Promise & _Included; - findMany: (options?: { where: Partial<_Schema> }) => Promise<_Schema[]>; - create: (options: { data: _Schema }) => Promise<_Schema>; - delete: (options: { where: Partial<_Schema> }) => Promise; - deleteMany: (options?: { where: Partial<_Schema> }) => Promise; - update: (options: { - data: Partial<_Schema>; - where: Partial<_Schema>; - }) => Promise<_Schema>; -}; diff --git a/packages/adapter-prisma/test/index.ts b/packages/adapter-prisma/test/index.ts deleted file mode 100644 index a7e7b8bb5..000000000 --- a/packages/adapter-prisma/test/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; -import { PrismaClient } from "@prisma/client"; - -import { prismaAdapter, transformPrismaSession } from "../src/prisma.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; -import type { SmartPrismaModel } from "../src/prisma.js"; - -const client = new PrismaClient(); - -const createTableQueryHandler = (model: any): TableQueryHandler => { - const Model = model as SmartPrismaModel; - return { - get: async () => { - return await Model.findMany(); - }, - insert: async (value: any) => { - await Model.create({ - data: value - }); - }, - clear: async () => { - await Model.deleteMany(); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(client.user), - session: { - ...createTableQueryHandler(client.session), - get: async () => { - const Session = client.session as any as SmartPrismaModel; - const result = await Session.findMany(); - return result.map((val) => transformPrismaSession(val)); - } - }, - key: createTableQueryHandler(client.key) -}; - -const adapter = prismaAdapter(client)(LuciaError); - -await testAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-prisma/tests/prisma.ts b/packages/adapter-prisma/tests/prisma.ts new file mode 100644 index 000000000..948977a7a --- /dev/null +++ b/packages/adapter-prisma/tests/prisma.ts @@ -0,0 +1,22 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { PrismaClient } from "@prisma/client"; + +import { PrismaAdapter } from "../src/index.js"; + +const client = new PrismaClient(); + +const adapter = new PrismaAdapter(client.session, client.user); + +await client.user.create({ + data: { + id: databaseUser.id, + ...databaseUser.attributes + } +}); + +await testAdapter(adapter); + +await client.session.deleteMany(); +await client.user.deleteMany(); + +process.exit(0); diff --git a/packages/adapter-prisma/tsconfig.json b/packages/adapter-prisma/tsconfig.json index f2fb9daed..5cb3f5468 100644 --- a/packages/adapter-prisma/tsconfig.json +++ b/packages/adapter-prisma/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/adapter-session-redis/.env.example b/packages/adapter-session-redis/.env.example deleted file mode 100644 index 5894a4896..000000000 --- a/packages/adapter-session-redis/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# for Redis adapter -REDIS_PORT="your_redis_port" - -# for Upstash adapter -URL="your_upstash_url" -TOKEN="your_upstash_token" \ No newline at end of file diff --git a/packages/adapter-session-redis/.gitignore b/packages/adapter-session-redis/.gitignore deleted file mode 100644 index 7a0ae98eb..000000000 --- a/packages/adapter-session-redis/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -/node_modules -/dist -.DS_Store -.env -*.tgz \ No newline at end of file diff --git a/packages/adapter-session-redis/CHANGELOG.md b/packages/adapter-session-redis/CHANGELOG.md deleted file mode 100644 index 1683f0c7b..000000000 --- a/packages/adapter-session-redis/CHANGELOG.md +++ /dev/null @@ -1,123 +0,0 @@ -# @lucia-auth/adapter-session-redis - -## 2.1.2 - -### Patch changes - -- [#1314](https://github.com/lucia-auth/lucia/pull/1314) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix expiration - -## 2.1.1 - -### Patch changes - -- [#1069](https://github.com/pilcrowOnPaper/lucia/pull/1069) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix Upstash adapter `getSessionsByUserId()` - -## 2.1.0 - -### Minor changes - -- [#950](https://github.com/pilcrowOnPaper/lucia/pull/950) by [@klapacz](https://github.com/klapacz) : Add adapter for `ioredis` - -## 2.0.0 - -### Major changes - -- [#885](https://github.com/pilcrowOnPaper/lucia/pull/885) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update version and peer dependency - -## 2.0.0-beta.8 - -### Minor changes - -- [#867](https://github.com/pilcrowOnPaper/lucia/pull/867) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.7 - -### Minor changes - -- [#842](https://github.com/pilcrowOnPaper/lucia/pull/842) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.6 - -### Minor changes - -- [#812](https://github.com/pilcrowOnPaper/lucia/pull/812) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.5 - -### Minor changes - -- [#699](https://github.com/pilcrowOnPaper/lucia/pull/699) by [@schweden1997](https://github.com/schweden1997) : Add Redis adapter for Upstash - -## 2.0.0-beta.4 - -### Patch changes - -- [#803](https://github.com/pilcrowOnPaper/lucia/pull/803) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.3 - -### Major changes - -- [#788](https://github.com/pilcrowOnPaper/lucia/pull/790) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Require `lucia@2.0.0-beta.3` - -## 2.0.0-beta.2 - -### Patch changes - -- [#768](https://github.com/pilcrowOnPaper/lucia/pull/768) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.1 - -### Patch changes - -- [#756](https://github.com/pilcrowOnPaper/lucia/pull/756) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix peer dependency version - -## 2.0.0-beta.0 - -### Major changes - -- [#682](https://github.com/pilcrowOnPaper/lucia/pull/682) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Require `lucia@^2.0.0` - - - `redis()` expects az single Redis instance instead of 2 - -## 1.0.0 - -### Major changes - -- [#443](https://github.com/pilcrowOnPaper/lucia/pull/443) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Release version 1.0! - -## 0.1.7 - -### Patch changes - -- [#430](https://github.com/pilcrowOnPaper/lucia/pull/430) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update dependency - -## 0.1.6 - -### Patch changes - -- [#424](https://github.com/pilcrowOnPaper/lucia/pull/424) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : - Update dependencies - -## 0.1.5 - -### Patch changes - -- [#398](https://github.com/pilcrowOnPaper/lucia/pull/398) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 0.1.4 - -### Patch changes - -- [#392](https://github.com/pilcrowOnPaper/lucia/pull/392) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 0.1.3 - -### Patch changes - -- [#388](https://github.com/pilcrowOnPaper/lucia/pull/388) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : remove unnecessary code - -## 0.1.2 - -### Patch changes - -- [#381](https://github.com/pilcrowOnPaper/lucia/pull/381) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update links in README and package.json diff --git a/packages/adapter-session-redis/README.md b/packages/adapter-session-redis/README.md deleted file mode 100644 index d798d0533..000000000 --- a/packages/adapter-session-redis/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# `@lucia-auth/adapter-session-redis` - -Redis session adapters for Lucia v2. - -**[Lucia documentation](https://lucia-auth.com)** - -**[Documentation](https://lucia-auth.com/reference#lucia-authadapter-session-redis)** - -## Included adapters - -- [Redis](https://redis.io) ([Documentation](https://v2.lucia-auth.com/database-adapters/redis)) -- [Upstash](https://upstash.com) ([Documentation](https://v2.lucia-auth.com/database-adapters/upstash-redis)) - -## Installation - -``` -npm install @lucia-auth/adapter-session-redis -``` - -## Testing - -### Redis - -Add the port of a local Redis DB to `.env`. - -``` -pnpm test.redis -``` - -### Upstash - -Add the `UPSTASH_REDIS_REST_URL` and the `UPSTASH_REDIS_REST_TOKEN` to `.env`. - -``` -pnpm test.upstash -``` diff --git a/packages/adapter-session-redis/package.json b/packages/adapter-session-redis/package.json deleted file mode 100644 index 632654c20..000000000 --- a/packages/adapter-session-redis/package.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "name": "@lucia-auth/adapter-session-redis", - "version": "2.1.2", - "description": "Redis session adapter for Lucia", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "module": "dist/index.js", - "type": "module", - "files": [ - "/dist/", - "CHANGELOG.md", - "README.md" - ], - "scripts": { - "build": "shx rm -rf ./dist/* && tsc", - "test": "pnpm test.redis && pnpm test.upstash && pnpm test.ioredis", - "test.redis": "tsx test/redis.ts", - "test.upstash": "tsx test/upstash.ts", - "test.ioredis": "tsx test/ioredis.ts", - "auri.build": "pnpm build" - }, - "keywords": [ - "lucia", - "lucia", - "auth", - "authentication", - "adapter", - "redis", - "session", - "upstash" - ], - "repository": { - "type": "git", - "url": "https://github.com/pilcrowOnPaper/lucia", - "directory": "packages/adapter-session-redis" - }, - "author": "pilcrowonpaper", - "license": "MIT", - "exports": { - ".": "./dist/index.js" - }, - "peerDependencies": { - "@upstash/redis": "^1.20.0", - "ioredis": "^5.0.0", - "lucia": "^2.0.0", - "redis": "^4.0.0" - }, - "peerDependenciesMeta": { - "redis": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "ioredis": { - "optional": true - } - }, - "devDependencies": { - "@lucia-auth/adapter-test": "workspace:*", - "@upstash/redis": "^1.21.0", - "dotenv": "^16.0.3", - "ioredis": "^5.3.2", - "lucia": "workspace:*", - "redis": "^4.3.1", - "tsx": "^3.12.6" - } -} diff --git a/packages/adapter-session-redis/src/drivers/ioredis.ts b/packages/adapter-session-redis/src/drivers/ioredis.ts deleted file mode 100644 index f844f78aa..000000000 --- a/packages/adapter-session-redis/src/drivers/ioredis.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { InitializeAdapter, SessionAdapter, SessionSchema } from "lucia"; -import type { Redis } from "ioredis"; - -export const DEFAULT_SESSION_PREFIX = "session"; -export const DEFAULT_USER_SESSIONS_PREFIX = "user_sessions"; - -export const ioredisSessionAdapter = ( - client: Redis, - prefixes?: { - session: string; - userSessions: string; - } -): InitializeAdapter => { - return () => { - const sessionKey = (sessionId: string) => { - return [prefixes?.session ?? DEFAULT_SESSION_PREFIX, sessionId].join(":"); - }; - const userSessionsKey = (userId: string) => { - return [ - prefixes?.userSessions ?? DEFAULT_USER_SESSIONS_PREFIX, - userId - ].join(":"); - }; - - return { - getSession: async (sessionId) => { - const sessionData = await client.get(sessionKey(sessionId)); - if (!sessionData) return null; - const session = JSON.parse(sessionData) as SessionSchema; - return session; - }, - getSessionsByUserId: async (userId) => { - const sessionIds = await client.smembers(userSessionsKey(userId)); - const sessionData = await Promise.all( - sessionIds.map((sessionId) => client.get(sessionKey(sessionId))) - ); - const sessions = sessionData - .filter((val): val is NonNullable => val !== null) - .map((val) => JSON.parse(val) as SessionSchema); - return sessions; - }, - setSession: async (session) => { - await Promise.all([ - client.sadd(userSessionsKey(session.user_id), session.id), - client.set( - sessionKey(session.id), - JSON.stringify(session), - "EXAT", - Math.floor(Number(session.idle_expires) / 1000) - ) - ]); - }, - deleteSession: async (sessionId) => { - const sessionData = await client.get(sessionKey(sessionId)); - if (!sessionData) return; - const session = JSON.parse(sessionData) as SessionSchema; - await Promise.all([ - client.del(sessionKey(sessionId)), - client.srem(userSessionsKey(session.user_id), sessionId) - ]); - }, - deleteSessionsByUserId: async (userId) => { - const sessionIds = await client.smembers(userSessionsKey(userId)); - await Promise.all([ - ...sessionIds.map((sessionId) => client.del(sessionKey(sessionId))), - client.del(userSessionsKey(userId)) - ]); - }, - updateSession: async (sessionId, partialSession) => { - const sessionData = await client.get(sessionKey(sessionId)); - if (!sessionData) return; - const session = JSON.parse(sessionData) as SessionSchema; - const updatedSession = { - ...session, - ...partialSession - }; - await client.set( - sessionKey(sessionId), - JSON.stringify(updatedSession), - "EXAT", - Math.floor(Number(updatedSession.idle_expires) / 1000) - ); - } - }; - }; -}; diff --git a/packages/adapter-session-redis/src/drivers/redis.ts b/packages/adapter-session-redis/src/drivers/redis.ts deleted file mode 100644 index e6dc65719..000000000 --- a/packages/adapter-session-redis/src/drivers/redis.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { InitializeAdapter, SessionAdapter, SessionSchema } from "lucia"; -import type { RedisClientType } from "redis"; - -export const DEFAULT_SESSION_PREFIX = "session"; -export const DEFAULT_USER_SESSIONS_PREFIX = "user_sessions"; - -export const redisSessionAdapter = ( - client: RedisClientType, - prefixes?: { - session: string; - userSessions: string; - } -): InitializeAdapter => { - return () => { - const sessionKey = (sessionId: string) => { - return [prefixes?.session ?? DEFAULT_SESSION_PREFIX, sessionId].join(":"); - }; - const userSessionsKey = (userId: string) => { - return [ - prefixes?.userSessions ?? DEFAULT_USER_SESSIONS_PREFIX, - userId - ].join(":"); - }; - - return { - getSession: async (sessionId) => { - const sessionData = await client.get(sessionKey(sessionId)); - if (!sessionData) return null; - const session = JSON.parse(sessionData) as SessionSchema; - return session; - }, - getSessionsByUserId: async (userId) => { - const sessionIds = await client.sMembers(userSessionsKey(userId)); - const sessionData = await Promise.all( - sessionIds.map((sessionId) => client.get(sessionKey(sessionId))) - ); - const sessions = sessionData - .filter((val): val is NonNullable => val !== null) - .map((val) => JSON.parse(val) as SessionSchema); - return sessions; - }, - setSession: async (session) => { - await Promise.all([ - client.sAdd(userSessionsKey(session.user_id), session.id), - client.set(sessionKey(session.id), JSON.stringify(session), { - EXAT: Math.floor(Number(session.idle_expires) / 1000) - }) - ]); - }, - deleteSession: async (sessionId) => { - const sessionData = await client.get(sessionKey(sessionId)); - if (!sessionData) return; - const session = JSON.parse(sessionData) as SessionSchema; - await Promise.all([ - client.del(sessionKey(sessionId)), - client.sRem(userSessionsKey(session.user_id), sessionId) - ]); - }, - deleteSessionsByUserId: async (userId) => { - const sessionIds = await client.sMembers(userSessionsKey(userId)); - await Promise.all([ - ...sessionIds.map((sessionId) => client.del(sessionKey(sessionId))), - client.del(userSessionsKey(userId)) - ]); - }, - updateSession: async (sessionId, partialSession) => { - const sessionData = await client.get(sessionKey(sessionId)); - if (!sessionData) return; - const session = JSON.parse(sessionData) as SessionSchema; - const updatedSession = { - ...session, - ...partialSession - }; - await client.set( - sessionKey(sessionId), - JSON.stringify(updatedSession), - { - EXAT: Math.floor(Number(updatedSession.idle_expires) / 1000) - } - ); - } - }; - }; -}; diff --git a/packages/adapter-session-redis/src/drivers/upstash.ts b/packages/adapter-session-redis/src/drivers/upstash.ts deleted file mode 100644 index f5d67d9a0..000000000 --- a/packages/adapter-session-redis/src/drivers/upstash.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Redis } from "@upstash/redis"; -import type { InitializeAdapter, SessionAdapter, SessionSchema } from "lucia"; - -export const DEFAULT_SESSION_PREFIX = "session"; -export const DEFAULT_USER_SESSIONS_PREFIX = "user_sessions"; - -export const upstashSessionAdapter = ( - upstashClient: Redis, - prefixes?: { - session: string; - userSessions: string; - } -): InitializeAdapter => { - return () => { - const sessionKey = (sessionId: string) => { - return [prefixes?.session ?? DEFAULT_SESSION_PREFIX, sessionId].join(":"); - }; - const userSessionsKey = (userId: string) => { - return [ - prefixes?.userSessions ?? DEFAULT_USER_SESSIONS_PREFIX, - userId - ].join(":"); - }; - - return { - getSession: async (sessionId) => { - const sessionData = await upstashClient.get(sessionKey(sessionId)); - if (!sessionData) return null; - return sessionData as SessionSchema; - }, - getSessionsByUserId: async (userId) => { - const sessionIds = await upstashClient.smembers( - userSessionsKey(userId) - ); - if (sessionIds.length === 0) return []; - const pipeline = upstashClient.pipeline(); - for (const sessionId of sessionIds) { - pipeline.get(sessionKey(sessionId)); - } - const maybeSessions = await pipeline.exec< - Array - >(); - return maybeSessions.filter( - (maybeSession): maybeSession is NonNullable => { - return maybeSession !== null; - } - ); - }, - setSession: async (session) => { - const pipeline = upstashClient.pipeline(); - pipeline.sadd(userSessionsKey(session.user_id), session.id); - pipeline.set(sessionKey(session.id), JSON.stringify(session), { - exat: Math.floor(Number(session.idle_expires) / 1000) - }); - await pipeline.exec(); - }, - deleteSession: async (sessionId) => { - const session = await upstashClient.get( - sessionKey(sessionId) - ); - if (!session) return; - const pipeline = upstashClient.pipeline(); - pipeline.del(sessionKey(sessionId)); - pipeline.srem(userSessionsKey(session.user_id), sessionId); - await pipeline.exec(); - }, - deleteSessionsByUserId: async (userId) => { - const sessionIds = await upstashClient.smembers( - userSessionsKey(userId) - ); - const pipeline = upstashClient.pipeline(); - for (const sessionId of sessionIds) { - pipeline.del(sessionKey(sessionId)); - } - pipeline.del(userSessionsKey(userId)); - await pipeline.exec(); - }, - updateSession: async (sessionId, partialSession) => { - const session = await upstashClient.get( - sessionKey(sessionId) - ); - if (!session) return; - const updatedSession = { ...session, ...partialSession }; - await upstashClient.set( - sessionKey(sessionId), - JSON.stringify(updatedSession), - { - exat: Math.floor(Number(updatedSession.idle_expires) / 1000) - } - ); - } - }; - }; -}; diff --git a/packages/adapter-session-redis/src/index.ts b/packages/adapter-session-redis/src/index.ts deleted file mode 100644 index 3d9e8bd4c..000000000 --- a/packages/adapter-session-redis/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { redisSessionAdapter as redis } from "./drivers/redis.js"; -export { upstashSessionAdapter as upstash } from "./drivers/upstash.js"; -export { ioredisSessionAdapter as ioredis } from "./drivers/ioredis.js"; diff --git a/packages/adapter-session-redis/src/lucia.d.ts b/packages/adapter-session-redis/src/lucia.d.ts deleted file mode 100644 index c3bd83307..000000000 --- a/packages/adapter-session-redis/src/lucia.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -declare namespace Lucia { - type Auth = import("lucia").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} diff --git a/packages/adapter-session-redis/test/ioredis.ts b/packages/adapter-session-redis/test/ioredis.ts deleted file mode 100644 index 1cc241460..000000000 --- a/packages/adapter-session-redis/test/ioredis.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Database, testSessionAdapter } from "@lucia-auth/adapter-test"; -import dotenv from "dotenv"; -import { LuciaError } from "lucia"; -import { resolve } from "path"; -import { Redis } from "ioredis"; - -import { - DEFAULT_SESSION_PREFIX, - DEFAULT_USER_SESSIONS_PREFIX, - ioredisSessionAdapter -} from "../src/drivers/ioredis.js"; - -import type { QueryHandler } from "@lucia-auth/adapter-test"; -import type { SessionSchema } from "lucia"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -const ioredisClient = new Redis(Number(process.env.REDIS_PORT)); - -const sessionKey = (sessionId: string) => { - return [DEFAULT_SESSION_PREFIX, sessionId].join(":"); -}; -const userSessionsKey = (userId: string) => { - return [DEFAULT_USER_SESSIONS_PREFIX, userId].join(":"); -}; - -const adapter = ioredisSessionAdapter(ioredisClient)(LuciaError); - -const queryHandler: QueryHandler = { - session: { - get: async () => { - const keys = await ioredisClient.keys(sessionKey("*")); - const sessionData = await Promise.all( - keys.map((key) => ioredisClient.get(key)) - ); - const sessions = sessionData - .filter((val): val is string => val !== null) - .map((data) => JSON.parse(data) as SessionSchema); - return sessions; - }, - insert: async (session) => { - await Promise.all([ - ioredisClient.set(sessionKey(session.id), JSON.stringify(session)), - ioredisClient.sadd(userSessionsKey(session.user_id), session.id) - ]); - }, - clear: async () => { - await ioredisClient.flushall(); - } - } -}; - -await testSessionAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-session-redis/test/redis.ts b/packages/adapter-session-redis/test/redis.ts deleted file mode 100644 index af405522f..000000000 --- a/packages/adapter-session-redis/test/redis.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Database, testSessionAdapter } from "@lucia-auth/adapter-test"; -import dotenv from "dotenv"; -import { LuciaError } from "lucia"; -import { resolve } from "path"; -import { createClient } from "redis"; - -import { - DEFAULT_SESSION_PREFIX, - DEFAULT_USER_SESSIONS_PREFIX, - redisSessionAdapter -} from "../src/drivers/redis.js"; - -import type { QueryHandler } from "@lucia-auth/adapter-test"; -import type { SessionSchema } from "lucia"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -const redisClient = createClient({ - socket: { - port: Number(process.env.REDIS_PORT) - } -}); - -redisClient.on("error", (err) => console.log("Redis Client Error", err)); - -const sessionKey = (sessionId: string) => { - return [DEFAULT_SESSION_PREFIX, sessionId].join(":"); -}; -const userSessionsKey = (userId: string) => { - return [DEFAULT_USER_SESSIONS_PREFIX, userId].join(":"); -}; - -const adapter = redisSessionAdapter(redisClient)(LuciaError); - -const queryHandler: QueryHandler = { - session: { - get: async () => { - const keys = await redisClient.keys(sessionKey("*")); - const sessionData = await Promise.all( - keys.map((key) => redisClient.get(key)) - ); - const sessions = sessionData - .filter((val): val is string => val !== null) - .map((data) => JSON.parse(data) as SessionSchema); - return sessions; - }, - insert: async (session) => { - await Promise.all([ - redisClient.set(sessionKey(session.id), JSON.stringify(session)), - redisClient.sAdd(userSessionsKey(session.user_id), session.id) - ]); - }, - clear: async () => { - await redisClient.flushAll(); - } - } -}; - -await redisClient.connect(); - -await testSessionAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-session-redis/test/upstash.ts b/packages/adapter-session-redis/test/upstash.ts deleted file mode 100644 index d3dabb6ae..000000000 --- a/packages/adapter-session-redis/test/upstash.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Database, testSessionAdapter } from "@lucia-auth/adapter-test"; -import { Redis } from "@upstash/redis"; -import dotenv from "dotenv"; -import { LuciaError } from "lucia"; -import { resolve } from "path"; - -import { - DEFAULT_SESSION_PREFIX, - DEFAULT_USER_SESSIONS_PREFIX, - upstashSessionAdapter -} from "../src/drivers/upstash"; - -import type { QueryHandler } from "@lucia-auth/adapter-test"; -import type { SessionSchema } from "lucia"; - -dotenv.config({ - path: `${resolve()}/.env` -}); - -const url = process.env.URL; -const token = process.env.TOKEN; - -if (!url || !token) throw new Error(".env is not set up"); - -const upstashClient = new Redis({ - url, - token -}); - -const sessionKey = (sessionId: string) => { - return [DEFAULT_SESSION_PREFIX, sessionId].join(":"); -}; -const userSessionsKey = (userId: string) => { - return [DEFAULT_USER_SESSIONS_PREFIX, userId].join(":"); -}; - -const adapter = upstashSessionAdapter(upstashClient)(LuciaError); - -const queryHandler: QueryHandler = { - session: { - get: async () => { - const keys = await upstashClient.keys(sessionKey("*")); - - const pipeline = upstashClient.pipeline(); - keys.forEach((key) => pipeline.get(key)); - const sessions = pipeline.exec(); - return sessions; - }, - insert: async (session) => { - const pipeline = upstashClient.pipeline(); - pipeline.set(sessionKey(session.id), JSON.stringify(session)); - pipeline.sadd(userSessionsKey(session.user_id), session.id); - await pipeline.exec(); - }, - clear: async () => { - await upstashClient.flushall(); - } - } -}; - -await testSessionAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-session-redis/tsconfig.json b/packages/adapter-session-redis/tsconfig.json deleted file mode 100644 index f2fb9daed..000000000 --- a/packages/adapter-session-redis/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "target": "ES2022", - "outDir": "./dist", - "declaration": true, - - "noImplicitAny": true, - "allowSyntheticDefaultImports": true - }, - "include": ["src"], - "exclude": ["node_modules/", "**/*.test.ts"] -} diff --git a/packages/adapter-session-unstorage/.gitignore b/packages/adapter-session-unstorage/.gitignore deleted file mode 100644 index e30934148..000000000 --- a/packages/adapter-session-unstorage/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -/dist -.DS_Store -.env \ No newline at end of file diff --git a/packages/adapter-session-unstorage/.npmignore b/packages/adapter-session-unstorage/.npmignore deleted file mode 100644 index b512c09d4..000000000 --- a/packages/adapter-session-unstorage/.npmignore +++ /dev/null @@ -1 +0,0 @@ -node_modules \ No newline at end of file diff --git a/packages/adapter-session-unstorage/.prettierignore b/packages/adapter-session-unstorage/.prettierignore deleted file mode 100644 index 38972655f..000000000 --- a/packages/adapter-session-unstorage/.prettierignore +++ /dev/null @@ -1,13 +0,0 @@ -.DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example - -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock diff --git a/packages/adapter-session-unstorage/CHANGELOG.md b/packages/adapter-session-unstorage/CHANGELOG.md deleted file mode 100644 index 7d9ff014b..000000000 --- a/packages/adapter-session-unstorage/CHANGELOG.md +++ /dev/null @@ -1,23 +0,0 @@ -# @lucia-auth/adapter-session-unstorage - -## 2.1.0 - -### Minor changes - -- [#970](https://github.com/pilcrowOnPaper/lucia/pull/970) by [@Hebilicious](https://github.com/Hebilicious) : Add ttl support - -## 2.0.0 - -### Major changes - -- [#885](https://github.com/pilcrowOnPaper/lucia/pull/885) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update version and peer dependency - -## 2.0.0-beta.1 - -### Minor changes - -- [#867](https://github.com/pilcrowOnPaper/lucia/pull/867) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -### Patch changes - -- [#862](https://github.com/pilcrowOnPaper/lucia/pull/862) by [@schweden1997](https://github.com/schweden1997) : use correct exports in package.json, fixes #861 diff --git a/packages/adapter-session-unstorage/README.md b/packages/adapter-session-unstorage/README.md deleted file mode 100644 index b1fe78e81..000000000 --- a/packages/adapter-session-unstorage/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# `@lucia-auth/adapter-session-storage` - -[Unstorage](https://github.com/unjs/unstorage) session adapter for Lucia v2. - -**[Documentation](https://lucia-auth.com/reference#lucia-authadapter-session-unstorage)** - -**[Lucia documentation](https://lucia-auth.com)** - -**[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/session-adapter-storage/CHANGELOG.md)** - -## Installation - -``` -npm install @lucia-auth/adapter-session-unstorage -``` - -## Testing - -``` -pnpm test -``` diff --git a/packages/adapter-session-unstorage/package.json b/packages/adapter-session-unstorage/package.json deleted file mode 100644 index 3d3ba4370..000000000 --- a/packages/adapter-session-unstorage/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@lucia-auth/adapter-session-unstorage", - "version": "2.1.0", - "description": "Unstorage session adapter for Lucia", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "module": "dist/index.js", - "type": "module", - "files": [ - "/dist/", - "CHANGELOG.md" - ], - "scripts": { - "build": "shx rm -rf ./dist/* && tsc", - "test": "tsx test/index.ts", - "auri.build": "pnpm build" - }, - "keywords": [ - "lucia", - "lucia-auth", - "auth", - "authentication", - "adapter", - "unstorage", - "session" - ], - "repository": { - "type": "git", - "url": "https://github.com/pilcrowOnPaper/lucia", - "directory": "packages/adapter-session-unstorage" - }, - "license": "MIT", - "exports": { - ".": "./dist/index.js" - }, - "peerDependencies": { - "lucia": "^2.0.0", - "unstorage": "^1.9.0" - }, - "devDependencies": { - "@azure/keyvault-secrets": "^4.7.0", - "@lucia-auth/adapter-test": "latest", - "lucia": "latest", - "tsx": "^3.12.6" - }, - "dependencies": { - "unstorage": "^1.9.0" - } -} diff --git a/packages/adapter-session-unstorage/src/index.ts b/packages/adapter-session-unstorage/src/index.ts deleted file mode 100644 index c65af73b7..000000000 --- a/packages/adapter-session-unstorage/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { unstorageAdapter as unstorage } from "./unstorage.js"; diff --git a/packages/adapter-session-unstorage/src/lucia.d.ts b/packages/adapter-session-unstorage/src/lucia.d.ts deleted file mode 100644 index 1762fe7db..000000000 --- a/packages/adapter-session-unstorage/src/lucia.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// -declare namespace Lucia { - type Auth = any; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} - -// fix weird unstorage type errors -type R2Bucket = any; -type KVNamespace = any; diff --git a/packages/adapter-session-unstorage/src/unstorage.ts b/packages/adapter-session-unstorage/src/unstorage.ts deleted file mode 100644 index 2129d7be5..000000000 --- a/packages/adapter-session-unstorage/src/unstorage.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { prefixStorage } from "unstorage"; - -import type { SessionSchema, SessionAdapter, InitializeAdapter } from "lucia"; -import type { Storage } from "unstorage"; - -export const DEFAULT_SESSION_PREFIX = "session"; -export const DEFAULT_USER_SESSION_PREFIX = "user_session"; - -export const unstorageAdapter = ( - storage: Storage, - prefixes?: { - session: string; - userSession: string; - } -): InitializeAdapter => { - return () => { - const sessionStorage = prefixStorage( - storage, - prefixes?.session ?? DEFAULT_SESSION_PREFIX - ); - const getUserSessionStorage = (userId: string) => { - const prefix = [ - prefixes?.userSession ?? DEFAULT_USER_SESSION_PREFIX, - userId - ].join(":"); - return prefixStorage<"">(storage, prefix); - }; - - return { - getSession: async (sessionId) => { - const sessionResult = (await sessionStorage.getItem(sessionId)) ?? null; - return sessionResult; - }, - getSessionsByUserId: async (userId) => { - const userSessionStorage = getUserSessionStorage(userId); - const sessionIds = await userSessionStorage.getKeys(); - const sessionResults = await Promise.all( - sessionIds.map((sessionId) => { - return sessionStorage.getItem(sessionId); - }) - ); - return sessionResults.filter( - ( - sessionResult - ): sessionResult is NonNullable => { - return sessionResult !== null; - } - ); - }, - setSession: async (session) => { - const userSessionStorage = getUserSessionStorage(session.user_id); - await Promise.all([ - userSessionStorage.setItem(session.user_id, ""), - sessionStorage.setItem(session.id, session, { - ttl: Math.floor(Number(session.idle_expires) / 1000) - }) - ]); - }, - deleteSession: async (sessionId) => { - const sessionResult = (await sessionStorage.getItem(sessionId)) ?? null; - if (!sessionResult) return; - const sessionUserStorage = getUserSessionStorage(sessionId); - await Promise.all([ - sessionStorage.removeItem(sessionId), - sessionUserStorage.removeItem(sessionId) - ]); - }, - deleteSessionsByUserId: async (userId) => { - const userSessionStorage = getUserSessionStorage(userId); - const sessionIds = await userSessionStorage.getKeys(); - await Promise.all([ - ...sessionIds.map((sessionId) => { - return sessionStorage.removeItem(sessionId); - }), - userSessionStorage.clear() - ]); - }, - updateSession: async (sessionId, partialSession) => { - const sessionResult = (await sessionStorage.getItem(sessionId)) ?? null; - if (!sessionResult) return; - const updatedSession = { ...sessionResult, ...partialSession }; - await sessionStorage.setItem(sessionId, updatedSession, { - ttl: Math.floor(Number(partialSession.idle_expires) / 1000) - }); - } - }; - }; -}; diff --git a/packages/adapter-session-unstorage/test/index.ts b/packages/adapter-session-unstorage/test/index.ts deleted file mode 100644 index 3c1b13121..000000000 --- a/packages/adapter-session-unstorage/test/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { testSessionAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; -import { createStorage } from "unstorage"; -import { - unstorageAdapter, - DEFAULT_SESSION_PREFIX, - DEFAULT_USER_SESSION_PREFIX -} from "../src/unstorage.js"; - -import type { QueryHandler } from "@lucia-auth/adapter-test"; -import type { SessionSchema } from "lucia"; - -const storage = createStorage(); - -export const adapter = unstorageAdapter(storage)(LuciaError); - -export const queryHandler: QueryHandler = { - session: { - get: async () => { - const sessionIds = await storage.getKeys(DEFAULT_SESSION_PREFIX); - return Promise.all( - sessionIds.map((id) => storage.getItem(id) as Promise) - ); - }, - insert: async (session) => { - await Promise.all([ - storage.setItem(`${DEFAULT_SESSION_PREFIX}:${session.id}`, session), - storage.setItem( - [DEFAULT_USER_SESSION_PREFIX, session.user_id, session.id].join(":"), - "" - ) - ]); - }, - clear: async () => storage.clear() - } -}; - -await testSessionAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-sqlite/CHANGELOG.md b/packages/adapter-sqlite/CHANGELOG.md index 0daeb0b5c..42c13fbb7 100644 --- a/packages/adapter-sqlite/CHANGELOG.md +++ b/packages/adapter-sqlite/CHANGELOG.md @@ -1,5 +1,9 @@ # @lucia-auth/adapter-sqlite +## 3.0.0 + +See the [migration guide](https://v3.lucia-auth.com/upgrade-v3/sqlite). + ## 2.0.1 ### Patch changes diff --git a/packages/adapter-sqlite/README.md b/packages/adapter-sqlite/README.md index 15580b157..b95480ba5 100644 --- a/packages/adapter-sqlite/README.md +++ b/packages/adapter-sqlite/README.md @@ -1,16 +1,18 @@ # `@lucia-auth/adapter-sqlite` -SQLite adapter for Lucia v2. +SQLite adapter for Lucia. -**[Documentation](https://lucia-auth.com/reference#lucia-authadapter-prisma)** +**[Documentation](https://v3.lucia-auth.com/database/sqlite)** -**[Lucia documentation](https://lucia-auth.com)** +**[Lucia documentation](https://v3.lucia-auth.com)** **[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-sqlite/CHANGELOG.md)** ## Supported drivers - [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3) +- [`bun:sqlite`](https://bun.sh/docs/api/sqlite) +- [Cloudflare D1](https://developers.cloudflare.com/d1/) - [libSQL](https://github.com/libsql/libsql) (Turso) ## Installation @@ -29,25 +31,13 @@ yarn add @lucia-auth/adapter-sqlite pnpm test.better-sqlite3 ``` -### Cloudflare D1 - -Make sure [Wrangler is installed](https://developers.cloudflare.com/workers/wrangler/install-and-update/). - -Create a new `d1` database by running: +### Bun SQLite -```ts -wrangler d1 create ``` - -This will return the database binding, name, and id. Set those in `.env`: - -```bash -D1_DATABASE_BINDING="" -D1_DATABASE_NAME="" -D1_DATABASE_ID="" +pnpm test.bun-sqlite ``` -Finally, run: +### Cloudflare D1 ``` pnpm test.d1 diff --git a/packages/adapter-sqlite/package.json b/packages/adapter-sqlite/package.json index e3da5337a..f06f66f10 100644 --- a/packages/adapter-sqlite/package.json +++ b/packages/adapter-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@lucia-auth/adapter-sqlite", - "version": "2.0.1", + "version": "3.0.0", "description": "SQLite adapter for Lucia", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -13,9 +13,10 @@ "scripts": { "build": "shx rm -rf ./dist/* && tsc", "auri.build": "pnpm build", - "test.better-sqlite3": "tsx test/better-sqlite3/index.ts", - "test.d1": "tsx test/d1/index.ts", - "test.libsql": "tsx test/libsql/index.ts" + "test.better-sqlite3": "tsx tests/better-sqlite3.ts", + "test.bun-sqlite": "bun tests/bun-sqlite.ts", + "test.d1": "tsx tests/d1.ts", + "test.libsql": "tsx tests/libsql.ts" }, "keywords": [ "lucia", @@ -43,7 +44,7 @@ "peerDependencies": { "@libsql/client": "^0.3.0", "better-sqlite3": "8.x - 9.x", - "lucia": "^2.0.0" + "lucia": "3.x" }, "peerDependenciesMeta": { "better-sqlite3": { @@ -56,11 +57,13 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20230518.0", "@libsql/client": "^0.3.0", - "@lucia-auth/adapter-test": "latest", + "@lucia-auth/adapter-test": "workspace:*", "@miniflare/d1": "^2.14.0", + "@miniflare/web-sockets": "^2.14.1", "@types/better-sqlite3": "^7.6.3", "better-sqlite3": "^8.4.0", - "lucia": "latest", + "bun-types": "^1.0.12", + "lucia": "workspace:*", "tsx": "^3.12.6" } } diff --git a/packages/adapter-sqlite/src/base.ts b/packages/adapter-sqlite/src/base.ts new file mode 100644 index 000000000..2c0591500 --- /dev/null +++ b/packages/adapter-sqlite/src/base.ts @@ -0,0 +1,146 @@ +import type { + Adapter, + DatabaseSession, + RegisteredDatabaseSessionAttributes, + DatabaseUser, + RegisteredDatabaseUserAttributes +} from "lucia"; + +export class SQLiteAdapter implements Adapter { + private controller: Controller; + + private escapedUserTableName: string; + private escapedSessionTableName: string; + + constructor(controller: Controller, tableNames: TableNames) { + this.controller = controller; + this.escapedSessionTableName = escapeName(tableNames.session); + this.escapedUserTableName = escapeName(tableNames.user); + } + + public async deleteSession(sessionId: string): Promise { + await this.controller.execute(`DELETE FROM ${this.escapedSessionTableName} WHERE id = ?`, [ + sessionId + ]); + } + + public async deleteUserSessions(userId: string): Promise { + await this.controller.execute(`DELETE FROM ${this.escapedSessionTableName} WHERE user_id = ?`, [ + userId + ]); + } + + public async getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + const [databaseSession, databaseUser] = await Promise.all([ + this.getSession(sessionId), + this.getUserFromSessionId(sessionId) + ]); + return [databaseSession, databaseUser]; + } + + public async getUserSessions(userId: string): Promise { + const result = await this.controller.getAll( + `SELECT * FROM ${this.escapedSessionTableName} WHERE user_id = ?`, + [userId] + ); + return result.map((val) => { + return transformIntoDatabaseSession(val); + }); + } + + public async setSession(databaseSession: DatabaseSession): Promise { + const value: SessionSchema = { + id: databaseSession.id, + user_id: databaseSession.userId, + expires_at: Math.floor(databaseSession.expiresAt.getTime() / 1000), + ...databaseSession.attributes + }; + const entries = Object.entries(value).filter(([_, v]) => v !== undefined); + const columns = entries.map(([k]) => escapeName(k)); + const placeholders = Array(columns.length).fill("?"); + const values = entries.map(([_, v]) => v); + await this.controller.execute( + `INSERT INTO ${this.escapedSessionTableName} (${columns.join( + ", " + )}) VALUES (${placeholders.join(", ")})`, + values + ); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + await this.controller.execute( + `UPDATE ${this.escapedSessionTableName} SET expires_at = ? WHERE id = ?`, + [Math.floor(expiresAt.getTime() / 1000), sessionId] + ); + } + + public async deleteExpiredSessions(): Promise { + await this.controller.execute( + `DELETE FROM ${this.escapedSessionTableName} WHERE expires_at <= ?`, + [Math.floor(Date.now() / 1000)] + ); + } + + private async getSession(sessionId: string): Promise { + const result = await this.controller.get( + `SELECT * FROM ${this.escapedSessionTableName} WHERE id = ?`, + [sessionId] + ); + if (!result) return null; + return transformIntoDatabaseSession(result); + } + + private async getUserFromSessionId(sessionId: string): Promise { + const result = await this.controller.get( + `SELECT ${this.escapedUserTableName}.* FROM ${this.escapedSessionTableName} INNER JOIN ${this.escapedUserTableName} ON ${this.escapedUserTableName}.id = ${this.escapedSessionTableName}.user_id WHERE ${this.escapedSessionTableName}.id = ?`, + [sessionId] + ); + if (!result) return null; + return transformIntoDatabaseUser(result); + } +} + +export interface TableNames { + user: string; + session: string; +} + +export interface Controller { + execute(sql: string, args: any[]): Promise; + get(sql: string, args: any[]): Promise; + getAll(sql: string, args: any[]): Promise; +} + +interface SessionSchema extends RegisteredDatabaseSessionAttributes { + id: string; + user_id: string; + expires_at: number; +} + +interface UserSchema extends RegisteredDatabaseUserAttributes { + id: string; +} + +function transformIntoDatabaseSession(raw: SessionSchema): DatabaseSession { + const { id, user_id: userId, expires_at: expiresAtUnix, ...attributes } = raw; + return { + userId, + id, + expiresAt: new Date(expiresAtUnix * 1000), + attributes + }; +} + +function transformIntoDatabaseUser(raw: UserSchema): DatabaseUser { + const { id, ...attributes } = raw; + return { + id, + attributes + }; +} + +function escapeName(val: string): string { + return "`" + val + "`"; +} diff --git a/packages/adapter-sqlite/src/drivers/better-sqlite3.ts b/packages/adapter-sqlite/src/drivers/better-sqlite3.ts index bee65d054..3d7347266 100644 --- a/packages/adapter-sqlite/src/drivers/better-sqlite3.ts +++ b/packages/adapter-sqlite/src/drivers/better-sqlite3.ts @@ -1,211 +1,29 @@ -import { helper, getSetArgs, escapeName } from "../utils.js"; +import { SQLiteAdapter } from "../base.js"; -import type { - SessionSchema, - Adapter, - InitializeAdapter, - UserSchema, - KeySchema -} from "lucia"; -import type { Database, SqliteError } from "better-sqlite3"; +import type { Controller, TableNames } from "../base.js"; +import type { Database } from "better-sqlite3"; -type BetterSQLiteError = InstanceType; - -export const betterSqlite3Adapter = ( - db: Database, - tables: { - user: string; - session: string | null; - key: string; +export class BetterSqlite3Adapter extends SQLiteAdapter { + constructor(db: Database, tableNames: TableNames) { + super(new BetterSqlite3Controller(db), tableNames); } -): InitializeAdapter => { - const transaction = <_Query extends () => any>(query: _Query): void => { - try { - db.exec("BEGIN TRANSACTION"); - const result = query(); - db.exec("COMMIT"); - return result; - } catch (e) { - if (db.inTransaction) { - db.exec("ROLLBACK"); - } - throw e; - } - }; +} - const ESCAPED_USER_TABLE_NAME = escapeName(tables.user); - const ESCAPED_SESSION_TABLE_NAME = tables.session - ? escapeName(tables.session) - : null; - const ESCAPED_KEY_TABLE_NAME = escapeName(tables.key); +class BetterSqlite3Controller implements Controller { + private db: Database; + constructor(db: Database) { + this.db = db; + } - return (LuciaError) => { - return { - getUser: async (userId) => { - const result: UserSchema | undefined = db - .prepare(`SELECT * FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`) - .get(userId); - return result ?? null; - }, - setUser: async (user, key) => { - const insertUser = () => { - const [userFields, userValues, userArgs] = helper(user); - db.prepare( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )` - ).run(...userArgs); - }; - if (!key) return insertUser(); - try { - transaction(() => { - insertUser(); - const [keyFields, keyValues, keyArgs] = helper(key); - db.prepare( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${keyFields} ) VALUES ( ${keyValues} )` - ).run(...keyArgs); - }); - } catch (e) { - const error = e as Partial; - if ( - error.code === "SQLITE_CONSTRAINT_PRIMARYKEY" && - error.message?.includes(".id") - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteUser: async (userId) => { - db.prepare(`DELETE FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`).run( - userId - ); - }, - updateUser: async (userId, partialUser) => { - const [fields, values, args] = helper(partialUser); - db.prepare( - `UPDATE ${ESCAPED_USER_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?` - ).run(...args, userId); - }, + public async get(sql: string, args: any[]): Promise { + return this.db.prepare(sql).get(...args); + } - getSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result: SessionSchema | undefined = db - .prepare(`SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`) - .get(sessionId); - return result ?? null; - }, - getSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result: SessionSchema[] = db - .prepare( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?` - ) - .all(userId); - return result; - }, - setSession: async (session) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - try { - const [fields, values, args] = helper(session); - db.prepare( - `INSERT INTO ${ESCAPED_SESSION_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )` - ).run(...args); - } catch (e) { - const error = e as Partial; - if (error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - throw e; - } - }, - deleteSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - db.prepare( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?` - ).run(sessionId); - }, - deleteSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - db.prepare( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?` - ).run(userId); - }, - updateSession: async (sessionId, partialSession) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(partialSession); - db.prepare( - `UPDATE ${ESCAPED_SESSION_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?` - ).run(...args, sessionId); - }, + public async getAll(sql: string, args: any[]): Promise { + return this.db.prepare(sql).all(...args); + } - getKey: async (keyId) => { - const result: KeySchema | undefined = db - .prepare(`SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`) - .get(keyId); - return result ?? null; - }, - getKeysByUserId: async (userId) => { - const result: KeySchema[] = db - .prepare(`SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`) - .all(userId); - return result; - }, - setKey: async (key) => { - try { - const [fields, values, args] = helper(key); - db.prepare( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )` - ).run(...args); - } catch (e) { - const error = e as Partial; - if (error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - if ( - error.code === "SQLITE_CONSTRAINT_PRIMARYKEY" && - error.message?.includes(".id") - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteKey: async (keyId) => { - db.prepare(`DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`).run( - keyId - ); - }, - deleteKeysByUserId: async (userId) => { - db.prepare( - `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?` - ).run(userId); - }, - updateKey: async (keyId, partialKey) => { - const [fields, values, args] = helper(partialKey); - db.prepare( - `UPDATE ${ESCAPED_KEY_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?` - ).run(...args, keyId); - } - }; - }; -}; + public async execute(sql: string, args: any[]): Promise { + await this.db.prepare(sql).run(...args); + } +} diff --git a/packages/adapter-sqlite/src/drivers/bun-sqlite.ts b/packages/adapter-sqlite/src/drivers/bun-sqlite.ts new file mode 100644 index 000000000..4bfaefc96 --- /dev/null +++ b/packages/adapter-sqlite/src/drivers/bun-sqlite.ts @@ -0,0 +1,39 @@ +import { SQLiteAdapter } from "../base.js"; + +import type { Controller, TableNames } from "../base.js"; + +export class BunSQLiteAdapter extends SQLiteAdapter { + constructor(db: Database, tableNames: TableNames) { + super(new BunSQLiteController(db), tableNames); + } +} + +class BunSQLiteController implements Controller { + private db: Database; + constructor(db: Database) { + this.db = db; + } + + public async get(sql: string, args: any[]): Promise { + return this.db.prepare(sql).get(...args) as T | null; + } + + public async getAll(sql: string, args: any[]): Promise { + return this.db.prepare(sql).all(...args) as T[]; + } + + public async execute(sql: string, args: any[]): Promise { + this.db.prepare(sql).run(...args); + } +} + +// not using `bun-types` since it collides with `@types/node` +interface Database { + prepare(sql: string): Statement; +} + +interface Statement { + get(...args: any[]): unknown; + all(...args: any[]): unknown; + run(...args: any[]): unknown; +} diff --git a/packages/adapter-sqlite/src/drivers/d1.ts b/packages/adapter-sqlite/src/drivers/d1.ts index 3e1525567..a6e397508 100644 --- a/packages/adapter-sqlite/src/drivers/d1.ts +++ b/packages/adapter-sqlite/src/drivers/d1.ts @@ -1,266 +1,42 @@ -import { helper, getSetArgs, escapeName } from "../utils.js"; +import { SQLiteAdapter } from "../base.js"; -import type { - SessionSchema, - Adapter, - InitializeAdapter, - UserSchema, - KeySchema -} from "lucia"; -import type { D1Database } from "@cloudflare/workers-types"; +import type { Controller, TableNames } from "../base.js"; +import type { D1Database as WorkerD1Database } from "@cloudflare/workers-types"; +import type { D1Database as MiniflareD1Database } from "@miniflare/d1"; -export const d1Adapter = ( - db: D1Database, - tables: { - user: string; - session: string | null; - key: string; +type D1Database = WorkerD1Database | MiniflareD1Database; + +export class D1Adapter extends SQLiteAdapter { + constructor(db: D1Database, tableNames: TableNames) { + super(new D1Controller(db), tableNames); } -): InitializeAdapter => { - const ESCAPED_USER_TABLE_NAME = escapeName(tables.user); - const ESCAPED_SESSION_TABLE_NAME = tables.session - ? escapeName(tables.session) - : null; - const ESCAPED_KEY_TABLE_NAME = escapeName(tables.key); +} - return (LuciaError) => { - return { - getUser: async (userId) => { - const user = await db - .prepare(`SELECT * FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`) - .bind(userId) - .first(); - return user; - }, - setUser: async (user, key) => { - const [userFields, userValues, userArgs] = helper(user); - const insertUserStatement = db - .prepare( - `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )` - ) - .bind(...userArgs); - if (!key) { - await insertUserStatement.run(); - return; - } - try { - const [keyFields, keyValues, keyArgs] = helper(key); - const insertKeyStatement = db - .prepare( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${keyFields} ) VALUES ( ${keyValues} )` - ) - .bind(...keyArgs); - await db.batch([insertUserStatement, insertKeyStatement]); - } catch (e) { - const error = e as Partial<{ - cause: Partial; - }>; - if ( - error.cause?.message?.includes("UNIQUE constraint failed") && - error.cause?.message?.includes(`${tables.key}.id`) - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteUser: async (userId) => { - await db - .prepare(`DELETE FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`) - .bind(userId) - .run(); - }, - updateUser: async (userId, partialUser) => { - const [fields, values, args] = helper(partialUser); - await db - .prepare( - `UPDATE ${ESCAPED_USER_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?` - ) - .bind(...args, userId) - .run(); - }, +class D1Controller implements Controller { + private db: D1Database; + constructor(db: D1Database) { + this.db = db; + } - getSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const session = await db - .prepare(`SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`) - .bind(sessionId) - .first(); - return session; - }, - getSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const { results: sessionResults } = await db - .prepare( - `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?` - ) - .bind(userId) - .all(); - return sessionResults ?? []; - }, - setSession: async (session) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - try { - const [fields, values, args] = helper(session); - await db - .prepare( - `INSERT INTO ${ESCAPED_SESSION_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )` - ) - .bind(...args) - .run(); - } catch (e) { - const error = e as Partial<{ - cause: Partial; - }>; - if (error.cause?.message?.includes("FOREIGN KEY constraint failed")) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - throw e; - } - }, - deleteSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await db - .prepare(`DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`) - .bind(sessionId) - .run(); - }, - deleteSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await db - .prepare( - `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?` - ) - .bind(userId) - .run(); - }, - updateSession: async (sessionId, partialSession) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(partialSession); - await db - .prepare( - `UPDATE ${ESCAPED_SESSION_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?` - ) - .bind(...args, sessionId) - .run(); - }, + public async get(sql: string, args: any[]): Promise { + return await this.db + .prepare(sql) + .bind(...args) + .first(); + } - getKey: async (keyId) => { - const key = await db - .prepare(`SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`) - .bind(keyId) - .first(); - return key; - }, - getKeysByUserId: async (userId) => { - const { results: keyResults } = await db - .prepare(`SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`) - .bind(userId) - .all(); - return keyResults ?? []; - }, - setKey: async (key) => { - try { - const [fields, values, args] = helper(key); - await db - .prepare( - `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )` - ) - .bind(...args) - .run(); - } catch (e) { - const error = e as Partial<{ - cause: Partial; - }>; - if (error.cause?.message?.includes("FOREIGN KEY constraint failed")) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - if ( - error.cause?.message?.includes("UNIQUE constraint failed") && - error.cause?.message?.includes(`${tables.key}.id`) - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteKey: async (keyId) => { - await db - .prepare(`DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`) - .bind(keyId) - .run(); - }, - deleteKeysByUserId: async (userId) => { - await db - .prepare(`DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`) - .bind(userId) - .run(); - }, - updateKey: async (keyId, partialKey) => { - const [fields, values, args] = helper(partialKey); - await db - .prepare( - `UPDATE ${ESCAPED_KEY_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?` - ) - .bind(...args, keyId) - .run(); - }, + public async getAll(sql: string, args: any[]): Promise { + const result = await this.db + .prepare(sql) + .bind(...args) + .all(); + return result.results ?? []; + } - getSessionAndUser: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const getSessionStatement = db - .prepare(`SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`) - .bind(sessionId); - const getUserFromJoinStatement = db - .prepare( - `SELECT ${ESCAPED_USER_TABLE_NAME}.*, ${ESCAPED_SESSION_TABLE_NAME}.id as __session_id FROM ${ESCAPED_SESSION_TABLE_NAME} INNER JOIN ${ESCAPED_USER_TABLE_NAME} ON ${ESCAPED_USER_TABLE_NAME}.id = ${ESCAPED_SESSION_TABLE_NAME}.user_id WHERE ${ESCAPED_SESSION_TABLE_NAME}.id = ?` - ) - .bind(sessionId); - type BatchQueryResult = { - error: any; - results?: Schema[]; - }; - const [{ results: sessionResults }, { results: userFromJoinResults }] = - (await db.batch([ - getSessionStatement, - getUserFromJoinStatement - ])) as any as [ - BatchQueryResult, - BatchQueryResult< - UserSchema & { - __session_id: string; - } - > - ]; - const sessionResult = sessionResults?.at(0) ?? null; - const userFromJoinResult = userFromJoinResults?.at(0) ?? null; - if (!sessionResult || !userFromJoinResult) return [null, null]; - const { __session_id: _, ...userResult } = userFromJoinResult; - return [sessionResult, userResult]; - } - }; - }; -}; + public async execute(sql: string, args: any[]): Promise { + await this.db + .prepare(sql) + .bind(...args) + .run(); + } +} diff --git a/packages/adapter-sqlite/src/drivers/libsql.ts b/packages/adapter-sqlite/src/drivers/libsql.ts index cdad2e699..cc160be69 100644 --- a/packages/adapter-sqlite/src/drivers/libsql.ts +++ b/packages/adapter-sqlite/src/drivers/libsql.ts @@ -1,211 +1,40 @@ -import { helper, getSetArgs, escapeName } from "../utils.js"; +import { SQLiteAdapter } from "../base.js"; -import type { - SessionSchema, - Adapter, - InitializeAdapter, - UserSchema, - KeySchema -} from "lucia"; -import type { Client, LibsqlError } from "@libsql/client"; +import type { Controller, TableNames } from "../base.js"; +import type { Client } from "@libsql/client"; -export const libsqlAdapter = ( - db: Client, - tables: { - user: string; - session: string | null; - key: string; +export class LibSQLAdapter extends SQLiteAdapter { + constructor(db: Client, tableNames: TableNames) { + super(new LibSQLController(db), tableNames); } -): InitializeAdapter => { - const ESCAPED_USER_TABLE_NAME = escapeName(tables.user); - const ESCAPED_SESSION_TABLE_NAME = tables.session - ? escapeName(tables.session) - : null; - const ESCAPED_KEY_TABLE_NAME = escapeName(tables.key); +} - return (LuciaError) => { - return { - getUser: async (userId) => { - const result = await db.execute({ - sql: `SELECT * FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`, - args: [userId] - }); - const rows = result.rows as unknown[] as UserSchema[]; - return rows.at(0) ?? null; - }, - setUser: async (user, key) => { - const [userFields, userValues, userArgs] = helper(user); - const insertUserQuery = { - sql: `INSERT INTO ${ESCAPED_USER_TABLE_NAME} ( ${userFields} ) VALUES ( ${userValues} )`, - args: userArgs - }; - if (!key) { - await db.execute(insertUserQuery); - return; - } - try { - const [keyFields, keyValues, keyArgs] = helper(key); - const insertKeyQuery = { - sql: `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${keyFields} ) VALUES ( ${keyValues} )`, - args: keyArgs - }; - await db.batch([insertUserQuery, insertKeyQuery], "write"); - } catch (e) { - const error = e as Partial; - if ( - error.code === "SQLITE_CONSTRAINT_PRIMARYKEY" && - error.message?.includes(".id") - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteUser: async (userId) => { - await db.execute({ - sql: `DELETE FROM ${ESCAPED_USER_TABLE_NAME} WHERE id = ?`, - args: [userId] - }); - }, - updateUser: async (userId, partialUser) => { - const [fields, values, args] = helper(partialUser); - args.push(userId); - await db.execute({ - sql: `UPDATE ${ESCAPED_USER_TABLE_NAME} SET ${getSetArgs( - fields, - values - )} WHERE id = ?`, - args - }); - }, +class LibSQLController implements Controller { + private db: Client; + constructor(db: Client) { + this.db = db; + } - getSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await db.execute({ - sql: `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, - args: [sessionId] - }); - const rows = result.rows as unknown[] as SessionSchema[]; - return rows.at(0) ?? null; - }, - getSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const result = await db.execute({ - sql: `SELECT * FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?`, - args: [userId] - }); - return result.rows as unknown[] as SessionSchema[]; - }, - setSession: async (session) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - try { - const [fields, values, args] = helper(session); - await db.execute({ - sql: `INSERT INTO ${ESCAPED_SESSION_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - }); - } catch (e) { - const error = e as Partial; - if (error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - throw e; - } - }, - deleteSession: async (sessionId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await db.execute({ - sql: `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE id = ?`, - args: [sessionId] - }); - }, - deleteSessionsByUserId: async (userId) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - await db.execute({ - sql: `DELETE FROM ${ESCAPED_SESSION_TABLE_NAME} WHERE user_id = ?`, - args: [userId] - }); - }, - updateSession: async (sessionId, partialSession) => { - if (!ESCAPED_SESSION_TABLE_NAME) { - throw new Error("Session table not defined"); - } - const [fields, values, args] = helper(partialSession); - const setArgs = getSetArgs(fields, values); - args.push(sessionId); - await db.execute({ - sql: `UPDATE ${ESCAPED_SESSION_TABLE_NAME} SET ${setArgs} WHERE id = ?`, - args - }); - }, + public async get(sql: string, args: any[]): Promise { + const result = await this.db.execute({ + sql, + args + }); + return (result.rows.at(0) as T) ?? null; + } - getKey: async (keyId) => { - const result = await db.execute({ - sql: `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`, - args: [keyId] - }); - const rows = result.rows as unknown[] as KeySchema[]; - return rows.at(0) ?? null; - }, - getKeysByUserId: async (userId) => { - const result = await db.execute({ - sql: `SELECT * FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`, - args: [userId] - }); - return result.rows as unknown[] as KeySchema[]; - }, - setKey: async (key) => { - try { - const [fields, values, args] = helper(key); - await db.execute({ - sql: `INSERT INTO ${ESCAPED_KEY_TABLE_NAME} ( ${fields} ) VALUES ( ${values} )`, - args - }); - } catch (e) { - const error = e as Partial; - if (error.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - if ( - error.code === "SQLITE_CONSTRAINT_PRIMARYKEY" && - error.message?.includes(".id") - ) { - throw new LuciaError("AUTH_DUPLICATE_KEY_ID"); - } - throw e; - } - }, - deleteKey: async (keyId) => { - await db.execute({ - sql: `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE id = ?`, - args: [keyId] - }); - }, - deleteKeysByUserId: async (userId) => { - await db.execute({ - sql: `DELETE FROM ${ESCAPED_KEY_TABLE_NAME} WHERE user_id = ?`, - args: [userId] - }); - }, - updateKey: async (keyId, partialKey) => { - const [fields, values, args] = helper(partialKey); - const setArgs = getSetArgs(fields, values); - args.push(keyId); - await db.execute({ - sql: `UPDATE ${ESCAPED_KEY_TABLE_NAME} SET ${setArgs} WHERE id = ?`, - args - }); - } - }; - }; -}; + public async getAll(sql: string, args: any[]): Promise { + const result = await this.db.execute({ + sql, + args + }); + return result.rows as T[]; + } + + public async execute(sql: string, args: any[]): Promise { + await this.db.execute({ + sql, + args + }); + } +} diff --git a/packages/adapter-sqlite/src/index.ts b/packages/adapter-sqlite/src/index.ts index 61072ecf4..de6176e2c 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/adapter-sqlite/src/index.ts @@ -1,3 +1,4 @@ -export { betterSqlite3Adapter as betterSqlite3 } from "./drivers/better-sqlite3.js"; -export { d1Adapter as d1 } from "./drivers/d1.js"; -export { libsqlAdapter as libsql } from "./drivers/libsql.js"; +export { BetterSqlite3Adapter } from "./drivers/better-sqlite3.js"; +export { D1Adapter } from "./drivers/d1.js"; +export { LibSQLAdapter } from "./drivers/libsql.js"; +export { BunSQLiteAdapter } from "./drivers/bun-sqlite.js"; diff --git a/packages/adapter-sqlite/src/lucia.d.ts b/packages/adapter-sqlite/src/lucia.d.ts deleted file mode 100644 index 8026ca988..000000000 --- a/packages/adapter-sqlite/src/lucia.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -declare namespace Lucia { - type Auth = any; - type DatabaseUserAttributes = any; - type DatabaseSessionAttributes = any; -} diff --git a/packages/adapter-sqlite/src/utils.ts b/packages/adapter-sqlite/src/utils.ts deleted file mode 100644 index 76b7007c2..000000000 --- a/packages/adapter-sqlite/src/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -const createPreparedStatementHelper = ( - placeholder: (index: number) => string -) => { - const helper = ( - values: Record - ): readonly [fields: string[], placeholders: string[], arguments: any[]] => { - const keys = Object.keys(values); - return [ - keys.map((k) => escapeName(k)), - keys.map((_, i) => placeholder(i)), - keys.map((k) => values[k]) - ] as const; - }; - return helper; -}; - -export const escapeName = (val: string) => { - return `${ESCAPE_CHAR}${val}${ESCAPE_CHAR}`; -}; - -const ESCAPE_CHAR = "`"; - -export const helper = createPreparedStatementHelper(() => "?"); - -export const getSetArgs = (fields: string[], placeholders: string[]) => { - return fields - .map((field, i) => [field, placeholders[i]].join(" = ")) - .join(","); -}; diff --git a/packages/adapter-sqlite/test/better-sqlite3/index.ts b/packages/adapter-sqlite/test/better-sqlite3/index.ts deleted file mode 100644 index 24fb4913c..000000000 --- a/packages/adapter-sqlite/test/better-sqlite3/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; - -import { TABLE_NAMES, db } from "../db.js"; -import { betterSqlite3Adapter } from "../../src/drivers/better-sqlite3.js"; -import { escapeName, helper } from "../../src/utils.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; - -const createTableQueryHandler = (tableName: string): TableQueryHandler => { - const ESCAPED_TABLE_NAME = escapeName(tableName); - return { - get: async () => { - return db.prepare(`SELECT * FROM ${ESCAPED_TABLE_NAME}`).all(); - }, - insert: async (value: any) => { - const [fields, placeholders, args] = helper(value); - db.prepare( - `INSERT INTO ${ESCAPED_TABLE_NAME} ( ${fields} ) VALUES ( ${placeholders} )` - ).run(...args); - }, - clear: async () => { - db.exec(`DELETE FROM ${ESCAPED_TABLE_NAME}`); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(TABLE_NAMES.user), - session: createTableQueryHandler(TABLE_NAMES.session), - key: createTableQueryHandler(TABLE_NAMES.key) -}; - -const adapter = betterSqlite3Adapter(db, TABLE_NAMES)(LuciaError); - -testAdapter(adapter, new Database(queryHandler)); diff --git a/packages/adapter-sqlite/test/d1/index.ts b/packages/adapter-sqlite/test/d1/index.ts deleted file mode 100644 index 74215bdbb..000000000 --- a/packages/adapter-sqlite/test/d1/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; -import { D1Database, D1DatabaseAPI } from "@miniflare/d1"; - -import { d1Adapter } from "../../src/drivers/d1.js"; -import { escapeName, helper } from "../../src/utils.js"; -import { TABLE_NAMES, db } from "../db.js"; - -import type { D1Database as WorkerD1Database } from "@cloudflare/workers-types"; -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; - -const D1 = new D1Database(new D1DatabaseAPI(db)) as any as WorkerD1Database; - -const createTableQueryHandler = (tableName: string): TableQueryHandler => { - const ESCAPED_TABLE_NAME = escapeName(tableName); - return { - get: async () => { - const { results } = await D1.prepare( - `SELECT * FROM ${ESCAPED_TABLE_NAME}` - ).all(); - return results ?? []; - }, - insert: async (value: any) => { - const [fields, placeholders, args] = helper(value); - await D1.prepare( - `INSERT INTO ${ESCAPED_TABLE_NAME} ( ${fields} ) VALUES ( ${placeholders} )` - ) - .bind(...args) - .run(); - }, - clear: async () => { - await D1.exec(`DELETE FROM ${ESCAPED_TABLE_NAME}`); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(TABLE_NAMES.user), - session: createTableQueryHandler(TABLE_NAMES.session), - key: createTableQueryHandler(TABLE_NAMES.key) -}; - -const adapter = d1Adapter(D1, TABLE_NAMES)(LuciaError); - -await testAdapter(adapter, new Database(queryHandler)); - -process.exit(0); diff --git a/packages/adapter-sqlite/test/db.ts b/packages/adapter-sqlite/test/db.ts deleted file mode 100644 index 4bb8bd093..000000000 --- a/packages/adapter-sqlite/test/db.ts +++ /dev/null @@ -1,9 +0,0 @@ -import sqlite from "better-sqlite3"; - -export const db = sqlite("test/main.db"); - -export const TABLE_NAMES = { - user: "test_user", - session: "user_session", - key: "user_key" -}; diff --git a/packages/adapter-sqlite/test/libsql/index.ts b/packages/adapter-sqlite/test/libsql/index.ts deleted file mode 100644 index b47123846..000000000 --- a/packages/adapter-sqlite/test/libsql/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { testAdapter, Database } from "@lucia-auth/adapter-test"; -import { LuciaError } from "lucia"; -import { createClient } from "@libsql/client"; - -import { TABLE_NAMES } from "../db.js"; -import { libsqlAdapter } from "../../src/drivers/libsql.js"; -import { escapeName, helper } from "../../src/utils.js"; - -import type { QueryHandler, TableQueryHandler } from "@lucia-auth/adapter-test"; - -const db = createClient({ - url: "file:test/main.db" -}); - -const createTableQueryHandler = (tableName: string): TableQueryHandler => { - const ESCAPED_TABLE_NAME = escapeName(tableName); - return { - get: async () => { - const { rows } = await db.execute(`SELECT * FROM ${ESCAPED_TABLE_NAME}`); - return rows; - }, - insert: async (value: any) => { - const [fields, placeholders, args] = helper(value); - await db.execute({ - sql: `INSERT INTO ${ESCAPED_TABLE_NAME} ( ${fields} ) VALUES ( ${placeholders} )`, - args - }); - }, - clear: async () => { - await db.execute(`DELETE FROM ${ESCAPED_TABLE_NAME}`); - } - }; -}; - -const queryHandler: QueryHandler = { - user: createTableQueryHandler(TABLE_NAMES.user), - session: createTableQueryHandler(TABLE_NAMES.session), - key: createTableQueryHandler(TABLE_NAMES.key) -}; - -const adapter = libsqlAdapter(db, TABLE_NAMES)(LuciaError); - -testAdapter(adapter, new Database(queryHandler)); diff --git a/packages/adapter-sqlite/test/main.db b/packages/adapter-sqlite/test/main.db deleted file mode 100644 index 6220e434208b688d6e6c7b2856e2b72d677ea53c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)J#W)M7zc2_q)8iET!s=Bt0$xif+A>~q|HdFOchZAgj8w=WE{t7T*r25J8>Eb zp>{y(2VmnHFfg;QF|Z-N00ST~F!BL#Ol=IP6-c}l{UarQeD+B_1|;z!8&wALhbalKA%)+*PRY8&L5xRFZ-=Y&V9H|nIiwz^7c;#ILG zR+q)w#8n+v8l81sYi4t4S#h<#Dyf66W~&aVRO{l3SR-N0oc}MK!w;u(M6r6NYwvtc zC9Ia+old4QJWuaBU;3x3J3)5-HuZhK{L)|5r?%|0)uz;y9mlim&r0d+rBpJr%+r)+ zHr0W%-SO}EB)R8W!G0hj`8i1WFvoH31NGv+xZW1Wl9_9BNA~>S+B@j|!CtaMr!up% z^zOM}5Th$63Yf2kF)BW498K9!zrI$;TD5X(O-yI=Y%(Lx9+qa*x+FwlBG3F-m3&l% z^+z;*KOYS6)aaM{NO5ntS3v=OAOHafKmY;|fB*y_009U<00Iy=1_B(z@-v$YJxk~g zEko{V9PJk@^c6jCT7#}tZpJ&29_@_o|KD@(7U009U<00Izz00bZa0SG_<0uVSB z0+TFHhw}i;36`G-=K$i_NB939xHs`~#1Mc01Rwwb2tWV=5P$##AOHaf94mowc7`53 z2f(D*nTc=L|D)&sA9Iak)hi?m0SG_<0uX=z1Rwwb2tWV=5csDAR+yy1kByy@UCHaT z3Z=F%=oK60KruXdUK`4q=jn}puVGu3UDlnVV()~j_VoVnSt3jC-+y~IyS~0cM-7z} zt>tQsl3iZ(G}SC`Z(ECAQPsBv!z-6ur=VMQuUKk_EB--4^Fc%N8yj(+X#M{=_b^_L z7y=N000bZa0SG_<0uX=z1Rwx`-z;#6Rp_6XE?_RQ%7lOZkIo9jvyayQA90W3<%l5w z0SG_<0uX=z1Rwwb2tWV=5co3!7nnqar}C|Ct5h_sVlm$@2xddr)E9NP-D$OYZNt$E zf@=&HHvNopJmm?){Fb@7qx*0D7uv(JrY>xGouMJfLT9TVJ2&b;wEq7D@Bfcb5I_I| m5P$##AOHafKmY;|fB*#kuE1HgLLWUL!1xKCJ$fD>-i1#=rJfl8 diff --git a/packages/adapter-sqlite/tests/better-sqlite3.ts b/packages/adapter-sqlite/tests/better-sqlite3.ts new file mode 100644 index 000000000..35731ad0d --- /dev/null +++ b/packages/adapter-sqlite/tests/better-sqlite3.ts @@ -0,0 +1,30 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { BetterSqlite3Adapter } from "../src/drivers/better-sqlite3.js"; +import sqlite from "better-sqlite3"; + +const db = sqlite(":memory:"); + +db.exec( + `CREATE TABLE user ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE +)` +).exec(`CREATE TABLE user_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + country TEXT, + FOREIGN KEY (user_id) REFERENCES user(id) +)`); + +db.prepare(`INSERT INTO user (id, username) VALUES (?, ?)`).run( + databaseUser.id, + databaseUser.attributes.username +); + +const adapter = new BetterSqlite3Adapter(db, { + user: "user", + session: "user_session" +}); + +await testAdapter(adapter); diff --git a/packages/adapter-sqlite/tests/bun-sqlite.ts b/packages/adapter-sqlite/tests/bun-sqlite.ts new file mode 100644 index 000000000..167e81517 --- /dev/null +++ b/packages/adapter-sqlite/tests/bun-sqlite.ts @@ -0,0 +1,32 @@ +/// +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { BunSQLiteAdapter } from "../src/drivers/bun-sqlite.js"; +import { Database } from "bun:sqlite"; + +const db = new Database(":memory:"); + +db.exec( + `CREATE TABLE user ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE +)` +); +db.exec(`CREATE TABLE user_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + country TEXT, + FOREIGN KEY (user_id) REFERENCES user(id) +)`); + +db.prepare(`INSERT INTO user (id, username) VALUES (?, ?)`).run( + databaseUser.id, + databaseUser.attributes.username +); + +const adapter = new BunSQLiteAdapter(db, { + user: "user", + session: "user_session" +}); + +await testAdapter(adapter); diff --git a/packages/adapter-sqlite/tests/d1.ts b/packages/adapter-sqlite/tests/d1.ts new file mode 100644 index 000000000..228109fcd --- /dev/null +++ b/packages/adapter-sqlite/tests/d1.ts @@ -0,0 +1,24 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { D1Adapter } from "../src/drivers/d1.js"; +import { D1Database, D1DatabaseAPI } from "@miniflare/d1"; +import sqlite from "better-sqlite3"; + +const db = sqlite(":memory:"); +const d1 = new D1Database(new D1DatabaseAPI(db)); + +await d1.exec(`CREATE TABLE user ( id TEXT NOT NULL PRIMARY KEY, username TEXT NOT NULL UNIQUE )`); +await d1.exec( + `CREATE TABLE user_session ( id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, country TEXT, FOREIGN KEY (user_id) REFERENCES user(id))` +); + +await d1 + .prepare(`INSERT INTO user (id, username) VALUES (?, ?)`) + .bind(databaseUser.id, databaseUser.attributes.username) + .run(); + +const adapter = new D1Adapter(d1, { + user: "user", + session: "user_session" +}); + +await testAdapter(adapter); diff --git a/packages/adapter-sqlite/tests/db.ts b/packages/adapter-sqlite/tests/db.ts new file mode 100644 index 000000000..1890b1dcc --- /dev/null +++ b/packages/adapter-sqlite/tests/db.ts @@ -0,0 +1,7 @@ +declare module "lucia" { + interface Register { + DatabaseUserAttributes: { + username: string; + }; + } +} diff --git a/packages/adapter-sqlite/tests/libsql.ts b/packages/adapter-sqlite/tests/libsql.ts new file mode 100644 index 000000000..f8863c848 --- /dev/null +++ b/packages/adapter-sqlite/tests/libsql.ts @@ -0,0 +1,40 @@ +import { testAdapter, databaseUser } from "@lucia-auth/adapter-test"; +import { LibSQLAdapter } from "../src/drivers/libsql.js"; +import { createClient } from "@libsql/client"; +import fs from "fs/promises"; + +await fs.rm("test/libsql/test.db"); + +const client = createClient({ + url: "file:test/libsql/test.db" +}); + +await client.execute( + `CREATE TABLE user ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE +)` +); +await client.execute(`CREATE TABLE user_session ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + country TEXT, + FOREIGN KEY (user_id) REFERENCES user(id) +)`); + +await client.execute({ + sql: `INSERT INTO user (id, username) VALUES (?, ?)`, + args: [databaseUser.id, databaseUser.attributes.username] +}); + +const adapter = new LibSQLAdapter(client, { + user: "user", + session: "user_session" +}); + +try { + await testAdapter(adapter); +} finally { + await fs.rm("test/libsql/test.db"); +} diff --git a/packages/adapter-sqlite/tsconfig.json b/packages/adapter-sqlite/tsconfig.json index f2fb9daed..5cb3f5468 100644 --- a/packages/adapter-sqlite/tsconfig.json +++ b/packages/adapter-sqlite/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/adapter-test/CHANGELOG.md b/packages/adapter-test/CHANGELOG.md index 73cf10a71..9aa8313dc 100644 --- a/packages/adapter-test/CHANGELOG.md +++ b/packages/adapter-test/CHANGELOG.md @@ -1,5 +1,9 @@ # @lucia-auth/adapter-test +## 5.0.0 + +- Update API to support Lucia v3 + ## 4.1.1 ### Patch changes diff --git a/packages/adapter-test/README.md b/packages/adapter-test/README.md index aa27d95a3..e5dc18c32 100644 --- a/packages/adapter-test/README.md +++ b/packages/adapter-test/README.md @@ -1,16 +1,14 @@ # Tests for Lucia adapters -Testing module for Lucia v2 database adapters. +Testing module for Lucia database adapters. -**[Documentation](https://lucia-auth.com/extending-lucia/database-adapters-api)** - -**[Lucia documentation](https://lucia-auth.com)** +**[Lucia documentation](https://v3.lucia-auth.com)** **[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/adapter-test/CHANGELOG.md)** ## Installation -```bash +``` npm i -D @lucia-auth/adapter-test pnpm add -D @lucia-auth/adapter-test yarn add -D @lucia-auth/adapter-test diff --git a/packages/adapter-test/package.json b/packages/adapter-test/package.json index 62208213e..b2194ff48 100644 --- a/packages/adapter-test/package.json +++ b/packages/adapter-test/package.json @@ -1,6 +1,6 @@ { "name": "@lucia-auth/adapter-test", - "version": "4.1.1", + "version": "5.0.0", "description": "Testing module for Lucia database adapters", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -34,12 +34,13 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "lucia": "latest" + "lucia": "workspace:*" }, "peerDependencies": { - "lucia": "^2.0.0" + "lucia": "3.x" }, "dependencies": { - "mocha": "^10.2.0" + "mocha": "^10.2.0", + "oslo": "0.28.2" } } diff --git a/packages/adapter-test/src/database.ts b/packages/adapter-test/src/database.ts deleted file mode 100644 index b9a9bd6de..000000000 --- a/packages/adapter-test/src/database.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { generateRandomString } from "lucia/utils"; -import type { KeySchema, SessionSchema, UserSchema } from "lucia"; - -export type TestUserSchema = UserSchema & { - username: string; -}; - -export type TestSessionSchema = SessionSchema & { - country: string; -}; - -export type TableQueryHandler< - Schema extends { - id: string; - } = any -> = { - get: () => Promise; - insert: (data: Schema) => Promise; - clear: () => Promise; -}; - -export type QueryHandler = { - user?: TableQueryHandler; - session?: TableQueryHandler; - key?: TableQueryHandler; -}; - -export class Database { - private readonly queryHandler: QueryHandler; - - constructor(queryHandler: QueryHandler) { - this.queryHandler = queryHandler; - } - - public user = () => { - const userQueryHandler = this.queryHandler["user"]; - if (!userQueryHandler) { - throw new Error("No query handler provided for 'user'"); - } - return new Table(userQueryHandler); - }; - - public session = () => { - const sessionQueryHandler = this.queryHandler["session"]; - if (!sessionQueryHandler) { - throw new Error("No query handler provided for 'session'"); - } - return new Table(sessionQueryHandler); - }; - - public key = () => { - const keyQueryHandler = this.queryHandler["key"]; - if (!keyQueryHandler) { - throw new Error("No query handler provided for 'key'"); - } - return new Table(keyQueryHandler); - }; - - public generateUser = (options?: { - userId?: string; - username?: string; - }): TestUserSchema => { - const userId = options?.userId ?? generateRandomString(8); - const username = options?.username ?? generateRandomString(4); - return { - id: userId, - username - }; - }; - public generateSession = ( - userId: string | null, - options?: { - id?: string; - country?: string; - } - ): TestSessionSchema => { - const activeExpires = new Date().getTime() + 1000 * 60 * 60 * 8; - return { - user_id: userId ?? generateRandomString(8), - id: options?.id ?? `at_${generateRandomString(40)}`, - active_expires: activeExpires, - idle_expires: activeExpires + 1000 * 60 * 60 * 24, - country: options?.country ?? "XX" - }; - }; - - public generateKey = ( - userId: string | null, - options?: { - id?: string; - } - ): KeySchema => { - const keyUserId = userId ?? generateRandomString(8); - return { - id: options?.id ?? generateRandomString(30), - user_id: keyUserId, - hashed_password: null - }; - }; - - public clear = async () => { - await this.queryHandler.key?.clear(); - await this.queryHandler.session?.clear(); - await this.queryHandler.user?.clear(); - }; -} - -class Table<_Schema extends { id: string }> { - protected readonly queryHandler: TableQueryHandler<_Schema>; - constructor(queryHandler: TableQueryHandler<_Schema>) { - this.queryHandler = queryHandler; - } - public insert = async (...values: _Schema[]) => { - for (const value of values) { - await this.queryHandler.insert(value); - } - }; - public get = async (id: string) => { - const result = await this.queryHandler.get(); - return result.find((val) => val.id === id) ?? null; - }; - public getAll = async () => { - return await this.queryHandler.get(); - }; -} diff --git a/packages/adapter-test/src/index.ts b/packages/adapter-test/src/index.ts index e6ff5ac66..156867a14 100644 --- a/packages/adapter-test/src/index.ts +++ b/packages/adapter-test/src/index.ts @@ -1,10 +1,87 @@ -export { testAdapter } from "./tests/main.js"; -export { testSessionAdapter } from "./tests/session.js"; -export { Database } from "./database.js"; - -export type { - QueryHandler, - TableQueryHandler, - TestUserSchema, - TestSessionSchema -} from "./database.js"; +import { Adapter, DatabaseSession, DatabaseUser } from "lucia"; +import { generateRandomString, alphabet } from "oslo/crypto"; +import assert from "node:assert/strict"; + +export const databaseUser: DatabaseUser = { + id: generateRandomString(15, alphabet("0-9", "a-z")), + attributes: { + username: generateRandomString(15, alphabet("0-9", "a-z")) + } +}; + +export async function testAdapter(adapter: Adapter) { + console.log(`\n\x1B[38;5;63;1m[start] \x1B[0mRunning adapter tests\x1B[0m\n`); + const databaseSession: DatabaseSession = { + userId: databaseUser.id, + id: generateRandomString(40, alphabet("0-9", "a-z")), + // get random date with 0ms + expiresAt: new Date(Math.floor(Date.now() / 1000) * 1000 + 10_000), + attributes: { + country: "us" + } + }; + + await test("getSessionAndUser() returns [null, null] on invalid session id", async () => { + const result = await adapter.getSessionAndUser(databaseSession.id); + assert.deepStrictEqual(result, [null, null]); + }); + + await test("getUserSessions() returns empty array on invalid user id", async () => { + const result = await adapter.getUserSessions(databaseUser.id); + assert.deepStrictEqual(result, []); + }); + + await test("setSession() creates session and getSessionAndUser() returns created session and associated user", async () => { + await adapter.setSession(databaseSession); + const result = await adapter.getSessionAndUser(databaseSession.id); + assert.deepStrictEqual(result, [databaseSession, databaseUser]); + }); + + await test("deleteSession() deletes session", async () => { + await adapter.deleteSession(databaseSession.id); + const result = await adapter.getUserSessions(databaseSession.userId); + assert.deepStrictEqual(result, []); + }); + + await test("updateSessionExpiration() updates session", async () => { + await adapter.setSession(databaseSession); + databaseSession.expiresAt = new Date(databaseSession.expiresAt.getTime() + 10_000); + await adapter.updateSessionExpiration(databaseSession.id, databaseSession.expiresAt); + const result = await adapter.getSessionAndUser(databaseSession.id); + assert.deepStrictEqual(result, [databaseSession, databaseUser]); + }); + + await test("deleteExpiredSessions() deletes all expired sessions", async () => { + const expiredSession: DatabaseSession = { + userId: databaseUser.id, + id: generateRandomString(40, alphabet("0-9", "a-z")), + expiresAt: new Date(Math.floor(Date.now() / 1000) * 1000 - 10_000), + attributes: { + country: "us" + } + }; + await adapter.setSession(expiredSession); + await adapter.deleteExpiredSessions(); + const result = await adapter.getUserSessions(databaseSession.userId); + assert.deepStrictEqual(result, [databaseSession]); + }); + + await test("deleteUserSessions() deletes all user sessions", async () => { + await adapter.deleteUserSessions(databaseSession.userId); + const result = await adapter.getUserSessions(databaseSession.userId); + assert.deepStrictEqual(result, []); + }); + + console.log(`\n\x1B[32;1m[success] \x1B[0mAdapter passed all tests\n`); +} + +async function test(name: string, runTest: () => Promise): Promise { + console.log(`\x1B[38;5;63;1m► \x1B[0m${name}\x1B[0m`); + try { + await runTest(); + console.log(" \x1B[32m✓ Passed\x1B[0m\n"); + } catch (error) { + console.log(" \x1B[31m✓ Failed\x1B[0m\n"); + throw error; + } +} diff --git a/packages/adapter-test/src/lucia.d.ts b/packages/adapter-test/src/lucia.d.ts deleted file mode 100644 index 1f534bf88..000000000 --- a/packages/adapter-test/src/lucia.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// -declare namespace Lucia { - type Auth = any; - type DatabaseUserAttributes = { - username: string; - }; - type DatabaseSessionAttributes = { - country: string; - }; -} diff --git a/packages/adapter-test/src/test.ts b/packages/adapter-test/src/test.ts deleted file mode 100644 index 2d47d7167..000000000 --- a/packages/adapter-test/src/test.ts +++ /dev/null @@ -1,47 +0,0 @@ -type Test = (name: string, fn: () => Promise) => Promise; -type Skip = () => void; - -let passedCount = 0; - -const test: Test = async (name, fn) => { - try { - await fn(); - passedCount += 1; - console.log(` \x1B[32m✓ \x1B[0;2m${name}\x1B[0m`); - await afterEachFn(); - } catch (error) { - console.log(` \x1B[31m✗ \x1B[0;2m${name}\x1B[0m`); - throw error; - } -}; - -const skip: Skip = () => { - console.log(` \x1B[33m! \x1B[0;2mSkipped tests\x1B[0m`); -}; - -export const method = async ( - name: string, - runTests: (test: Test, skip: Skip) => Promise -) => { - console.log(`\n \x1B[36m${name}\x1B[0m`); - - await runTests(test, skip); -}; - -export const start = () => { - console.log( - `\n\x1B[38;5;63;1m[start] \x1B[0;2m Running adapter testing module\x1B[0m` - ); -}; - -export const finish = () => { - console.log( - `\n\x1B[32;1m[success] \x1B[0;2m Adapter passed \x1B[3m${passedCount}\x1B[23m tests\x1B[0m\n` - ); -}; - -let afterEachFn = async () => {}; - -export const afterEach = (fn: () => Promise) => { - afterEachFn = fn; -}; diff --git a/packages/adapter-test/src/tests/main.ts b/packages/adapter-test/src/tests/main.ts deleted file mode 100644 index 481de645c..000000000 --- a/packages/adapter-test/src/tests/main.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { LuciaError } from "lucia"; -import assert from "node:assert/strict"; -import { start, finish, method, afterEach } from "../test.js"; - -import type { Database } from "../database.js"; -import type { Adapter, SessionSchema, KeySchema, UserSchema } from "lucia"; - -export const testAdapter = async (adapter: Adapter, database: Database) => { - await database.clear(); - - const User = database.user(); - const Session = database.session(); - const Key = database.key(); - - afterEach(database.clear); - - start(); - - await method("getUser()", async (test) => { - await test("Returns target user", async () => { - const user = database.generateUser(); - await User.insert(user); - const result = await adapter.getUser(user.id); - assert.deepStrictEqual(result, user); - }); - await test("Returns null if invalid target user id", async () => { - const user = database.generateUser(); - await User.insert(user); - const result = await adapter.getUser("*"); - assert.deepStrictEqual(result, null); - }); - }); - - await method("setUser()", async (test) => { - await test("Inserts user only", async () => { - const user = database.generateUser(); - await adapter.setUser(user, null); - const storedUser = await User.get(user.id); - assert.deepStrictEqual(storedUser, user); - }); - await test("Inserts user and key", async () => { - const user = database.generateUser(); - const key = database.generateKey(user.id); - await adapter.setUser(user, key); - const storedUser = await User.get(user.id); - const storedKey = await Key.get(key.id); - assert.deepStrictEqual(storedUser, user); - assert.deepStrictEqual(storedKey, key); - }); - await test("Throws AUTH_DUPLICATE_KEY_ID on duplicate key id", async () => { - const user1 = database.generateUser(); - const key1 = database.generateKey(user1.id); - await User.insert(user1); - await Key.insert(key1); - const user2 = database.generateUser(); - const key2 = database.generateKey(user2.id, { - id: key1.id - }); - await assert.rejects(async () => { - await adapter.setUser(user2, key2); - }, new LuciaError("AUTH_DUPLICATE_KEY_ID")); - }); - - await test("Does not insert key if errors", async () => { - const user1 = database.generateUser(); - const key1 = database.generateKey(user1.id); - await User.insert(user1); - await Key.insert(key1); - const user2 = database.generateUser(); - const key2 = database.generateKey(user2.id, { - id: key1.id - }); - await assert.rejects(async () => { - await adapter.setUser(user2, key2); - }, new LuciaError("AUTH_DUPLICATE_KEY_ID")); - const storedUsers = await User.getAll(); - assert.deepStrictEqual(storedUsers, [user1]); - }); - }); - - await method("deleteUser()", async (test) => { - await test("Deletes target user", async () => { - const user1 = database.generateUser(); - const user2 = database.generateUser(); - await User.insert(user1, user2); - await adapter.deleteUser(user2.id); - const storedUsers = await User.getAll(); - assert.deepStrictEqual(storedUsers, [user1]); - }); - await test("Does not throw on invalid user id", async () => { - const user = database.generateUser(); - await adapter.deleteUser(user.id); - }); - }); - - await method("updateUser()", async (test) => { - await test("Updates user 'username' field", async () => { - const user = database.generateUser(); - await User.insert(user); - await adapter.updateUser(user.id, { - username: "Y" - }); - const updatedUser = { - ...user, - username: "Y" - } satisfies UserSchema; - const storedUser = await User.get(user.id); - assert.deepStrictEqual(storedUser, updatedUser); - }); - }); - - await method("getKey()", async (test) => { - await test("Returns target key", async () => { - const user = database.generateUser(); - const key = database.generateKey(user.id); - await User.insert(user); - await Key.insert(key); - const result = await adapter.getKey(key.id); - assert.deepStrictEqual(result, key); - }); - await test("Returns null if invalid target key id", async () => { - const result = await adapter.getKey("*"); - assert.deepStrictEqual(result, null); - }); - }); - - await method("setKey()", async (test) => { - await test("Inserts key", async () => { - const user = database.generateUser(); - const key = database.generateKey(user.id); - await User.insert(user); - await adapter.setKey(key); - const storedKey = await Key.get(key.id); - assert.deepStrictEqual(storedKey, key); - }); - await test("Throws AUTH_DUPLICATE_KEY_ID on duplicate key id", async () => { - const user = database.generateUser(); - const key1 = database.generateKey(user.id); - await User.insert(user); - await adapter.setKey(key1); - const key2 = database.generateKey(user.id, { - id: key1.id - }); - await assert.rejects(async () => { - await adapter.setKey(key2); - }, new LuciaError("AUTH_DUPLICATE_KEY_ID")); - }); - await test("Optionally throws AUTH_INVALID_USER_ID on invalid user id", async () => { - const key = database.generateKey(null); - try { - await adapter.setKey(key); - } catch (e) { - assert.deepStrictEqual(e, new LuciaError("AUTH_INVALID_USER_ID")); - } - }); - }); - - await method("updateKey", async (test) => { - await test("Updates key 'hashed_password' field", async () => { - const user = database.generateUser(); - const key = database.generateKey(user.id); - await User.insert(user); - await Key.insert(key); - await adapter.updateKey(key.id, { - hashed_password: "HASHED" - }); - const updatedKey = { - ...key, - hashed_password: "HASHED" - } satisfies KeySchema; - const storedKey = await Key.get(key.id); - assert.deepStrictEqual(storedKey, updatedKey); - }); - }); - - await method("getKeysByUserId()", async (test) => { - await test("Returns keys with target user id", async () => { - const user1 = database.generateUser(); - const user2 = database.generateUser(); - const key1 = database.generateKey(user1.id); - const key2 = database.generateKey(user2.id); - await User.insert(user1, user2); - await Key.insert(key1, key2); - const result = await adapter.getKeysByUserId(user1.id); - assert.deepStrictEqual(result, [key1]); - }); - await test("Returns an empty array if none matches target", async () => { - const user = database.generateUser(); - const key = database.generateKey(user.id); - await User.insert(user); - await Key.insert(key); - const result = await adapter.getKeysByUserId("*"); - assert.deepStrictEqual(result, []); - }); - }); - - await method("deleteKeysByUserId()", async (test) => { - await test("Deletes keys with target user id", async () => { - const user1 = database.generateUser(); - const user2 = database.generateUser(); - const key1 = database.generateKey(user1.id); - const key2 = database.generateKey(user2.id); - await User.insert(user1, user2); - await Key.insert(key1, key2); - await adapter.deleteKeysByUserId(user1.id); - const storedKeys = await Key.getAll(); - assert.deepStrictEqual(storedKeys, [key2]); - }); - await test("Does not throw on invalid user id", async () => { - const user = database.generateUser(); - await adapter.deleteKeysByUserId(user.id); - }); - }); - - await method("deleteKey()", async (test) => { - await test("Deletes target key", async () => { - const user = database.generateUser(); - const key1 = database.generateKey(user.id); - const key2 = database.generateKey(user.id); - await User.insert(user); - await Key.insert(key1); - await Key.insert(key2); - await adapter.deleteKey(key1.id); - const storedKeys = await Key.getAll(); - assert.deepStrictEqual(storedKeys, [key2]); - }); - await test("Does not throw on invalid key id", async () => { - const user = database.generateUser(); - const key = database.generateKey(user.id); - await adapter.deleteKey(key.id); - }); - }); - - await method("getSession()", async (test) => { - await test("Returns target session", async () => { - const user = database.generateUser(); - const session = database.generateSession(user.id); - await User.insert(user); - await Session.insert(session); - const sessionResult = await adapter.getSession(session.id); - assert.deepStrictEqual(sessionResult, session); - }); - await test("Returns null if invalid target session id", async () => { - const session = await adapter.getSession("*"); - assert.deepStrictEqual(session, null); - }); - }); - - await method("getSessionsByUserId()", async (test) => { - await test("Return sessions with target user id", async () => { - const user1 = database.generateUser(); - const user2 = database.generateUser(); - const session1 = database.generateSession(user1.id); - const session2 = database.generateSession(user2.id); - await User.insert(user1, user2); - await Session.insert(session1, session2); - const result = await adapter.getSessionsByUserId(user1.id); - assert.deepStrictEqual(result, [session1]); - }); - await test("Returns an empty array if none matches target", async () => { - const user = database.generateUser(); - const session = database.generateSession(user.id); - await User.insert(user); - await Session.insert(session); - const result = await adapter.getSessionsByUserId("*"); - assert.deepStrictEqual(result, []); - }); - }); - - await method("setSession()", async (test) => { - await test("Inserts session", async () => { - const user = database.generateUser(); - await User.insert(user); - const session = database.generateSession(user.id); - await adapter.setSession(session); - const storedSession = await Session.get(session.id); - assert.deepStrictEqual(storedSession, session); - }); - await test("Optionally throws AUTH_INVALID_USER_ID on invalid user id", async () => { - const session = database.generateSession(null); - try { - await adapter.setSession(session); - } catch (e) { - assert.deepStrictEqual(e, new LuciaError("AUTH_INVALID_USER_ID")); - } - }); - }); - - await method("deleteSession()", async (test) => { - await test("Deletes target session", async () => { - const user = database.generateUser(); - const session1 = database.generateSession(user.id); - const session2 = database.generateSession(user.id); - await User.insert(user); - await Session.insert(session1); - await Session.insert(session2); - await adapter.deleteSession(session1.id); - const storedSessions = await Session.getAll(); - assert.deepStrictEqual(storedSessions, [session2]); - }); - await test("Does not throw on invalid session id", async () => { - const user = database.generateUser(); - const session = database.generateSession(user.id); - await adapter.deleteSession(session.id); - }); - }); - - await method("deleteSessionsByUserId()", async (test) => { - await test("Deletes sessions with target user id", async () => { - const user1 = database.generateUser(); - const user2 = database.generateUser(); - const session1 = database.generateSession(user1.id); - const session2 = database.generateSession(user2.id); - await User.insert(user1, user2); - await Session.insert(session1, session2); - await adapter.deleteSessionsByUserId(user1.id); - const storedSessions = await Session.getAll(); - assert.deepStrictEqual(storedSessions, [session2]); - }); - await test("Does not throw on invalid user id", async () => { - const user = database.generateUser(); - await adapter.deleteSessionsByUserId(user.id); - }); - }); - - await method("updateSession()", async (test) => { - await test("Updates session 'country' field", async () => { - const user = database.generateUser(); - const session = database.generateSession(user.id); - await User.insert(user); - await Session.insert(session); - await adapter.updateSession(session.id, { - country: "YY" - }); - const expectedSession = { - ...session, - country: "YY" - } satisfies SessionSchema; - const storedSession = await Session.get(expectedSession.id); - assert.deepStrictEqual(storedSession, expectedSession); - }); - }); - - await method("getSessionAndUser()", async (test, skip) => { - if (!adapter.getSessionAndUser) return skip(); - await test("Returns target session and user", async () => { - if (!adapter.getSessionAndUser) return; - const user = database.generateUser(); - const session = database.generateSession(user.id); - await User.insert(user); - await Session.insert(session); - const [sessionResult, userResult] = await adapter.getSessionAndUser( - session.id - ); - assert.deepStrictEqual(sessionResult, session); - assert.deepStrictEqual(userResult, user); - }); - - await test("Returns null, null if invalid target session id", async () => { - if (!adapter.getSessionAndUser) return; - const [sessionResult, userResult] = await adapter.getSessionAndUser("*"); - assert.deepStrictEqual(sessionResult, null); - assert.deepStrictEqual(userResult, null); - }); - }); - - finish(); -}; diff --git a/packages/adapter-test/src/tests/session.ts b/packages/adapter-test/src/tests/session.ts deleted file mode 100644 index 7424317fe..000000000 --- a/packages/adapter-test/src/tests/session.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { start, finish, method, afterEach } from "../test.js"; -import assert from "node:assert/strict"; - -import type { SessionSchema, SessionAdapter } from "lucia"; -import type { Database } from "../database.js"; - -export const testSessionAdapter = async ( - adapter: SessionAdapter, - database: Database -) => { - const Session = database.session(); - - afterEach(database.clear); - - start(); - - await method("getSession()", async (test) => { - await test("Returns target session", async () => { - const session = database.generateSession(null); - await Session.insert(session); - const sessionResult = await adapter.getSession(session.id); - assert.deepStrictEqual(sessionResult, session); - }); - await test("Returns null if invalid target session id", async () => { - const session = await adapter.getSession("*"); - assert.deepStrictEqual(session, null); - }); - }); - - await method("getSessionsByUserId()", async (test) => { - await test("Return sessions with target user id", async () => { - const user1 = database.generateUser(); - const user2 = database.generateUser(); - const session1 = database.generateSession(user1.id); - const session2 = database.generateSession(user2.id); - await Session.insert(session1); - await Session.insert(session2); - const result = await adapter.getSessionsByUserId(user1.id); - assert.deepStrictEqual(result, [session1]); - }); - await test("Returns an empty array if none matches target", async () => { - const result = await adapter.getSessionsByUserId("*"); - assert.deepStrictEqual(result, []); - }); - }); - - await method("setSession()", async (test) => { - await test("Inserts session", async () => { - const session = database.generateSession(null); - await adapter.setSession(session); - const storedSession = await Session.get(session.id); - assert.deepStrictEqual(storedSession, session); - }); - }); - - await method("deleteSession()", async (test) => { - await test("Deletes target session", async () => { - const user = database.generateUser(); - const session1 = database.generateSession(user.id); - const session2 = database.generateSession(user.id); - await Session.insert(session1); - await Session.insert(session2); - await adapter.deleteSession(session1.id); - const storedSessions = await Session.getAll(); - assert.deepStrictEqual(storedSessions, [session2]); - }); - await test("Does not throw on invalid session id", async () => { - const user = database.generateUser(); - const session = database.generateSession(user.id); - await adapter.deleteSession(session.id); - }); - }); - - await method("deleteSessionsByUserId()", async (test) => { - await test("Deletes sessions with target user id", async () => { - const user1 = database.generateUser(); - const user2 = database.generateUser(); - const session1 = database.generateSession(user1.id); - const session2 = database.generateSession(user2.id); - await Session.insert(session1, session2); - await adapter.deleteSessionsByUserId(user1.id); - const storedSessions = await Session.getAll(); - assert.deepStrictEqual(storedSessions, [session2]); - }); - await test("Does not throw on invalid user id", async () => { - const user = database.generateUser(); - await adapter.deleteSessionsByUserId(user.id); - }); - }); - - await method("updateSession()", async (test) => { - await test("Updates session 'country' field", async () => { - const session = database.generateSession(null); - await Session.insert(session); - await adapter.updateSession(session.id, { - country: "YY" - }); - const expectedSession = { - ...session, - country: "YY" - } satisfies SessionSchema; - const storedSession = await Session.get(expectedSession.id); - assert.deepStrictEqual(storedSession, expectedSession); - }); - }); - - finish(); -}; diff --git a/packages/adapter-test/tsconfig.json b/packages/adapter-test/tsconfig.json index f2fb9daed..adaa062dd 100644 --- a/packages/adapter-test/tsconfig.json +++ b/packages/adapter-test/tsconfig.json @@ -1,11 +1,13 @@ { "compilerOptions": { + "baseUrl": ".", "strict": true, "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/lucia/.prettierignore b/packages/lucia/.prettierignore index 38972655f..d656f210f 100644 --- a/packages/lucia/.prettierignore +++ b/packages/lucia/.prettierignore @@ -6,6 +6,7 @@ node_modules .env .env.* !.env.example +dist # Ignore files for PNPM, NPM and YARN pnpm-lock.yaml diff --git a/packages/lucia/CHANGELOG.md b/packages/lucia/CHANGELOG.md index a620be955..7c95f0f38 100644 --- a/packages/lucia/CHANGELOG.md +++ b/packages/lucia/CHANGELOG.md @@ -1,10 +1,8 @@ # lucia -## 2.7.7 +## 3.0.0 -### Patch changes - -- [#1359](https://github.com/lucia-auth/lucia/pull/1359) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Allow `SameSite=None` session cookies +See the [migration guide](https://v3.lucia-auth.com/upgrade-v3). ## 2.7.6 diff --git a/packages/lucia/README.md b/packages/lucia/README.md index 9f37f5570..4a60202b6 100644 --- a/packages/lucia/README.md +++ b/packages/lucia/README.md @@ -1,14 +1,14 @@ # `lucia` -A simple authentication library for managing users and sessions. +An open source auth library that abstracts away the complexity of handling sessions. It works alongside your database to provide an API that's easy to use, understand, and extend. -**[Documentation](https://lucia-auth.com)** +**[Documentation](https://v3.lucia-auth.com)** **[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/lucia/CHANGELOG.md)** ## Installation -```bash +``` npm install lucia pnpm add lucia yarn add lucia diff --git a/packages/lucia/package.json b/packages/lucia/package.json index 2129d4257..f5325105e 100644 --- a/packages/lucia/package.json +++ b/packages/lucia/package.json @@ -1,6 +1,6 @@ { "name": "lucia", - "version": "2.7.7", + "version": "3.0.0", "description": "A simple and flexible authentication library", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -22,25 +22,6 @@ "authentication", "auth" ], - "exports": { - ".": "./dist/index.js", - "./middleware": "./dist/middleware/index.js", - "./polyfill/node": "./dist/polyfill/node.js", - "./utils": "./dist/utils/index.js" - }, - "typesVersions": { - "*": { - "middleware": [ - "dist/middleware/index.d.ts" - ], - "polyfill/node": [ - "dist/polyfill/node.d.ts" - ], - "utils": [ - "dist/utils/index.d.ts" - ] - } - }, "repository": { "type": "git", "url": "https://github.com/pilcrowOnPaper/lucia", @@ -52,5 +33,8 @@ "@types/node": "^18.6.2", "prettier": "^2.3.0", "vitest": "^0.33.0" + }, + "dependencies": { + "oslo": "1.0.1" } } diff --git a/packages/lucia/src/auth/adapter.ts b/packages/lucia/src/auth/adapter.ts deleted file mode 100644 index 3732cca2a..000000000 --- a/packages/lucia/src/auth/adapter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { LuciaError } from "./error.js"; - -import type { LuciaErrorConstructor } from "../index.js"; -import type { UserSchema, SessionSchema, KeySchema } from "./database.js"; - -export type InitializeAdapter< - T extends Adapter | UserAdapter | SessionAdapter -> = (E: LuciaErrorConstructor) => T; - -export type Adapter = Readonly< - { - getSessionAndUser?: ( - sessionId: string - ) => Promise<[SessionSchema, UserSchema] | [null, null]>; - } & SessionAdapter & - UserAdapter ->; - -export type UserAdapter = Readonly<{ - getUser: (userId: string) => Promise; - setUser: (user: UserSchema, key: KeySchema | null) => Promise; - updateUser: ( - userId: string, - partialUser: Partial - ) => Promise; - deleteUser: (userId: string) => Promise; - - getKey: (keyId: string) => Promise; - getKeysByUserId: (userId: string) => Promise; - setKey: (key: KeySchema) => Promise; - updateKey: (keyId: string, partialKey: Partial) => Promise; - deleteKey: (keyId: string) => Promise; - deleteKeysByUserId: (userId: string) => Promise; -}>; - -export type SessionAdapter = Readonly<{ - getSession: (sessionId: string) => Promise; - getSessionsByUserId: (userId: string) => Promise; - setSession: (session: SessionSchema) => Promise; - updateSession: ( - sessionId: string, - partialSession: Partial - ) => Promise; - deleteSession: (sessionId: string) => Promise; - deleteSessionsByUserId: (userId: string) => Promise; -}>; - -export const createAdapter = ( - adapter: - | InitializeAdapter - | { - user: InitializeAdapter; - session: InitializeAdapter; - } -): Adapter => { - if (!("user" in adapter)) return adapter(LuciaError); - let userAdapter = adapter.user(LuciaError); - let sessionAdapter = adapter.session(LuciaError); - - if ("getSessionAndUser" in userAdapter) { - const { getSessionAndUser: _, ...extractedUserAdapter } = userAdapter; - userAdapter = extractedUserAdapter; - } - - if ("getSessionAndUser" in sessionAdapter) { - const { getSessionAndUser: _, ...extractedSessionAdapter } = sessionAdapter; - sessionAdapter = extractedSessionAdapter; - } - return { - ...userAdapter, - ...sessionAdapter - }; -}; diff --git a/packages/lucia/src/auth/cookie.ts b/packages/lucia/src/auth/cookie.ts deleted file mode 100644 index 66815feaa..000000000 --- a/packages/lucia/src/auth/cookie.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { serializeCookie } from "../utils/cookie.js"; - -import type { Env, Session } from "./index.js"; -import type { CookieAttributes } from "../utils/cookie.js"; - -export const DEFAULT_SESSION_COOKIE_NAME = "auth_session"; - -type SessionCookieAttributes = { - sameSite?: "strict" | "lax" | "none"; - path?: string; - domain?: string; -}; - -export type SessionCookieConfiguration = { - name?: string; - attributes?: SessionCookieAttributes; - expires?: boolean; -}; - -const defaultSessionCookieAttributes: SessionCookieAttributes = { - sameSite: "lax", - path: "/" -}; - -export const createSessionCookie = ( - session: Session | null, - options: { env: Env; cookie: SessionCookieConfiguration } -): Cookie => { - let expires: number; - if (session === null) { - expires = 0; - } else if (options.cookie.expires !== false) { - expires = session.idlePeriodExpiresAt.getTime(); - } else { - expires = Date.now() + 1000 * 60 * 60 * 24 * 365; // + 1 year - } - return new Cookie( - options.cookie.name ?? DEFAULT_SESSION_COOKIE_NAME, - session?.sessionId ?? "", - { - ...(options.cookie.attributes ?? defaultSessionCookieAttributes), - httpOnly: true, - expires: new Date(expires), - secure: options.env === "PROD" - } - ); -}; - -export class Cookie { - constructor(name: string, value: string, options: CookieAttributes) { - this.name = name; - this.value = value; - this.attributes = options; - } - public readonly name: string; - public readonly value: string; - public readonly attributes: CookieAttributes; - public readonly serialize = () => { - return serializeCookie(this.name, this.value, this.attributes); - }; -} diff --git a/packages/lucia/src/auth/database.ts b/packages/lucia/src/auth/database.ts deleted file mode 100644 index e4e099694..000000000 --- a/packages/lucia/src/auth/database.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type KeySchema = { - id: string; - hashed_password: string | null; - user_id: string; -}; - -export type UserSchema = { - id: string; -} & Lucia.DatabaseUserAttributes; - -export type SessionSchema = { - id: string; - active_expires: number; - idle_expires: number; - user_id: string; -} & Lucia.DatabaseSessionAttributes; - -export const createKeyId = (providerId: string, providerUserId: string) => { - if (providerId.includes(":")) { - throw new TypeError("Provider id must not include any colons (:)"); - } - return `${providerId}:${providerUserId}`; -}; diff --git a/packages/lucia/src/auth/error.ts b/packages/lucia/src/auth/error.ts deleted file mode 100644 index 25a8f5475..000000000 --- a/packages/lucia/src/auth/error.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class LuciaError extends Error { - constructor(errorMsg: ErrorMessage, detail?: string) { - super(errorMsg); - this.message = errorMsg; - this.detail = detail ?? ""; - } - public detail: string; - public message: ErrorMessage; -} - -type Constructor any> = new ( - ...args: ConstructorParameters -) => InstanceType; - -export type LuciaErrorConstructor = Constructor; - -export type ErrorMessage = - | "AUTH_INVALID_SESSION_ID" - | "AUTH_INVALID_PASSWORD" - | "AUTH_DUPLICATE_KEY_ID" - | "AUTH_INVALID_KEY_ID" - | "AUTH_INVALID_USER_ID" - | "AUTH_INVALID_REQUEST" - | "AUTH_NOT_AUTHENTICATED" - | "REQUEST_UNAUTHORIZED" - | "UNKNOWN_ERROR" - | "AUTH_OUTDATED_PASSWORD"; diff --git a/packages/lucia/src/auth/index.ts b/packages/lucia/src/auth/index.ts deleted file mode 100644 index e951528c9..000000000 --- a/packages/lucia/src/auth/index.ts +++ /dev/null @@ -1,690 +0,0 @@ -import { DEFAULT_SESSION_COOKIE_NAME, createSessionCookie } from "./cookie.js"; -import { logError } from "../utils/log.js"; -import { generateScryptHash, validateScryptHash } from "../utils/crypto.js"; -import { generateRandomString } from "../utils/crypto.js"; -import { LuciaError } from "./error.js"; -import { parseCookie } from "../utils/cookie.js"; -import { isValidDatabaseSession } from "./session.js"; -import { AuthRequest, transformRequestContext } from "./request.js"; -import { lucia as defaultMiddleware } from "../middleware/index.js"; -import { debug } from "../utils/debug.js"; -import { isWithinExpiration } from "../utils/date.js"; -import { createAdapter } from "./adapter.js"; -import { createKeyId } from "./database.js"; -import { isAllowedOrigin, safeParseUrl } from "../utils/url.js"; - -import type { Cookie, SessionCookieConfiguration } from "./cookie.js"; -import type { UserSchema, SessionSchema, KeySchema } from "./database.js"; -import type { Adapter, SessionAdapter, InitializeAdapter } from "./adapter.js"; -import type { CSRFProtectionConfiguration, Middleware } from "./request.js"; - -export type Session = Readonly<{ - user: User; - sessionId: string; - activePeriodExpiresAt: Date; - idlePeriodExpiresAt: Date; - state: "idle" | "active"; - fresh: boolean; -}> & - ReturnType; - -export type Key = Readonly<{ - userId: string; - providerId: string; - providerUserId: string; - passwordDefined: boolean; -}>; - -export type Env = "DEV" | "PROD"; - -export type User = { - userId: string; -} & ReturnType; - -export const lucia = <_Configuration extends Configuration>( - config: _Configuration -) => { - return new Auth(config); -}; - -const validateConfiguration = (config: Configuration) => { - const adapterProvided = config.adapter; - if (!adapterProvided) { - logError('Adapter is not defined in configuration ("config.adapter")'); - process.exit(1); - } -}; - -export class Auth<_Configuration extends Configuration = any> { - private adapter: Adapter; - private sessionCookieConfig: SessionCookieConfiguration; - private sessionExpiresIn: { - activePeriod: number; - idlePeriod: number; - }; - private csrfProtection: CSRFProtectionConfiguration | boolean; - private env: Env; - private passwordHash: { - generate: (s: string) => MaybePromise; - validate: (s: string, hash: string) => MaybePromise; - } = { - generate: generateScryptHash, - validate: validateScryptHash - }; - protected middleware: _Configuration["middleware"] extends Middleware - ? _Configuration["middleware"] - : ReturnType = defaultMiddleware(); - - private experimental: { - debugMode: boolean; - }; - - constructor(config: _Configuration) { - validateConfiguration(config); - - this.adapter = createAdapter(config.adapter); - this.env = config.env; - this.sessionExpiresIn = { - activePeriod: - config.sessionExpiresIn?.activePeriod ?? 1000 * 60 * 60 * 24, - idlePeriod: - config.sessionExpiresIn?.idlePeriod ?? 1000 * 60 * 60 * 24 * 14 - }; - - this.getUserAttributes = (databaseUser) => { - const defaultTransform = () => { - return {} as any; - }; - const transform = config.getUserAttributes ?? defaultTransform; - return transform(databaseUser); - }; - this.getSessionAttributes = (databaseSession) => { - const defaultTransform = () => { - return {} as any; - }; - const transform = config.getSessionAttributes ?? defaultTransform; - return transform(databaseSession); - }; - this.csrfProtection = config.csrfProtection ?? true; - this.sessionCookieConfig = config.sessionCookie ?? {}; - if (config.passwordHash) { - this.passwordHash = config.passwordHash; - } - if (config.middleware) { - this.middleware = config.middleware; - } - this.experimental = { - debugMode: config.experimental?.debugMode ?? false - }; - - debug.init(this.experimental.debugMode); - } - - protected getUserAttributes: ( - databaseUser: UserSchema - ) => _Configuration extends Configuration - ? _UserAttributes - : never; - - protected getSessionAttributes: ( - databaseSession: SessionSchema - ) => _Configuration extends Configuration - ? _SessionAttributes - : never; - - public transformDatabaseUser = (databaseUser: UserSchema): User => { - const attributes = this.getUserAttributes(databaseUser); - return { - ...attributes, - userId: databaseUser.id - }; - }; - - public transformDatabaseKey = (databaseKey: KeySchema): Key => { - const [providerId, ...providerUserIdSegments] = databaseKey.id.split(":"); - const providerUserId = providerUserIdSegments.join(":"); - const userId = databaseKey.user_id; - const isPasswordDefined = !!databaseKey.hashed_password; - return { - providerId, - providerUserId, - userId, - passwordDefined: isPasswordDefined - }; - }; - - public transformDatabaseSession = ( - databaseSession: SessionSchema, - context: { - user: User; - fresh: boolean; - } - ): Session => { - const attributes = this.getSessionAttributes(databaseSession); - const active = isWithinExpiration(databaseSession.active_expires); - return { - ...attributes, - user: context.user, - sessionId: databaseSession.id, - activePeriodExpiresAt: new Date(Number(databaseSession.active_expires)), - idlePeriodExpiresAt: new Date(Number(databaseSession.idle_expires)), - state: active ? "active" : "idle", - fresh: context.fresh - }; - }; - - private getDatabaseUser = async (userId: string): Promise => { - const databaseUser = await this.adapter.getUser(userId); - if (!databaseUser) { - throw new LuciaError("AUTH_INVALID_USER_ID"); - } - return databaseUser; - }; - - private getDatabaseSession = async ( - sessionId: string - ): Promise => { - const databaseSession = await this.adapter.getSession(sessionId); - if (!databaseSession) { - debug.session.fail("Session not found", sessionId); - throw new LuciaError("AUTH_INVALID_SESSION_ID"); - } - if (!isValidDatabaseSession(databaseSession)) { - debug.session.fail( - `Session expired at ${new Date(Number(databaseSession.idle_expires))}`, - sessionId - ); - throw new LuciaError("AUTH_INVALID_SESSION_ID"); - } - return databaseSession; - }; - - private getDatabaseSessionAndUser = async ( - sessionId: string - ): Promise<[SessionSchema, UserSchema]> => { - if (this.adapter.getSessionAndUser) { - const [databaseSession, databaseUser] = - await this.adapter.getSessionAndUser(sessionId); - - if (!databaseSession) { - debug.session.fail("Session not found", sessionId); - throw new LuciaError("AUTH_INVALID_SESSION_ID"); - } - - if (!isValidDatabaseSession(databaseSession)) { - debug.session.fail( - `Session expired at ${new Date( - Number(databaseSession.idle_expires) - )}`, - sessionId - ); - throw new LuciaError("AUTH_INVALID_SESSION_ID"); - } - - return [databaseSession, databaseUser]; - } - const databaseSession = await this.getDatabaseSession(sessionId); - const databaseUser = await this.getDatabaseUser(databaseSession.user_id); - return [databaseSession, databaseUser]; - }; - - private validateSessionIdArgument = (sessionId: string) => { - if (!sessionId) { - debug.session.fail("Empty session id"); - throw new LuciaError("AUTH_INVALID_SESSION_ID"); - } - }; - - private getNewSessionExpiration = (sessionExpiresIn?: { - activePeriod: number; - idlePeriod: number; - }): { - activePeriodExpiresAt: Date; - idlePeriodExpiresAt: Date; - } => { - const activePeriodExpiresAt = new Date( - new Date().getTime() + - (sessionExpiresIn?.activePeriod ?? this.sessionExpiresIn.activePeriod) - ); - const idlePeriodExpiresAt = new Date( - activePeriodExpiresAt.getTime() + - (sessionExpiresIn?.idlePeriod ?? this.sessionExpiresIn.idlePeriod) - ); - return { activePeriodExpiresAt, idlePeriodExpiresAt }; - }; - - public getUser = async (userId: string): Promise => { - const databaseUser = await this.getDatabaseUser(userId); - const user = this.transformDatabaseUser(databaseUser); - return user; - }; - - public createUser = async (options: { - userId?: string; - key: { - providerId: string; - providerUserId: string; - password: string | null; - } | null; - attributes: Lucia.DatabaseUserAttributes; - }): Promise => { - const userId = options.userId ?? generateRandomString(15); - const userAttributes = options.attributes ?? {}; - const databaseUser = { - ...userAttributes, - id: userId - } satisfies UserSchema; - if (options.key === null) { - await this.adapter.setUser(databaseUser, null); - return this.transformDatabaseUser(databaseUser); - } - const keyId = createKeyId( - options.key.providerId, - options.key.providerUserId - ); - const password = options.key.password; - const hashedPassword = - password === null ? null : await this.passwordHash.generate(password); - await this.adapter.setUser(databaseUser, { - id: keyId, - user_id: userId, - hashed_password: hashedPassword - }); - return this.transformDatabaseUser(databaseUser); - }; - - public updateUserAttributes = async ( - userId: string, - attributes: Partial - ): Promise => { - await this.adapter.updateUser(userId, attributes); - return await this.getUser(userId); - }; - - public deleteUser = async (userId: string): Promise => { - await this.adapter.deleteSessionsByUserId(userId); - await this.adapter.deleteKeysByUserId(userId); - await this.adapter.deleteUser(userId); - }; - - public useKey = async ( - providerId: string, - providerUserId: string, - password: string | null - ): Promise => { - const keyId = createKeyId(providerId, providerUserId); - const databaseKey = await this.adapter.getKey(keyId); - if (!databaseKey) { - debug.key.fail("Key not found", keyId); - throw new LuciaError("AUTH_INVALID_KEY_ID"); - } - const hashedPassword = databaseKey.hashed_password; - if (hashedPassword !== null) { - debug.key.info("Key includes password"); - if (!password) { - debug.key.fail("Key password not provided", keyId); - throw new LuciaError("AUTH_INVALID_PASSWORD"); - } - const validPassword = await this.passwordHash.validate( - password, - hashedPassword - ); - if (!validPassword) { - debug.key.fail("Incorrect key password", password); - throw new LuciaError("AUTH_INVALID_PASSWORD"); - } - debug.key.notice("Validated key password"); - } else { - if (password !== null) { - debug.key.fail("Incorrect key password", password); - throw new LuciaError("AUTH_INVALID_PASSWORD"); - } - debug.key.info("No password included in key"); - } - debug.key.success("Validated key", keyId); - return this.transformDatabaseKey(databaseKey); - }; - - public getSession = async (sessionId: string): Promise => { - this.validateSessionIdArgument(sessionId); - const [databaseSession, databaseUser] = - await this.getDatabaseSessionAndUser(sessionId); - const user = this.transformDatabaseUser(databaseUser); - return this.transformDatabaseSession(databaseSession, { - user, - fresh: false - }); - }; - - public getAllUserSessions = async (userId: string): Promise => { - const [user, databaseSessions] = await Promise.all([ - this.getUser(userId), - await this.adapter.getSessionsByUserId(userId) - ]); - const validStoredUserSessions = databaseSessions - .filter((databaseSession) => { - return isValidDatabaseSession(databaseSession); - }) - .map((databaseSession) => { - return this.transformDatabaseSession(databaseSession, { - user, - fresh: false - }); - }); - return validStoredUserSessions; - }; - - public validateSession = async (sessionId: string): Promise => { - this.validateSessionIdArgument(sessionId); - const [databaseSession, databaseUser] = - await this.getDatabaseSessionAndUser(sessionId); - const user = this.transformDatabaseUser(databaseUser); - const session = this.transformDatabaseSession(databaseSession, { - user, - fresh: false - }); - if (session.state === "active") { - debug.session.success("Validated session", session.sessionId); - return session; - } - const { activePeriodExpiresAt, idlePeriodExpiresAt } = - this.getNewSessionExpiration(); - await this.adapter.updateSession(session.sessionId, { - active_expires: activePeriodExpiresAt.getTime(), - idle_expires: idlePeriodExpiresAt.getTime() - }); - const renewedDatabaseSession: Session = { - ...session, - idlePeriodExpiresAt, - activePeriodExpiresAt, - fresh: true - }; - return renewedDatabaseSession; - }; - - public createSession = async (options: { - sessionId?: string; - userId: string; - attributes: Lucia.DatabaseSessionAttributes; - }): Promise => { - const { activePeriodExpiresAt, idlePeriodExpiresAt } = - this.getNewSessionExpiration(); - const userId = options.userId; - const sessionId = options?.sessionId ?? generateRandomString(40); - const attributes = options.attributes; - const databaseSession = { - ...attributes, - id: sessionId, - user_id: userId, - active_expires: activePeriodExpiresAt.getTime(), - idle_expires: idlePeriodExpiresAt.getTime() - } satisfies SessionSchema; - const [user] = await Promise.all([ - this.getUser(userId), - this.adapter.setSession(databaseSession) - ]); - return this.transformDatabaseSession(databaseSession, { - user, - fresh: false - }); - }; - - public updateSessionAttributes = async ( - sessionId: string, - attributes: Partial - ): Promise => { - this.validateSessionIdArgument(sessionId); - await this.adapter.updateSession(sessionId, attributes); - return this.getSession(sessionId); - }; - - public invalidateSession = async (sessionId: string): Promise => { - this.validateSessionIdArgument(sessionId); - await this.adapter.deleteSession(sessionId); - debug.session.notice("Invalidated session", sessionId); - }; - - public invalidateAllUserSessions = async (userId: string): Promise => { - await this.adapter.deleteSessionsByUserId(userId); - }; - - public deleteDeadUserSessions = async (userId: string): Promise => { - const databaseSessions = await this.adapter.getSessionsByUserId(userId); - const deadSessionIds = databaseSessions - .filter((databaseSession) => { - return !isValidDatabaseSession(databaseSession); - }) - .map((databaseSession) => databaseSession.id); - await Promise.all( - deadSessionIds.map((deadSessionId) => { - this.adapter.deleteSession(deadSessionId); - }) - ); - }; - - /** - * @deprecated To be removed in next major release - */ - public validateRequestOrigin = (request: { - url: string | null; - method: string | null; - headers: { - origin: string | null; - }; - }): void => { - if (request.method === null) { - debug.request.fail("Request method unavailable"); - throw new LuciaError("AUTH_INVALID_REQUEST"); - } - if (request.url === null) { - debug.request.fail("Request url unavailable"); - throw new LuciaError("AUTH_INVALID_REQUEST"); - } - if ( - request.method.toUpperCase() !== "GET" && - request.method.toUpperCase() !== "HEAD" - ) { - const requestOrigin = request.headers.origin; - if (!requestOrigin) { - debug.request.fail("No request origin available"); - throw new LuciaError("AUTH_INVALID_REQUEST"); - } - try { - const url = safeParseUrl(request.url); - const allowedSubDomains = - typeof this.csrfProtection === "object" - ? this.csrfProtection.allowedSubDomains ?? [] - : []; - if ( - url === null || - !isAllowedOrigin(requestOrigin, url.origin, allowedSubDomains) - ) { - throw new LuciaError("AUTH_INVALID_REQUEST"); - } - debug.request.info("Valid request origin", requestOrigin); - } catch { - debug.request.fail("Invalid origin string", requestOrigin); - // failed to parse url - throw new LuciaError("AUTH_INVALID_REQUEST"); - } - } else { - debug.request.notice("Skipping CSRF check"); - } - }; - - public readSessionCookie = ( - cookieHeader: string | null | undefined - ): string | null => { - if (!cookieHeader) { - debug.request.info("No session cookie found"); - return null; - } - const cookies = parseCookie(cookieHeader); - const sessionCookieName = - this.sessionCookieConfig.name ?? DEFAULT_SESSION_COOKIE_NAME; - const sessionId = cookies[sessionCookieName] ?? null; - if (sessionId) { - debug.request.info("Found session cookie", sessionId); - } else { - debug.request.info("No session cookie found"); - } - return sessionId; - }; - - public readBearerToken = ( - authorizationHeader: string | null | undefined - ): string | null => { - if (!authorizationHeader) { - debug.request.info("No token found in authorization header"); - return null; - } - const [authScheme, token] = authorizationHeader.split(" ") as [ - string, - string | undefined - ]; - if (authScheme !== "Bearer") { - debug.request.fail( - "Invalid authorization header auth scheme", - authScheme - ); - return null; - } - return token ?? null; - }; - - public handleRequest = ( - // cant reference middleware type with Lucia.Auth - ...args: Auth<_Configuration>["middleware"] extends Middleware - ? Args - : never - ): AuthRequest => { - const middleware = this.middleware as Middleware; - const sessionCookieName = - this.sessionCookieConfig.name ?? DEFAULT_SESSION_COOKIE_NAME; - return new AuthRequest(this, { - csrfProtection: this.csrfProtection, - requestContext: transformRequestContext( - middleware({ - args, - env: this.env, - sessionCookieName: sessionCookieName - }) - ) - }); - }; - - public createSessionCookie = (session: Session | null): Cookie => { - return createSessionCookie(session, { - env: this.env, - cookie: this.sessionCookieConfig - }); - }; - - public createKey = async (options: { - userId: string; - providerId: string; - providerUserId: string; - password: string | null; - }): Promise => { - const keyId = createKeyId(options.providerId, options.providerUserId); - let hashedPassword: string | null = null; - if (options.password !== null) { - hashedPassword = await this.passwordHash.generate(options.password); - } - const userId = options.userId; - await this.adapter.setKey({ - id: keyId, - user_id: userId, - hashed_password: hashedPassword - }); - return { - providerId: options.providerId, - providerUserId: options.providerUserId, - passwordDefined: !!options.password, - userId - } satisfies Key as any; - }; - - public deleteKey = async ( - providerId: string, - providerUserId: string - ): Promise => { - const keyId = createKeyId(providerId, providerUserId); - await this.adapter.deleteKey(keyId); - }; - - public getKey = async ( - providerId: string, - providerUserId: string - ): Promise => { - const keyId = createKeyId(providerId, providerUserId); - const databaseKey = await this.adapter.getKey(keyId); - if (!databaseKey) { - throw new LuciaError("AUTH_INVALID_KEY_ID"); - } - const key = this.transformDatabaseKey(databaseKey); - return key; - }; - - public getAllUserKeys = async (userId: string): Promise => { - const [databaseKeys] = await Promise.all([ - await this.adapter.getKeysByUserId(userId), - this.getUser(userId) - ]); - return databaseKeys.map((databaseKey) => - this.transformDatabaseKey(databaseKey) - ); - }; - - public updateKeyPassword = async ( - providerId: string, - providerUserId: string, - password: string | null - ): Promise => { - const keyId = createKeyId(providerId, providerUserId); - const hashedPassword = - password === null ? null : await this.passwordHash.generate(password); - await this.adapter.updateKey(keyId, { - hashed_password: hashedPassword - }); - return await this.getKey(providerId, providerUserId); - }; -} - -type MaybePromise = T | Promise; - -export type Configuration< - _UserAttributes extends Record = {}, - _SessionAttributes extends Record = {} -> = { - adapter: - | InitializeAdapter - | { - user: InitializeAdapter; - session: InitializeAdapter; - }; - env: Env; - - middleware?: Middleware; - csrfProtection?: - | boolean - | { - host?: string; - hostHeader?: string; - allowedSubDomains?: string[] | "*"; - }; - sessionExpiresIn?: { - activePeriod: number; - idlePeriod: number; - }; - sessionCookie?: SessionCookieConfiguration; - getSessionAttributes?: (databaseSession: SessionSchema) => _SessionAttributes; - getUserAttributes?: (databaseUser: UserSchema) => _UserAttributes; - passwordHash?: { - generate: (password: string) => MaybePromise; - validate: (password: string, hash: string) => MaybePromise; - }; - experimental?: { - debugMode?: boolean; - }; -}; diff --git a/packages/lucia/src/auth/request.ts b/packages/lucia/src/auth/request.ts deleted file mode 100644 index 8ed120ae2..000000000 --- a/packages/lucia/src/auth/request.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { debug } from "../utils/debug.js"; - -import { LuciaError } from "./error.js"; -import { createHeadersFromObject } from "../utils/request.js"; -import { isAllowedOrigin, safeParseUrl } from "../utils/url.js"; - -import type { Auth, Env, Session } from "./index.js"; -import type { Cookie } from "./cookie.js"; - -export type LuciaRequest = { - method: string; - url?: string; - headers: Pick; -}; -export type RequestContext = { - sessionCookie?: string | null; - request: LuciaRequest; - setCookie: (cookie: Cookie) => void; -}; - -export type Middleware = (context: { - args: Args; - env: Env; - sessionCookieName: string; -}) => MiddlewareRequestContext; - -type MiddlewareRequestContext = Omit & { - sessionCookie?: string | null; - request: { - method: string; - url?: string; - headers: - | Pick - | { - origin: string | null; - cookie: string | null; - authorization: string | null; - }; // remove regular object: v3 - storedSessionCookie?: string | null; // remove: v3 - }; - setCookie: (cookie: Cookie) => void; -}; - -export type CSRFProtectionConfiguration = { - host?: string; - hostHeader?: string; - allowedSubDomains?: string[] | "*"; -}; - -export class AuthRequest<_Auth extends Auth = any> { - private auth: _Auth; - private requestContext: RequestContext; - - constructor( - auth: _Auth, - config: { - requestContext: RequestContext; - csrfProtection: boolean | CSRFProtectionConfiguration; - } - ) { - debug.request.init( - config.requestContext.request.method, - config.requestContext.request.url ?? "(url unknown)" - ); - this.auth = auth; - this.requestContext = config.requestContext; - - const csrfProtectionConfig = - typeof config.csrfProtection === "object" ? config.csrfProtection : {}; - const csrfProtectionEnabled = config.csrfProtection !== false; - - if ( - !csrfProtectionEnabled || - this.isValidRequestOrigin(csrfProtectionConfig) - ) { - this.storedSessionId = - this.requestContext.sessionCookie ?? - auth.readSessionCookie( - this.requestContext.request.headers.get("Cookie") - ); - } else { - this.storedSessionId = null; - } - this.bearerToken = auth.readBearerToken( - this.requestContext.request.headers.get("Authorization") - ); - } - - private validatePromise: Promise | null = null; - private validateBearerTokenPromise: Promise | null = null; - private storedSessionId: string | null; - private bearerToken: string | null; - - public setSession = (session: Session | null) => { - const sessionId = session?.sessionId ?? null; - if (this.storedSessionId === sessionId) return; - this.validatePromise = null; - this.setSessionCookie(session); - }; - - private maybeSetSession = (session: Session | null) => { - try { - this.setSession(session); - } catch { - // ignore error - // some middleware throw error - } - }; - - private setSessionCookie = (session: Session | null) => { - const sessionId = session?.sessionId ?? null; - if (this.storedSessionId === sessionId) return; - this.storedSessionId = sessionId; - this.requestContext.setCookie(this.auth.createSessionCookie(session)); - if (session) { - debug.request.notice("Session cookie stored", session.sessionId); - } else { - debug.request.notice("Session cookie deleted"); - } - }; - - public validate = async (): Promise => { - if (this.validatePromise) { - debug.request.info("Using cached result for session validation"); - return this.validatePromise; - } - this.validatePromise = new Promise(async (resolve, reject) => { - if (!this.storedSessionId) return resolve(null); - try { - const session = await this.auth.validateSession(this.storedSessionId); - if (session.fresh) { - this.maybeSetSession(session); - } - return resolve(session); - } catch (e) { - if ( - e instanceof LuciaError && - e.message === "AUTH_INVALID_SESSION_ID" - ) { - this.maybeSetSession(null); - return resolve(null); - } - return reject(e); - } - }); - - return await this.validatePromise; - }; - - public validateBearerToken = async (): Promise => { - if (this.validateBearerTokenPromise) { - debug.request.info("Using cached result for bearer token validation"); - return this.validatePromise; - } - this.validatePromise = new Promise(async (resolve, reject) => { - if (!this.bearerToken) return resolve(null); - try { - const session = await this.auth.validateSession(this.bearerToken); - return resolve(session); - } catch (e) { - if (e instanceof LuciaError) { - return resolve(null); - } - return reject(e); - } - }); - - return await this.validatePromise; - }; - - public invalidate(): void { - this.validatePromise = null; - this.validateBearerTokenPromise = null; - } - - private isValidRequestOrigin = ( - config: CSRFProtectionConfiguration - ): boolean => { - const request = this.requestContext.request; - const whitelist = ["GET", "HEAD", "OPTIONS", "TRACE"]; - if (whitelist.some((val) => val === request.method.toUpperCase())) { - return true; - } - const requestOrigin = request.headers.get("Origin"); - if (!requestOrigin) { - debug.request.fail("No request origin available"); - return false; - } - let host: string | null = null; - if (config.host !== undefined) { - host = config.host ?? null; - } else if (request.url !== null && request.url !== undefined) { - host = safeParseUrl(request.url)?.host ?? null; - } else { - host = request.headers.get(config.hostHeader ?? "Host"); - } - debug.request.info("Host", host ?? "(Host unknown)"); - if ( - host !== null && - isAllowedOrigin(requestOrigin, host, config.allowedSubDomains ?? []) - ) { - debug.request.info("Valid request origin", requestOrigin); - return true; - } - debug.request.info("Invalid request origin", requestOrigin); - return false; - }; -} - -export const transformRequestContext = ({ - request, - setCookie, - sessionCookie -}: MiddlewareRequestContext): RequestContext => { - return { - request: { - url: request.url, - method: request.method, - headers: - "authorization" in request.headers - ? createHeadersFromObject(request.headers) - : request.headers - }, - setCookie, - sessionCookie: sessionCookie ?? request.storedSessionCookie - }; -}; diff --git a/packages/lucia/src/auth/session.test.ts b/packages/lucia/src/auth/session.test.ts deleted file mode 100644 index 0fa9567f1..000000000 --- a/packages/lucia/src/auth/session.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from "vitest"; -import { isValidDatabaseSession } from "./session.js"; - -test("isValidDatabaseSession() returns false if dead state", async () => { - const output = isValidDatabaseSession({ - id: "", - idle_expires: new Date().getTime() - 10 * 1000, - active_expires: new Date().getTime(), - user_id: "" - }); - expect(output).toBe(false); -}); diff --git a/packages/lucia/src/auth/session.ts b/packages/lucia/src/auth/session.ts deleted file mode 100644 index 253d56af2..000000000 --- a/packages/lucia/src/auth/session.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { isWithinExpiration } from "../utils/date.js"; - -import type { SessionSchema } from "./database.js"; - -export const isValidDatabaseSession = ( - databaseSession: SessionSchema -): boolean => { - return isWithinExpiration(databaseSession.idle_expires); -}; diff --git a/packages/lucia/src/core.ts b/packages/lucia/src/core.ts new file mode 100644 index 000000000..144412c37 --- /dev/null +++ b/packages/lucia/src/core.ts @@ -0,0 +1,226 @@ +import { TimeSpan, createDate, isWithinExpirationDate } from "oslo"; +import { generateId } from "./crypto.js"; +import { CookieController } from "oslo/cookie"; + +import type { Cookie } from "oslo/cookie"; +import type { Adapter } from "./database.js"; +import type { + RegisteredDatabaseSessionAttributes, + RegisteredDatabaseUserAttributes, + RegisteredLucia +} from "./index.js"; +import { CookieAttributes } from "oslo/cookie"; + +type SessionAttributes = RegisteredLucia extends Lucia + ? _SessionAttributes + : {}; + +type UserAttributes = RegisteredLucia extends Lucia + ? _UserAttributes + : {}; + +export interface Session extends SessionAttributes { + id: string; + expiresAt: Date; + fresh: boolean; + userId: string; +} + +export interface User extends UserAttributes { + id: string; +} + +export class Lucia< + _SessionAttributes extends {} = Record, + _UserAttributes extends {} = Record +> { + private adapter: Adapter; + private sessionExpiresIn: TimeSpan; + private sessionCookieController: CookieController; + + private getSessionAttributes: ( + databaseSessionAttributes: RegisteredDatabaseSessionAttributes + ) => _SessionAttributes; + + private getUserAttributes: ( + databaseUserAttributes: RegisteredDatabaseUserAttributes + ) => _UserAttributes; + + public readonly sessionCookieName: string; + + constructor( + adapter: Adapter, + options?: { + sessionExpiresIn?: TimeSpan; + sessionCookie?: SessionCookieOptions; + getSessionAttributes?: ( + databaseSessionAttributes: RegisteredDatabaseSessionAttributes + ) => _SessionAttributes; + getUserAttributes?: ( + databaseUserAttributes: RegisteredDatabaseUserAttributes + ) => _UserAttributes; + } + ) { + this.adapter = adapter; + + // we have to use `any` here since TS can't do conditional return types + this.getUserAttributes = (databaseUserAttributes): any => { + if (options && options.getUserAttributes) { + return options.getUserAttributes(databaseUserAttributes); + } + return {}; + }; + this.getSessionAttributes = (databaseSessionAttributes): any => { + if (options && options.getSessionAttributes) { + return options.getSessionAttributes(databaseSessionAttributes); + } + return {}; + }; + this.sessionExpiresIn = options?.sessionExpiresIn ?? new TimeSpan(30, "d"); + this.sessionCookieName = options?.sessionCookie?.name ?? "auth_session"; + let sessionCookieExpiresIn = this.sessionExpiresIn; + if (options?.sessionCookie?.expires === false) { + sessionCookieExpiresIn = new TimeSpan(365 * 2, "d"); + } + const baseSessionCookieAttributes: CookieAttributes = { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + ...options?.sessionCookie?.attributes + }; + this.sessionCookieController = new CookieController( + this.sessionCookieName, + baseSessionCookieAttributes, + { + expiresIn: sessionCookieExpiresIn + } + ); + } + + public async getUserSessions(userId: string): Promise { + const databaseSessions = await this.adapter.getUserSessions(userId); + const sessions: Session[] = []; + for (const databaseSession of databaseSessions) { + if (!isWithinExpirationDate(databaseSession.expiresAt)) { + continue; + } + sessions.push({ + id: databaseSession.id, + expiresAt: databaseSession.expiresAt, + userId: databaseSession.userId, + fresh: false, + ...this.getSessionAttributes(databaseSession.attributes) + }); + } + return sessions; + } + + public async validateSession( + sessionId: string + ): Promise<{ user: User; session: Session } | { user: null; session: null }> { + const [databaseSession, databaseUser] = await this.adapter.getSessionAndUser(sessionId); + if (!databaseSession) { + return { session: null, user: null }; + } + if (!databaseUser) { + await this.adapter.deleteSession(databaseSession.id); + return { session: null, user: null }; + } + if (!isWithinExpirationDate(databaseSession.expiresAt)) { + await this.adapter.deleteSession(databaseSession.id); + return { session: null, user: null }; + } + const activePeriodExpirationDate = new Date( + databaseSession.expiresAt.getTime() - this.sessionExpiresIn.milliseconds() / 2 + ); + const session: Session = { + ...this.getSessionAttributes(databaseSession.attributes), + id: databaseSession.id, + userId: databaseSession.userId, + fresh: false, + expiresAt: databaseSession.expiresAt + }; + if (!isWithinExpirationDate(activePeriodExpirationDate)) { + session.fresh = true; + session.expiresAt = createDate(this.sessionExpiresIn); + await this.adapter.updateSessionExpiration(databaseSession.id, session.expiresAt); + } + const user: User = { + ...this.getUserAttributes(databaseUser.attributes), + id: databaseUser.id + }; + return { user, session }; + } + + public async createSession( + userId: string, + attributes: RegisteredDatabaseSessionAttributes, + options?: { + sessionId?: string; + } + ): Promise { + const sessionId = options?.sessionId ?? generateId(40); + const sessionExpiresAt = createDate(this.sessionExpiresIn); + await this.adapter.setSession({ + id: sessionId, + userId, + expiresAt: sessionExpiresAt, + attributes + }); + const session: Session = { + id: sessionId, + userId, + fresh: true, + expiresAt: sessionExpiresAt, + ...this.getSessionAttributes(attributes) + }; + return session; + } + + public async invalidateSession(sessionId: string): Promise { + await this.adapter.deleteSession(sessionId); + } + + public async invalidateUserSessions(userId: string): Promise { + await this.adapter.deleteUserSessions(userId); + } + + public async deleteExpiredSessions(): Promise { + await this.adapter.deleteExpiredSessions(); + } + + public readSessionCookie(cookieHeader: string): string | null { + const sessionId = this.sessionCookieController.parse(cookieHeader); + return sessionId; + } + + public readBearerToken(authorizationHeader: string): string | null { + const [authScheme, token] = authorizationHeader.split(" ") as [string, string | undefined]; + if (authScheme !== "Bearer") { + return null; + } + return token ?? null; + } + + public createSessionCookie(sessionId: string): Cookie { + return this.sessionCookieController.createCookie(sessionId); + } + + public createBlankSessionCookie(): Cookie { + return this.sessionCookieController.createBlankCookie(); + } +} + +export interface SessionCookieOptions { + name?: string; + expires?: boolean; + attributes?: SessionCookieAttributesOptions; +} + +export interface SessionCookieAttributesOptions { + sameSite?: "lax" | "strict"; + domain?: string; + path?: string; + secure?: boolean; +} diff --git a/packages/lucia/src/crypto.test.ts b/packages/lucia/src/crypto.test.ts new file mode 100644 index 000000000..c51335183 --- /dev/null +++ b/packages/lucia/src/crypto.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from "vitest"; +import { Scrypt } from "./crypto.js"; +import { encodeHex } from "oslo/encoding"; + +test("validateScryptHash() validates hashes generated with generateScryptHash()", async () => { + const password = encodeHex(crypto.getRandomValues(new Uint8Array(32))); + const scrypt = new Scrypt(); + const hash = await scrypt.hash(password); + await expect(scrypt.verify(hash, password)).resolves.toBe(true); + const falsePassword = encodeHex(crypto.getRandomValues(new Uint8Array(32))); + await expect(scrypt.verify(hash, falsePassword)).resolves.toBe(false); +}); diff --git a/packages/lucia/src/crypto.ts b/packages/lucia/src/crypto.ts new file mode 100644 index 000000000..5c7e58439 --- /dev/null +++ b/packages/lucia/src/crypto.ts @@ -0,0 +1,63 @@ +import { encodeHex, decodeHex } from "oslo/encoding"; +import { constantTimeEqual, generateRandomString, alphabet } from "oslo/crypto"; +import { scrypt } from "./scrypt/index.js"; + +import type { PasswordHashingAlgorithm } from "oslo/password"; + +export type { PasswordHashingAlgorithm } from "oslo/password"; + +async function generateScryptKey(data: string, salt: string, blockSize = 16): Promise { + const encodedData = new TextEncoder().encode(data); + const encodedSalt = new TextEncoder().encode(salt); + const keyUint8Array = await scrypt(encodedData, encodedSalt, { + N: 16384, + r: blockSize, + p: 1, + dkLen: 64 + }); + return keyUint8Array; +} + +export function generateId(length: number): string { + return generateRandomString(length, alphabet("0-9", "a-z")); +} + +export class Scrypt implements PasswordHashingAlgorithm { + async hash(password: string): Promise { + const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16))); + const key = await generateScryptKey(password.normalize("NFKC"), salt); + return `${salt}:${encodeHex(key)}`; + } + async verify(hash: string, password: string): Promise { + const parts = hash.split(":"); + if (parts.length !== 2) return false; + + const [salt, key] = parts; + const targetKey = await generateScryptKey(password.normalize("NFKC"), salt); + return constantTimeEqual(targetKey, decodeHex(key)); + } +} + +export class LegacyScrypt implements PasswordHashingAlgorithm { + async hash(password: string): Promise { + const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16))); + const key = await generateScryptKey(password.normalize("NFKC"), salt); + return `${salt}:${encodeHex(key)}`; + } + async verify(hash: string, password: string): Promise { + const parts = hash.split(":"); + if (parts.length === 2) { + const [salt, key] = parts; + const targetKey = await generateScryptKey(password.normalize("NFKC"), salt, 8); + const result = constantTimeEqual(targetKey, decodeHex(key)); + return result; + } + if (parts.length !== 3) return false; + const [version, salt, key] = parts; + if (version === "s2") { + const targetKey = await generateScryptKey(password.normalize("NFKC"), salt); + return constantTimeEqual(targetKey, decodeHex(key)); + } + return false; + } +} diff --git a/packages/lucia/src/database.ts b/packages/lucia/src/database.ts new file mode 100644 index 000000000..bb8221e66 --- /dev/null +++ b/packages/lucia/src/database.ts @@ -0,0 +1,28 @@ +import type { + RegisteredDatabaseSessionAttributes, + RegisteredDatabaseUserAttributes +} from "./index.js"; + +export interface Adapter { + getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]>; + getUserSessions(userId: string): Promise; + setSession(session: DatabaseSession): Promise; + updateSessionExpiration(sessionId: string, expiresAt: Date): Promise; + deleteSession(sessionId: string): Promise; + deleteUserSessions(userId: string): Promise; + deleteExpiredSessions(): Promise; +} + +export interface DatabaseUser { + id: string; + attributes: RegisteredDatabaseUserAttributes; +} + +export interface DatabaseSession { + userId: string; + expiresAt: Date; + id: string; + attributes: RegisteredDatabaseSessionAttributes; +} diff --git a/packages/lucia/src/index.ts b/packages/lucia/src/index.ts index dc119ea2b..7fddb567a 100644 --- a/packages/lucia/src/index.ts +++ b/packages/lucia/src/index.ts @@ -1,31 +1,39 @@ -export { lucia } from "./auth/index.js"; -export { DEFAULT_SESSION_COOKIE_NAME } from "./auth/cookie.js"; -export { LuciaError } from "./auth/error.js"; -export { createKeyId } from "./auth/database.js"; - -export type GlobalAuth = Lucia.Auth; -export type GlobalDatabaseUserAttributes = Lucia.DatabaseUserAttributes; -export type GlobalDatabaseSessionAttributes = Lucia.DatabaseSessionAttributes; +export { Lucia } from "./core.js"; +export { Scrypt, LegacyScrypt, generateId } from "./crypto.js"; +export { TimeSpan } from "oslo"; +export { Cookie } from "oslo/cookie"; +export { verifyRequestOrigin } from "oslo/request"; export type { User, - Key, Session, - Configuration, - Env, - Auth -} from "./auth/index.js"; -export type { - Adapter, - InitializeAdapter, - UserAdapter, - SessionAdapter -} from "./auth/adapter.js"; -export type { UserSchema, KeySchema, SessionSchema } from "./auth/database.js"; -export type { - RequestContext, - Middleware, - AuthRequest -} from "./auth/request.js"; -export type { Cookie } from "./auth/cookie.js"; -export type { LuciaErrorConstructor } from "./auth/error.js"; + SessionCookieOptions, + SessionCookieAttributesOptions +} from "./core.js"; +export type { DatabaseSession, DatabaseUser, Adapter } from "./database.js"; +export type { PasswordHashingAlgorithm } from "./crypto.js"; +export type { CookieAttributes } from "oslo/cookie"; + +import type { Lucia } from "./core.js"; + +export interface Register {} + +export type RegisteredLucia = Register extends { + Lucia: infer _Lucia; +} + ? _Lucia extends Lucia + ? _Lucia + : Lucia + : Lucia; + +export type RegisteredDatabaseUserAttributes = Register extends { + DatabaseUserAttributes: infer _DatabaseUserAttributes; +} + ? _DatabaseUserAttributes + : {}; + +export type RegisteredDatabaseSessionAttributes = Register extends { + DatabaseSessionAttributes: infer _DatabaseSessionAttributes; +} + ? _DatabaseSessionAttributes + : {}; diff --git a/packages/lucia/src/lucia.d.ts b/packages/lucia/src/lucia.d.ts deleted file mode 100644 index 1a70ea048..000000000 --- a/packages/lucia/src/lucia.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare namespace Lucia { - export type DatabaseUserAttributes = {}; - export type DatabaseSessionAttributes = {}; - export class Auth extends (await import("./auth/index.js")).Auth {} -} diff --git a/packages/lucia/src/middleware/index.ts b/packages/lucia/src/middleware/index.ts deleted file mode 100644 index 2ce7f131c..000000000 --- a/packages/lucia/src/middleware/index.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { createHeadersFromObject } from "../utils/request.js"; - -import type { CookieAttributes } from "../utils/cookie.js"; -import type { - LuciaRequest, - Middleware, - RequestContext -} from "../auth/request.js"; - -type NodeIncomingMessage = { - method?: string; - headers: Record; -}; - -type NodeOutGoingMessage = { - getHeader: (name: string) => string | string[] | number | undefined; - setHeader: (name: string, value: string | number | readonly string[]) => void; -}; - -export const node = (): Middleware< - [NodeIncomingMessage, NodeOutGoingMessage] -> => { - return ({ args }) => { - const [incomingMessage, outgoingMessage] = args; - const requestContext = { - request: { - method: incomingMessage.method ?? "", - headers: createHeadersFromObject(incomingMessage.headers) - }, - setCookie: (cookie) => { - let parsedSetCookieHeaderValues: string[] = []; - const setCookieHeaderValue = outgoingMessage.getHeader("Set-Cookie"); - if (typeof setCookieHeaderValue === "string") { - parsedSetCookieHeaderValues = [setCookieHeaderValue]; - } else if (Array.isArray(setCookieHeaderValue)) { - parsedSetCookieHeaderValues = setCookieHeaderValue; - } - outgoingMessage.setHeader("Set-Cookie", [ - cookie.serialize(), - ...parsedSetCookieHeaderValues - ]); - } - } as const satisfies RequestContext; - - return requestContext; - }; -}; - -type ExpressRequest = { - method: string; - headers: Record; -}; - -type ExpressResponse = { - cookie: (name: string, val: string, options?: CookieAttributes) => void; -}; - -export const express = (): Middleware<[ExpressRequest, ExpressResponse]> => { - return ({ args }) => { - const [req, res] = args; - const requestContext = { - request: { - method: req.method, - headers: createHeadersFromObject(req.headers) - }, - setCookie: (cookie) => { - const cookieMaxAge = cookie.attributes.maxAge; - res.cookie(cookie.name, cookie.value, { - ...cookie.attributes, - maxAge: cookieMaxAge ? cookieMaxAge * 1000 : cookieMaxAge - }); - } - } as const satisfies RequestContext; - return requestContext; - }; -}; - -type FastifyRequest = { - method: string; - headers: Record; -}; - -type FastifyReply = { - header: (name: string, val: any) => void; -}; - -export const fastify = (): Middleware<[FastifyRequest, FastifyReply]> => { - return ({ args }) => { - const [req, res] = args; - const requestContext = { - request: { - method: req.method, - headers: createHeadersFromObject(req.headers) - }, - setCookie: (cookie) => { - res.header("Set-Cookie", [cookie.serialize()]); - } - } as const satisfies RequestContext; - return requestContext; - }; -}; - -type SvelteKitRequestEvent = { - request: Request; - cookies: { - set: ( - name: string, - value: string, - options: CookieAttributes & { path: string } - ) => void; - get: (name: string) => string | undefined; - }; -}; - -export const sveltekit = (): Middleware<[SvelteKitRequestEvent]> => { - return ({ args, sessionCookieName }) => { - const [event] = args; - const requestContext = { - request: event.request, - sessionCookie: event.cookies.get(sessionCookieName) ?? null, - setCookie: (cookie) => { - event.cookies.set(cookie.name, cookie.value, { - path: ".", - ...cookie.attributes - }); - } - } as const satisfies RequestContext; - - return requestContext; - }; -}; - -type AstroAPIContext = { - request: Request; - cookies: { - set: (name: string, value: string, options?: CookieAttributes) => void; - get: (name: string) => - | { - value: string | undefined; - } - | undefined; - }; -}; - -export const astro = (): Middleware<[AstroAPIContext]> => { - return ({ args, sessionCookieName }) => { - const [context] = args; - const requestContext = { - request: context.request, - sessionCookie: context.cookies.get(sessionCookieName)?.value || null, - setCookie: (cookie) => { - context.cookies.set(cookie.name, cookie.value, cookie.attributes); - } - } as const satisfies RequestContext; - - return requestContext; - }; -}; - -type QwikRequestEvent = { - request: Request; - cookie: { - set: (name: string, value: string, options?: CookieAttributes) => void; - get: (key: string) => { - value: string; - } | null; - }; -}; - -export const qwik = (): Middleware<[QwikRequestEvent]> => { - return ({ args, sessionCookieName }) => { - const [event] = args; - const requestContext = { - request: event.request, - sessionCookie: event.cookie.get(sessionCookieName)?.value ?? null, - setCookie: (cookie) => { - event.cookie.set(cookie.name, cookie.value, cookie.attributes); - } - } as const satisfies RequestContext; - return requestContext; - }; -}; - -type ElysiaContext = { - request: Request; - set: { - headers: Record & { - ["Set-Cookie"]?: string | string[]; - }; - }; -}; - -export const elysia = (): Middleware<[ElysiaContext]> => { - return ({ args }) => { - const [{ request, set }] = args; - return { - request, - setCookie: (cookie) => { - const setCookieHeader = set.headers["Set-Cookie"] ?? []; - const setCookieHeaders: string[] = Array.isArray(setCookieHeader) - ? setCookieHeader - : [setCookieHeader]; - setCookieHeaders.push(cookie.serialize()); - set.headers["Set-Cookie"] = setCookieHeaders; - } - }; - }; -}; - -export const lucia = (): Middleware<[RequestContext]> => { - return ({ args }) => args[0]; -}; - -export const web = (): Middleware<[Request]> => { - return ({ args }) => { - const [request] = args; - const requestContext = { - request, - setCookie: () => { - throw new Error( - "Cookies cannot be set when using the `web()` middleware" - ); - } - } as const satisfies RequestContext; - return requestContext; - }; -}; - -type NextJsPagesServerContext = { - req: NodeIncomingMessage; - res?: NodeOutGoingMessage; -}; - -type NextCookie = - | { - name: string; - value: string; - } - | undefined; - -type NextCookiesFunction = () => { - set: (name: string, value: string, options: CookieAttributes) => void; - get: (name: string) => NextCookie; -}; - -type NextHeadersFunction = () => { - get: (name: string) => string | null; -}; - -type NextRequest = Request & { - cookies: { - get: (name: string) => NextCookie; - }; -}; - -type NextJsAppServerContext = { - cookies: NextCookiesFunction; - request: NextRequest | null; -}; - -export const nextjs = (): Middleware< - [NextJsPagesServerContext | NextJsAppServerContext | NextRequest] -> => { - return ({ args, sessionCookieName, env }) => { - const [serverContext] = args; - - if ("cookies" in serverContext) { - // for some reason `"request" in NextRequest` returns true??? - const request = - typeof serverContext.cookies === "function" - ? (serverContext as NextJsAppServerContext).request - : (serverContext as NextRequest); - - const readonlyCookieStore = - typeof serverContext.cookies === "function" - ? serverContext.cookies() - : serverContext.cookies; - - const sessionCookie = - readonlyCookieStore.get(sessionCookieName)?.value ?? null; - const requestContext = { - request: request ?? { - method: "GET", - headers: new Headers() - }, - sessionCookie, - setCookie: (cookie) => { - if (typeof serverContext.cookies !== "function") return; - const cookieStore = serverContext.cookies(); - if (!cookieStore.set) return; - try { - cookieStore.set(cookie.name, cookie.value, cookie.attributes); - } catch { - // ignore - set() is not available - } - } - } as const satisfies RequestContext; - return requestContext; - } - - const req = "req" in serverContext ? serverContext.req : serverContext; - const res = "res" in serverContext ? serverContext.res : null; - - const request = { - method: req.method ?? "", - headers: createHeadersFromObject(req.headers) - } satisfies LuciaRequest; - - return { - request, - setCookie: (cookie) => { - if (!res) return; - const setCookieHeaderValues = - res - .getHeader("Set-Cookie") - ?.toString() - .split(",") - .filter((val) => val) ?? []; - res.setHeader("Set-Cookie", [ - cookie.serialize(), - ...setCookieHeaderValues - ]); - } - }; - }; -}; - -type NextJsAppServerContext_V3 = { - headers: NextHeadersFunction; - cookies: NextCookiesFunction; -}; - -export const nextjs_future = (): Middleware< - | [NextJsPagesServerContext] - | [NextRequest] - | [requestMethod: string, context: NextJsAppServerContext_V3] -> => { - return ({ args, sessionCookieName }) => { - if (args.length === 2) { - const [requestMethod, context] = args; - return { - request: { - method: requestMethod, - headers: context.headers() - }, - setCookie: (cookie) => { - try { - context.cookies().set(cookie.name, cookie.value, cookie.attributes); - } catch { - // ignore error - // can't differentiate between page.tsx render (can't set cookies) - // vs API routes (can set cookies) - } - }, - sessionCookie: context.cookies().get(sessionCookieName)?.value ?? null - }; - } - if ("req" in args[0]) { - const [{ req, res }] = args; - return { - request: { - method: req.method ?? "", - headers: createHeadersFromObject(req.headers) - }, - setCookie: (cookie) => { - if (!res) return; - const setCookieHeaderValues = - res - .getHeader("Set-Cookie") - ?.toString() - .split(",") - .filter((val) => val) ?? []; - res.setHeader("Set-Cookie", [ - cookie.serialize(), - ...setCookieHeaderValues - ]); - } - }; - } - const [request] = args; - return { - request, - setCookie: () => { - throw new Error( - "Cookies cannot be set when using the `web()` middleware" - ); - }, - sessionCookie: request.cookies.get(sessionCookieName)?.value ?? null - }; - }; -}; - -type H3Event = { - node: { - req: NodeIncomingMessage; - res: NodeOutGoingMessage; - }; -}; - -export const h3 = (): Middleware<[H3Event]> => { - const nodeMiddleware = node(); - - return ({ args, sessionCookieName, env }) => { - const [context] = args; - return nodeMiddleware({ - args: [context.node.req, context.node.res], - sessionCookieName, - env - }); - }; -}; - -type HonoContext = { - req: { - url: string; - method: string; - headers: Headers; - }; - header: (name: string, value: string) => void; -}; - -export const hono = (): Middleware<[HonoContext]> => { - return ({ args }) => { - const [context] = args; - return { - request: context.req, - setCookie: (cookie) => { - context.header("Set-Cookie", cookie.serialize()); - } - }; - }; -}; diff --git a/packages/lucia/src/polyfill/node.ts b/packages/lucia/src/polyfill/node.ts deleted file mode 100644 index 9700f6b03..000000000 --- a/packages/lucia/src/polyfill/node.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { webcrypto } from "crypto"; - -const isObject = ( - maybeObject: unknown -): maybeObject is Record => { - return ( - maybeObject !== null && - typeof maybeObject === "object" && - !Array.isArray(maybeObject) - ); -}; - -const _global = globalThis as { - crypto?: unknown; -}; - -const polyfillCrypto = () => { - if (!("crypto" in _global)) { - _global.crypto = webcrypto as any; - return; - } - if (!isObject(_global.crypto)) { - _global.crypto = webcrypto as any; - return; - } - if (Object.isFrozen(_global.crypto)) { - _global.crypto = webcrypto as any; - return; - } - const getRandomValuesDefined = - "getRandomValues" in _global.crypto && - _global.crypto.getRandomValues !== undefined; - const randomUUIDDefined = - "randomUUID" in _global.crypto && _global.crypto.randomUUID !== undefined; - const subtleDefined = - "subtle" in _global.crypto && _global.crypto.subtle !== undefined; - if (!getRandomValuesDefined) { - _global.crypto.getRandomValues = webcrypto.getRandomValues as any; - } - if (!randomUUIDDefined) { - _global.crypto.randomUUID = webcrypto.randomUUID as any; - } - if (!subtleDefined) { - _global.crypto.subtle = webcrypto.subtle as any; - } -}; - -polyfillCrypto(); diff --git a/packages/lucia/src/scrypt/index.test.ts b/packages/lucia/src/scrypt/index.test.ts index 625eb4888..b0e19c5e2 100644 --- a/packages/lucia/src/scrypt/index.test.ts +++ b/packages/lucia/src/scrypt/index.test.ts @@ -1,11 +1,12 @@ import { expect, test } from "vitest"; import { scrypt } from "./index.js"; import { scryptSync as nodeScrypt } from "node:crypto"; -import { generateRandomString } from "../utils/crypto.js"; +import { generateRandomString, alphabet } from "oslo/crypto"; +import { encodeHex } from "oslo/encoding"; test("scrypt() output matches crypto", async () => { - const password = generateRandomString(16); - const salt = generateRandomString(16); + const password = generateRandomString(16, alphabet("a-z", "A-Z", "0-9")); + const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16))); const scryptHash = await scrypt( new TextEncoder().encode(password), new TextEncoder().encode(salt), diff --git a/packages/lucia/src/scrypt/index.ts b/packages/lucia/src/scrypt/index.ts index 73c361673..4b7caad91 100644 --- a/packages/lucia/src/scrypt/index.ts +++ b/packages/lucia/src/scrypt/index.ts @@ -1,19 +1,44 @@ -export const scrypt = async ( +/* +The MIT License (MIT) + +Copyright (c) 2022 Paul Miller (https://paulmillr.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +export async function scrypt( password: Uint8Array, salt: Uint8Array, - options: ScryptOptions -): Promise => { + options: { + N: number; + r: number; + p: number; + dkLen?: number; + maxmem?: number; + } +): Promise { const { N, r, p } = options; const dkLen = options.dkLen ?? 32; const maxmem = 1024 ** 3 + 1024; const blockSize = 128 * r; const blockSize32 = blockSize / 4; - if ( - N <= 1 || - (N & (N - 1)) !== 0 || - N >= 2 ** (blockSize / 8) || - N > 2 ** 32 - ) { + if (N <= 1 || (N & (N - 1)) !== 0 || N >= 2 ** (blockSize / 8) || N > 2 ** 32) { throw new Error( "Scrypt: N must be larger than 1, a power of 2, less than 2^(128 * r / 8) and less than 2^32" ); @@ -62,18 +87,20 @@ export const scrypt = async ( V.fill(0); tmp.fill(0); return res; -}; +} -const rotl = (a: number, b: number): number => (a << b) | (a >>> (32 - b)); +function rotl(a: number, b: number): number { + return (a << b) | (a >>> (32 - b)); +} -const XorAndSalsa = ( +function XorAndSalsa( prev: Uint32Array, pi: number, input: Uint32Array, ii: number, out: Uint32Array, oi: number -): void => { +): void { const y00 = prev[pi++] ^ input[ii++], y01 = prev[pi++] ^ input[ii++]; const y02 = prev[pi++] ^ input[ii++], @@ -156,23 +183,17 @@ const XorAndSalsa = ( out[oi++] = (y13 + x13) | 0; out[oi++] = (y14 + x14) | 0; out[oi++] = (y15 + x15) | 0; -}; +} -const pbkdf2 = async ( +async function pbkdf2( password: Uint8Array, salt: Uint8Array, options: { c: number; dkLen: number; } -): Promise => { - const pwKey = await crypto.subtle.importKey( - "raw", - password, - "PBKDF2", - false, - ["deriveBits"] - ); +): Promise { + const pwKey = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveBits"]); const keyBuffer = await crypto.subtle.deriveBits( { name: "PBKDF2", @@ -184,15 +205,9 @@ const pbkdf2 = async ( options.dkLen * 8 ); return new Uint8Array(keyBuffer); -}; +} -const BlockMix = ( - input: Uint32Array, - ii: number, - out: Uint32Array, - oi: number, - r: number -): void => { +function BlockMix(input: Uint32Array, ii: number, out: Uint32Array, oi: number, r: number): void { let head = oi + 0; let tail = oi + 16 * r; for (let i = 0; i < 16; i++) out[tail + i] = input[ii + (2 * r - 1) * 16 + i]; @@ -201,44 +216,8 @@ const BlockMix = ( if (i > 0) tail += 16; XorAndSalsa(out, head, input, (ii += 16), out, tail); } -}; - -const u32 = (arr: Uint8Array): Uint32Array => { - return new Uint32Array( - arr.buffer, - arr.byteOffset, - Math.floor(arr.byteLength / 4) - ); -}; - -type ScryptOptions = { - N: number; - r: number; - p: number; - dkLen?: number; - maxmem?: number; -}; - -/* -The MIT License (MIT) - -Copyright (c) 2022 Paul Miller (https://paulmillr.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the “Software”), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +} -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ +function u32(arr: Uint8Array): Uint32Array { + return new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4)); +} diff --git a/packages/lucia/src/utils/adapter.ts b/packages/lucia/src/utils/adapter.ts deleted file mode 100644 index 01e1e5595..000000000 --- a/packages/lucia/src/utils/adapter.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - Adapter, - InitializeAdapter, - SessionAdapter, - UserAdapter -} from "../index.js"; - -export const joinAdapters = ( - baseAdapter: InitializeAdapter, - ...adapters: Array< - Partial | InitializeAdapter - > -): InitializeAdapter => { - return (LuciaError) => { - return Object.assign( - // start with the baseAdapter - baseAdapter(LuciaError), - // merge in the partial adapters - ...adapters.map((adapter) => { - if (typeof adapter === "function") return adapter(LuciaError); - return adapter; - }) - ); - }; -}; diff --git a/packages/lucia/src/utils/cookie.ts b/packages/lucia/src/utils/cookie.ts deleted file mode 100644 index 79e1ef92b..000000000 --- a/packages/lucia/src/utils/cookie.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* -code from https://github.com/jshttp/cookie - -the library this code is from is a commonjs library, -which some frameworks don't support (eg. Astro) - -also required an external library for ts support -*/ - -export const parseCookie = (str: string): Record => { - const obj: Record = {}; - let index = 0; - while (index < str.length) { - const eqIdx = str.indexOf("=", index); - if (eqIdx === -1) { - break; - } - let endIdx = str.indexOf(";", index); - if (endIdx === -1) { - endIdx = str.length; - } else if (endIdx < eqIdx) { - index = str.lastIndexOf(";", eqIdx - 1) + 1; - continue; - } - const key = str.slice(index, eqIdx).trim(); - // only assign once - if (!(key in obj)) { - let val = str.slice(eqIdx + 1, endIdx).trim(); - // quoted values - if (val.charCodeAt(0) === 0x22) { - val = val.slice(1, -1); - } - obj[key] = tryDecode(val); - } - index = endIdx + 1; - } - return obj; -}; - -export type CookieAttributes = Partial<{ - domain: string; - encode: (value: string) => string; - expires: Date; - httpOnly: boolean; - maxAge: number; - path: string; - priority: "low" | "medium" | "high"; - sameSite: true | false | "lax" | "strict" | "none"; - secure: boolean; -}>; - -type CookieSerializeOptions = CookieAttributes; - -export const serializeCookie = ( - name: string, - val: string, - options?: CookieSerializeOptions -): string => { - const opt = options ?? {}; - const enc = opt.encode ?? encodeURIComponent; - const value = enc(val); - let str = name + "=" + value; - - if (null != opt.maxAge) { - const maxAge = opt.maxAge - 0; - if (isNaN(maxAge) || !isFinite(maxAge)) { - throw new TypeError("option maxAge is invalid"); - } - - str += "; Max-Age=" + Math.floor(maxAge); - } - if (opt.domain) { - str += "; Domain=" + opt.domain; - } - if (opt.path) { - str += "; Path=" + opt.path; - } - if (opt.expires) { - const expires = opt.expires; - str += "; Expires=" + expires.toUTCString(); - } - if (opt.httpOnly) { - str += "; HttpOnly"; - } - if (opt.secure) { - str += "; Secure"; - } - if (opt.priority) { - const priority = - typeof opt.priority === "string" - ? opt.priority.toLowerCase() - : opt.priority; - switch (priority) { - case "low": - str += "; Priority=Low"; - break; - case "medium": - str += "; Priority=Medium"; - break; - case "high": - str += "; Priority=High"; - break; - default: - throw new TypeError("option priority is invalid"); - } - } - if (opt.sameSite) { - const sameSite = - typeof opt.sameSite === "string" - ? opt.sameSite.toLowerCase() - : opt.sameSite; - switch (sameSite) { - case true: - str += "; SameSite=Strict"; - break; - case "lax": - str += "; SameSite=Lax"; - break; - case "strict": - str += "; SameSite=Strict"; - break; - case "none": - str += "; SameSite=None"; - break; - default: - throw new TypeError("option sameSite is invalid"); - } - } - return str; -}; - -const tryDecode = (str: string) => { - try { - return decodeURIComponent(str); - } catch (e) { - return str; - } -}; diff --git a/packages/lucia/src/utils/crypto.test.ts b/packages/lucia/src/utils/crypto.test.ts deleted file mode 100644 index c8350d392..000000000 --- a/packages/lucia/src/utils/crypto.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test, expect } from "vitest"; -import { - convertUint8ArrayToHex, - generateScryptHash, - validateScryptHash, - generateRandomString -} from "./crypto.js"; - -test("convertUint8ArrayToHex() output matches Buffer.toString()", async () => { - const testUint8Array = crypto.getRandomValues(new Uint8Array(16)); - const output = convertUint8ArrayToHex(testUint8Array); - const nodeOutput = Buffer.from(testUint8Array).toString("hex"); - expect(output).toBe(nodeOutput); -}); - -test("validateScryptHash() validates hashes generated with generateScryptHash()", async () => { - const password = generateRandomString(16); - const hash = await generateScryptHash(password); - expect(await validateScryptHash(password, hash)).toBeTruthy(); - const falsePassword = generateRandomString(16); - expect(await validateScryptHash(falsePassword, hash)).toBeFalsy(); -}); diff --git a/packages/lucia/src/utils/crypto.ts b/packages/lucia/src/utils/crypto.ts deleted file mode 100644 index f5327d7d2..000000000 --- a/packages/lucia/src/utils/crypto.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { LuciaError } from "../auth/error.js"; -import { scrypt } from "../scrypt/index.js"; - -const DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyz1234567890"; - -export const generateRandomString = ( - length: number, - alphabet: string = DEFAULT_ALPHABET -) => { - const randomUint32Values = new Uint32Array(length); - crypto.getRandomValues(randomUint32Values); - const u32Max = 0xffffffff; - let result = ""; - for (let i = 0; i < randomUint32Values.length; i++) { - const rand = randomUint32Values[i] / (u32Max + 1); - result += alphabet[Math.floor(alphabet.length * rand)]; - } - return result; -}; - -export const generateScryptHash = async (s: string): Promise => { - const salt = generateRandomString(16); - const key = await hashWithScrypt(s.normalize("NFKC"), salt); - return `s2:${salt}:${key}`; -}; - -const hashWithScrypt = async ( - s: string, - salt: string, - blockSize = 16 -): Promise => { - const keyUint8Array = await scrypt( - new TextEncoder().encode(s), - new TextEncoder().encode(salt), - { - N: 16384, - r: blockSize, - p: 1, - dkLen: 64 - } - ); - return convertUint8ArrayToHex(keyUint8Array); -}; - -export const validateScryptHash = async ( - s: string, - hash: string -): Promise => { - // detect bcrypt hash - // lucia used bcrypt in one of the beta versions - // TODO: remove in v3 - if (hash.startsWith("$2a")) { - throw new LuciaError("AUTH_OUTDATED_PASSWORD"); - } - const arr = hash.split(":"); - if (arr.length === 2) { - const [salt, key] = arr; - const targetKey = await hashWithScrypt(s.normalize("NFKC"), salt, 8); - const result = constantTimeEqual(targetKey, key); - return result; - } - if (arr.length !== 3) return false; - const [version, salt, key] = arr; - if (version === "s2") { - const targetKey = await hashWithScrypt(s.normalize("NFKC"), salt); - const result = constantTimeEqual(targetKey, key); - return result; - } - return false; -}; - -const constantTimeEqual = (a: string, b: string): boolean => { - if (a.length !== b.length) { - return false; - } - const aUint8Array = new TextEncoder().encode(a); - const bUint8Array = new TextEncoder().encode(b); - - let c = 0; - for (let i = 0; i < a.length; i++) { - c |= aUint8Array[i] ^ bUint8Array[i]; // ^: XOR operator - } - return c === 0; -}; - -export const convertUint8ArrayToHex = (arr: Uint8Array): string => { - return [...arr].map((x) => x.toString(16).padStart(2, "0")).join(""); -}; diff --git a/packages/lucia/src/utils/date.test.ts b/packages/lucia/src/utils/date.test.ts deleted file mode 100644 index 7529c287f..000000000 --- a/packages/lucia/src/utils/date.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { test, expect } from "vitest"; -import { isWithinExpiration } from "./date.js"; - -test("isWithinExpiration()", async () => { - const futureTime = new Date().getTime() + 10 * 1000; - expect(isWithinExpiration(futureTime)).toBeTruthy(); - const pastTime = new Date().getTime() - 10 * 1000; - expect(isWithinExpiration(pastTime)).toBeFalsy(); -}); diff --git a/packages/lucia/src/utils/date.ts b/packages/lucia/src/utils/date.ts deleted file mode 100644 index c278c6035..000000000 --- a/packages/lucia/src/utils/date.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getTimeAfterSeconds = (seconds: number): number => { - return new Date().getTime() + 1000 * seconds; -}; - -export const isWithinExpiration = (expiresInMs: number | bigint): boolean => { - const currentTime = Date.now(); - if (currentTime > expiresInMs) return false; - return true; -}; diff --git a/packages/lucia/src/utils/debug.test.ts b/packages/lucia/src/utils/debug.test.ts deleted file mode 100644 index 6b5063180..000000000 --- a/packages/lucia/src/utils/debug.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { test } from "vitest"; -import { debug, enableDebugMode } from "./debug.js"; - -test("log format", async () => { - enableDebugMode(); - debug.request.init("get", "http://localhost:3000"); - debug.request.info("Incoming session cookie", "123456"); - debug.request.notice("Skipping CSRF check"); - debug.session.success("Validated session", "123456"); - debug.session.fail("Failed to validate session", "123456"); - debug.key.success("Validated password", "123456"); - debug.key.fail("Failed to validate password", "123456"); -}); diff --git a/packages/lucia/src/utils/debug.ts b/packages/lucia/src/utils/debug.ts deleted file mode 100644 index 0e4ef3d86..000000000 --- a/packages/lucia/src/utils/debug.ts +++ /dev/null @@ -1,148 +0,0 @@ -const DEBUG_GLOBAL = "__lucia_debug_mode"; - -const ESCAPE = "\x1B"; -const DEFAULT_TEXT_FORMAT = "\x1B[0m"; -const DEFAULT_FG_BG = `${ESCAPE}[0m`; -const RED_CODE = 9; -const LUCIA_COLOR_CODE = 63; -const WHITE_CODE = 231; -const GREEN_CODE = 34; -const CYAN_CODE = 6; -const YELLOW_CODE = 3; -const PURPLE_CODE = 5; -const BLUE_CODE = 4; - -const globalContext = globalThis as { - [K in typeof DEBUG_GLOBAL]?: boolean; -}; - -globalContext[DEBUG_GLOBAL] = false; - -const format = ( - text: string, - format: string, - removeFormat?: string -): string => { - return `${format}${text}${removeFormat ? removeFormat : DEFAULT_TEXT_FORMAT}`; -}; - -const bgFormat = (text: string, colorCode: number): string => { - return format(text, `${ESCAPE}[48;5;${colorCode}m`, DEFAULT_FG_BG); -}; - -const fgFormat = (text: string, colorCode: number): string => { - return format(text, `${ESCAPE}[38;5;${colorCode}m`, DEFAULT_FG_BG); -}; - -export const bg = { - lucia: (text: string) => bgFormat(text, LUCIA_COLOR_CODE), - red: (text: string) => bgFormat(text, RED_CODE), - white: (text: string) => bgFormat(text, WHITE_CODE), - green: (text: string) => bgFormat(text, GREEN_CODE), - cyan: (text: string) => bgFormat(text, CYAN_CODE), - yellow: (text: string) => bgFormat(text, YELLOW_CODE), - purple: (text: string) => bgFormat(text, PURPLE_CODE), - blue: (text: string) => bgFormat(text, BLUE_CODE) -} as const; - -export const fg = { - lucia: (text: string) => fgFormat(text, LUCIA_COLOR_CODE), - red: (text: string) => fgFormat(text, RED_CODE), - white: (text: string) => fgFormat(text, WHITE_CODE), - green: (text: string) => fgFormat(text, GREEN_CODE), - cyan: (text: string) => fgFormat(text, CYAN_CODE), - yellow: (text: string) => fgFormat(text, YELLOW_CODE), - purple: (text: string) => fgFormat(text, PURPLE_CODE), - blue: (text: string) => fgFormat(text, BLUE_CODE), - default: (text: string) => format(text, DEFAULT_TEXT_FORMAT) -} as const; - -export const bold = (text: string): string => { - return format(text, `${ESCAPE}[1m`, `${ESCAPE}[22m`); -}; - -const dim = (text: string): string => { - return format(text, `${ESCAPE}[2m`, `${ESCAPE}[22m`); -}; - -const isDebugModeEnabled = (): boolean => { - return Boolean(globalContext[DEBUG_GLOBAL]); -}; - -const linebreak = (): void => console.log(""); - -const createCategory = (name: string, themeTextColor: TextColor) => { - const createLogger = (textColor: TextColor = fg.default) => { - return (text: string, subtext?: string) => { - if (!isDebugModeEnabled()) return; - if (subtext) { - return log(themeTextColor(`[${name}]`), `${textColor(text)}`, subtext); - } - log(themeTextColor(`[${name}]`), `${textColor(text)}`); - }; - }; - return { - info: createLogger(), - notice: createLogger(fg.yellow), - fail: createLogger(fg.red), - success: createLogger(fg.green) - }; -}; - -export const enableDebugMode = (): void => { - globalContext[DEBUG_GLOBAL] = true; -}; - -const disableDebugMode = (): void => { - globalContext[DEBUG_GLOBAL] = false; -}; - -export const debug = { - init: (debugEnabled: boolean) => { - if (debugEnabled) { - enableDebugMode(); - linebreak(); - console.log( - ` ${bg.lucia(bold(fg.white(" lucia ")))} ${fg.lucia( - bold("Debug mode enabled") - )}` - ); - } else { - disableDebugMode(); - } - }, - request: { - init: (method: string, href: string) => { - if (!isDebugModeEnabled()) return; - linebreak(); - const getUrl = () => { - try { - const url = new URL(href); - return url.origin + url.pathname; - } catch { - return href; - } - }; - log( - bg.cyan(bold(fg.white(" request "))), - fg.cyan(`${method.toUpperCase()} ${getUrl()}`) - ); - }, - ...createCategory("request", fg.cyan) - }, - session: createCategory("session", fg.purple), - key: createCategory("key", fg.blue) -} as const; - -type TextColor = (typeof fg)[keyof typeof fg]; - -const log = (type: string, text: string, subtext?: string): void => { - if (!subtext) { - return console.log( - `${dim(new Date().toLocaleTimeString())} ${type} ${text}` - ); - } - console.log( - `${dim(new Date().toLocaleTimeString())} ${type} ${text} ${dim(subtext)}` - ); -}; diff --git a/packages/lucia/src/utils/index.ts b/packages/lucia/src/utils/index.ts deleted file mode 100644 index bad1f8515..000000000 --- a/packages/lucia/src/utils/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { serializeCookie, parseCookie } from "./cookie.js"; -export { isWithinExpiration } from "./date.js"; -export { - generateScryptHash as generateLuciaPasswordHash, - validateScryptHash as validateLuciaPasswordHash, - generateRandomString -} from "./crypto.js"; -export { joinAdapters as __experimental_joinAdapters } from "./adapter.js"; diff --git a/packages/lucia/src/utils/log.ts b/packages/lucia/src/utils/log.ts deleted file mode 100644 index 6498666de..000000000 --- a/packages/lucia/src/utils/log.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const logError = (message: string): void => { - console.log("\x1b[31m%s\x1b[31m", `[LUCIA_ERROR] ${message}`); -}; diff --git a/packages/lucia/src/utils/request.ts b/packages/lucia/src/utils/request.ts deleted file mode 100644 index a127a9d40..000000000 --- a/packages/lucia/src/utils/request.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const createHeadersFromObject = ( - headersObject: Record -): Headers => { - const headers = new Headers(); - for (const [key, value] of Object.entries(headersObject)) { - if (value === null || value === undefined) continue; - if (typeof value === "string") { - headers.set(key, value); - } else { - for (const item of value) { - headers.append(key, item); - } - } - } - return headers; -}; diff --git a/packages/lucia/src/utils/url.test.ts b/packages/lucia/src/utils/url.test.ts deleted file mode 100644 index 3e43963c2..000000000 --- a/packages/lucia/src/utils/url.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { test, expect } from "vitest"; -import { isAllowedOrigin } from "./url.js"; - -test("isAllowedOrigin() returns expected result", async () => { - expect(isAllowedOrigin("http://example.com", "example.com", [])).toBe(true); - expect(isAllowedOrigin("http://foo.example.com", "foo.example.com", [])).toBe( - true - ); - expect(isAllowedOrigin("http://not-allowed.com", "example.com", [])).toBe( - false - ); - expect(isAllowedOrigin("http://localhost:3000", "example.com", [])).toBe( - false - ); - expect(isAllowedOrigin("http://example.", "example.com", [])).toBe(false); - - expect(isAllowedOrigin("http://example.com/foo", "example.com", "*")).toBe( - true - ); - expect(isAllowedOrigin("http://foo.example.com", "example.com", "*")).toBe( - true - ); - expect( - isAllowedOrigin("http://foo.example.com", "bar.example.com", "*") - ).toBe(true); - expect( - isAllowedOrigin("http://foo.bar.example.com", "example.com", "*") - ).toBe(true); - expect( - isAllowedOrigin("http://foo.bar.example.com", "foo.example.com", "*") - ).toBe(true); - - expect( - isAllowedOrigin("http://foo.example.com", "example.com", ["foo"]) - ).toBe(true); - expect(isAllowedOrigin("http://foo.example.com", "example.com", [])).toBe( - false - ); - expect( - isAllowedOrigin("http://foo.not-allowed.com", "example.com", ["foo"]) - ).toBe(false); - - expect( - isAllowedOrigin("http://foo.bar.example.com", "example.com", ["foo.bar"]) - ).toBe(true); - expect( - isAllowedOrigin("http://foo.bar.example.com", "example.com", ["bar"]) - ).toBe(false); - expect(isAllowedOrigin("http://example.com/foo", "example.com", [null])).toBe( - true - ); - - expect(isAllowedOrigin("http://localhost:3000", "localhost:3000", [])).toBe( - true - ); - expect( - isAllowedOrigin("http://foo.localhost:3000", "localhost:3000", "*") - ).toBe(true); - expect( - isAllowedOrigin("http://foo.localhost:3000", "localhost:3000", ["foo"]) - ).toBe(true); - expect( - isAllowedOrigin("http://bar.localhost:3000", "localhost:3000", ["foo"]) - ).toBe(false); - - expect(isAllowedOrigin("http://example.", "example.", [])).toBe(true); - expect(isAllowedOrigin("http://foo.example.", "example.", "*")).toBe(true); - expect(isAllowedOrigin("http://foo.example.", "example.", ["foo"])).toBe( - true - ); - expect(isAllowedOrigin("http://bar.example.", "example.", ["foo"])).toBe( - false - ); - - expect(isAllowedOrigin("http://example.com.com", "example.com", [])).toBe( - false - ); - expect(isAllowedOrigin("http://example.com.com", "example.com", "*")).toBe( - false - ); - expect(isAllowedOrigin("http://localhost.com", "localhost:3000", "*")).toBe( - false - ); - - expect( - isAllowedOrigin("http://foo.example.com", "example.com", ["foo", "bar"]) - ).toBe(true); - expect( - isAllowedOrigin("http://example.com", "example.com", [null, "bar"]) - ).toBe(true); -}); diff --git a/packages/lucia/src/utils/url.ts b/packages/lucia/src/utils/url.ts deleted file mode 100644 index 2fd5c652a..000000000 --- a/packages/lucia/src/utils/url.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const isAllowedOrigin = ( - origin: string, - host: string, - allowedSubdomains: "*" | (string | null)[] -): boolean => { - const originHost = new URL(origin).host; - const baseDomain = getBaseDomain(host); - if (host.length < 1 || origin.length < 1) return false; - if (originHost === host) return true; - if (allowedSubdomains === "*") { - if (originHost.endsWith(`.${baseDomain}`)) return true; - return false; - } - for (const subdomain of allowedSubdomains) { - const allowedHostPermutation = - subdomain === null ? baseDomain : [subdomain, baseDomain].join("."); - if (allowedHostPermutation === originHost) return true; - } - return false; -}; - -const getBaseDomain = (host: string): string => { - if (host.startsWith("localhost:")) return host; - return host.split(".").slice(-2).join("."); -}; - -export const safeParseUrl = (url: string): URL | null => { - try { - return new URL(url); - } catch { - return null; - } -}; diff --git a/packages/lucia/tsconfig.json b/packages/lucia/tsconfig.json index f2fb9daed..5cb3f5468 100644 --- a/packages/lucia/tsconfig.json +++ b/packages/lucia/tsconfig.json @@ -6,6 +6,7 @@ "target": "ES2022", "outDir": "./dist", "declaration": true, + "skipLibCheck": true, "noImplicitAny": true, "allowSyntheticDefaultImports": true diff --git a/packages/oauth/.eslintignore b/packages/oauth/.eslintignore deleted file mode 100644 index 38972655f..000000000 --- a/packages/oauth/.eslintignore +++ /dev/null @@ -1,13 +0,0 @@ -.DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example - -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock diff --git a/packages/oauth/.prettierignore b/packages/oauth/.prettierignore deleted file mode 100644 index 38972655f..000000000 --- a/packages/oauth/.prettierignore +++ /dev/null @@ -1,13 +0,0 @@ -.DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example - -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock diff --git a/packages/oauth/CHANGELOG.md b/packages/oauth/CHANGELOG.md deleted file mode 100644 index 772674ee6..000000000 --- a/packages/oauth/CHANGELOG.md +++ /dev/null @@ -1,509 +0,0 @@ -# @lucia-auth/oauth - -## 3.5.3 - -### Patch changes - -- [#1353](https://github.com/lucia-auth/lucia/pull/1353) by [@xyassini](https://github.com/xyassini) : Fixed the endpoint used for exchanging authorization codes for the Atlassian provider - -## 3.5.2 - -### Patch changes - -- [#1337](https://github.com/lucia-auth/lucia/pull/1337) by [@AmruthPillai](https://github.com/AmruthPillai) : Update Keycloak provider to accept domain argument with protocol - -## 3.5.1 - -### Patch changes - -- [#1323](https://github.com/lucia-auth/lucia/pull/1323) by [@NuttyShrimp](https://github.com/NuttyShrimp) : Fix Dropbox provider - -## 3.5.0 - -### Minor changes - -- [#1165](https://github.com/lucia-auth/lucia/pull/1165) by [@Ed1ks](https://github.com/Ed1ks) : Adds Keycloak Provider - -- [#1207](https://github.com/lucia-auth/lucia/pull/1207) by [@sjunepark](https://github.com/sjunepark) : Add Kakao provider - -## 3.4.0 - -### Minor changes - -- [#1230](https://github.com/lucia-auth/lucia/pull/1230) by [@andr35](https://github.com/andr35) : Add `serverUrl` param to `GitlabAuth` config - -## 3.3.2 - -### Patch changes - -- [#1226](https://github.com/lucia-auth/lucia/pull/1226) by [@nlfmt](https://github.com/nlfmt) : Fix `DiscordUser` - -## 3.3.1 - -### Patch changes - -- [#1179](https://github.com/lucia-auth/lucia/pull/1179) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update params `appDomain` for `auth0()` - -## 3.3.0 - -### Minor changes - -- [#1147](https://github.com/lucia-auth/lucia/pull/1147) by [@ollema](https://github.com/ollema) : Fix `slack()` provider - -### Patch changes - -- [#1141](https://github.com/lucia-auth/lucia/pull/1141) by [@q1b](https://github.com/q1b) : Fix `config.accessType` in `google()` provider - -- [#1132](https://github.com/lucia-auth/lucia/pull/1132) by [@KazuumiN](https://github.com/KazuumiN) : Fix link at `getLineUser()` - -## 3.2.0 - -### Minor changes - -- [#1098](https://github.com/pilcrowOnPaper/lucia/pull/1098) by [@OmerSabic](https://github.com/OmerSabic) : Update `AppleUser` - -- [#1098](https://github.com/pilcrowOnPaper/lucia/pull/1098) by [@OmerSabic](https://github.com/OmerSabic) : Add `scope` params to `apple()` - -### Patch changes - -- [#1106](https://github.com/pilcrowOnPaper/lucia/pull/1106) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix `linkedin()` provider missing default `openid` scope - -- [#1105](https://github.com/pilcrowOnPaper/lucia/pull/1105) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Remove unused `identityProvider` params from `CognitoAuth.getAuthorizationUrl()` - -## 3.1.0 - -### Minor changes - -- [#988](https://github.com/pilcrowOnPaper/lucia/pull/988) by [@tmadge](https://github.com/tmadge) : Add AWS Cognito provider - -- [#1072](https://github.com/pilcrowOnPaper/lucia/pull/1072) by [@infovore](https://github.com/infovore) : Add Strava provider - -- [#1068](https://github.com/pilcrowOnPaper/lucia/pull/1068) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add `email` field `FacebookUser` - -### Patch changes - -- [#1070](https://github.com/pilcrowOnPaper/lucia/pull/1070) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update `Auth0User` - -- [#1065](https://github.com/pilcrowOnPaper/lucia/pull/1065) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Remove test files from release - -## 3.0.0 - -### Major changes - -- [#993](https://github.com/pilcrowOnPaper/lucia/pull/993) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Remove `generateState()` export - -- [#993](https://github.com/pilcrowOnPaper/lucia/pull/993) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Replace `OAuthProvider` with `OAuth2ProviderAuth` and `OAuth2ProviderAuthWithPKCE` - -- [#993](https://github.com/pilcrowOnPaper/lucia/pull/993) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Replace `GithubProvider` with `GithubAuth` etc - -- [#1022](https://github.com/pilcrowOnPaper/lucia/pull/1022) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Rename `linkedin()`, type `LinkedinUser`, and type `LinkedinTokens` to `linkedIn()`, `LinkedInUser`, and `LinkedInTokens` - -- [#1024](https://github.com/pilcrowOnPaper/lucia/pull/1024) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update `auth0()`, `patreon()`, `reddit()`, `spotify()`, `twitch()` params - -### Minor changes - -- [#1011](https://github.com/pilcrowOnPaper/lucia/pull/1011) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add Salesforce provider - -- [#993](https://github.com/pilcrowOnPaper/lucia/pull/993) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Experimental API `createOAuth2AuthorizationUrl()`, `createOAuth2AuthorizationUrlWithPKCE()`, `validateOAuth2AuthorizationCode()`, and `decodeIdToken()` are now stable - -- [#993](https://github.com/pilcrowOnPaper/lucia/pull/993) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update `createOAuth2AuthorizationUrlWithPKCE()` return type - -- [#1016](https://github.com/pilcrowOnPaper/lucia/pull/1016) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add Box provider - -- [#1005](https://github.com/pilcrowOnPaper/lucia/pull/1005) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add Azure Active Directory provider - -- [#1012](https://github.com/pilcrowOnPaper/lucia/pull/1012) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add Atlassian provider - -- [#1015](https://github.com/pilcrowOnPaper/lucia/pull/1015) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add Line provider - -- [#1013](https://github.com/pilcrowOnPaper/lucia/pull/1013) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add GitLab provider - -- [#1017](https://github.com/pilcrowOnPaper/lucia/pull/1017) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add Bitbucket provider - -- [#993](https://github.com/pilcrowOnPaper/lucia/pull/993) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Remove `options.searchParams` and `options.state` from `createOAuth2AuthorizationUrl()` and `createOAuth2AuthorizationUrlWithPKCE()` params - -- [#1011](https://github.com/pilcrowOnPaper/lucia/pull/1011) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add Slack provider - -### Patch changes - -- [#1024](https://github.com/pilcrowOnPaper/lucia/pull/1024) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add `global_name` and `avatar_decoration` fields in `DiscordUser` type - -- [#1023](https://github.com/pilcrowOnPaper/lucia/pull/1023) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix `validateOAuth2AuthorizationCode()` sending malformed basic auth headers - -## 2.2.0 - -### Minor changes - -- [#990](https://github.com/pilcrowOnPaper/lucia/pull/990) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add `twitter()` provider (OAuth 2.0 with PKCE) - -- [#983](https://github.com/pilcrowOnPaper/lucia/pull/983) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : `decodeIdToken()` throws `SyntaxError` - - - Remove `IdTokenError` - -### Patch changes - -- [#990](https://github.com/pilcrowOnPaper/lucia/pull/990) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix `GithubUserAuth.githubTokens` not including refresh token - -- [#973](https://github.com/pilcrowOnPaper/lucia/pull/973) by [@anhtuank7c](https://github.com/anhtuank7c) : Fix `linkedin()` to use latest LinkedIn OAuth implementation - -## 2.1.2 - -### Patch changes - -- [#968](https://github.com/pilcrowOnPaper/lucia/pull/968) by [@KazuumiN](https://github.com/KazuumiN) : Fixes `generatePKCECodeChallenge()` to correctly apply SHA-256 and Base64Url encoding as per PKCE specification. - -## 2.1.1 - -### Patch changes - -- [#948](https://github.com/pilcrowOnPaper/lucia/pull/948) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix `createOAuth2AuthorizationUrl()` and `createOAuth2AuthorizationUrlWithPKCE()` incorrectly setting `redirect_uri` field to `redirect_url`. - -## 2.1.0 - -### Minor changes - -- [#910](https://github.com/pilcrowOnPaper/lucia/pull/910) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Add experimental OAuth helpers: - - - `createOAuth2AuthorizationUrl()` - - - `createOAuth2AuthorizationUrlWithPKCE()` - - - `validateOAuth2AuthorizationCode()` - - - `decodeIdToken()` - - - `IdTokenError` - -- [#657](https://github.com/pilcrowOnPaper/lucia/pull/657) by [@luccasr73](https://github.com/luccasr73) : Add Apple provider - -## 2.0.1 - -### Patch changes - -- [#894](https://github.com/pilcrowOnPaper/lucia/pull/894) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix type `GoogleUser` - -## 2.0.0 - -### Major changes - -- [#885](https://github.com/pilcrowOnPaper/lucia/pull/885) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update version and peer dependency - -### Minor changes - -- [#869](https://github.com/pilcrowOnPaper/lucia/pull/869) by [@bachiitter](https://github.com/bachiitter) : Add Google OAuth Access type - -## 2.0.0-beta.8 - -### Minor changes - -- [#867](https://github.com/pilcrowOnPaper/lucia/pull/867) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.7 - -### Minor changes - -- [#843](https://github.com/pilcrowOnPaper/lucia/pull/843) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.6 - -### Minor changes - -- [#814](https://github.com/pilcrowOnPaper/lucia/pull/814) by [@L-Mario564](https://github.com/L-Mario564) : Add osu! OAuth provider - -- [#812](https://github.com/pilcrowOnPaper/lucia/pull/812) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.5 - -### Patch changes - -- [#803](https://github.com/pilcrowOnPaper/lucia/pull/803) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.4 - -### Major changes - -- [#788](https://github.com/pilcrowOnPaper/lucia/pull/790) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Require `lucia@2.0.0-beta.3` - -- [#772](https://github.com/pilcrowOnPaper/lucia/pull/772) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update `ProviderUserAuth.createUser()` params - -## 2.0.0-beta.3 - -### Major changes - -- [#776](https://github.com/pilcrowOnPaper/lucia/pull/776) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Rename `providerUser` property to `User` (`githubUser` etc) - -- [#776](https://github.com/pilcrowOnPaper/lucia/pull/776) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Rename `useAuth()` to `providerUserAuth()` - -- [#776](https://github.com/pilcrowOnPaper/lucia/pull/776) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Rename `tokens` property to `Tokens` (`githubTokens` etc) - -### Minor changes - -- [#603](https://github.com/pilcrowOnPaper/lucia/pull/603) by [@msonnberger](https://github.com/msonnberger) : Add Spotify OAuth provider - -- [#542](https://github.com/pilcrowOnPaper/lucia/pull/542) by [@gtim](https://github.com/gtim) : Add Lichess OAuth provider - -### Patch changes - -- [#734](https://github.com/pilcrowOnPaper/lucia/pull/734) by [@KarolusD](https://github.com/KarolusD) : Fix `FacebookUser` type - -## 2.0.0-beta.2 - -### Patch changes - -- [#768](https://github.com/pilcrowOnPaper/lucia/pull/768) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 2.0.0-beta.1 - -### Major changes - -- [#759](https://github.com/pilcrowOnPaper/lucia/pull/759) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Replace `LuciaOAuthRequestError` with `OAuthRequestError` - -### Patch changes - -- [#756](https://github.com/pilcrowOnPaper/lucia/pull/756) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix peer dependency version - -## 2.0.0-beta.0 - -### Major changes - -- [#682](https://github.com/pilcrowOnPaper/lucia/pull/682) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Remove `redirectUri` from `getAuthorizationUrl()` - -### Minor changes - -- [#682](https://github.com/pilcrowOnPaper/lucia/pull/682) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Require `lucia@^2.0.0` - - - Export `useAuth()` - - - Remove `provider()` - -## 1.2.1 - -### Minor changes - -- [#666](https://github.com/pilcrowOnPaper/lucia/pull/666) by [@bachiitter](https://github.com/bachiitter) : Add Google OAuth Access type - -### Patch changes - -- [#694](https://github.com/pilcrowOnPaper/lucia/pull/694) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix `GithubUser` type - -- [#694](https://github.com/pilcrowOnPaper/lucia/pull/694) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix `GoogleUser` type - -## 1.1.1 - -### Patch changes - -- [#672](https://github.com/pilcrowOnPaper/lucia/pull/672) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix `LuciaOAuthRequestError` - -## 1.1.0 - -### Minor changes - -- [#628](https://github.com/pilcrowOnPaper/lucia/pull/628) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Also adds the option to pass a default `redirectUri` to the github provider config. - -- [#628](https://github.com/pilcrowOnPaper/lucia/pull/628) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update OAuth Provider type to allow for a custom `redirectUri` to be passed to `getAuthorizationUrl` and update all providers accordingly. - -### Patch changes - -- [#626](https://github.com/pilcrowOnPaper/lucia/pull/626) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : `providerUser` respects scope and update `DiscordUser` - -## 1.0.1 - -### Patch changes - -- [#550](https://github.com/pilcrowOnPaper/lucia/pull/550) by [@pkb-pmj](https://github.com/pkb-pmj) : Fix OAuth provider types - - - Take `Auth` as a generic for every provider - -## 1.0.0 - -### Major changes - -- [#443](https://github.com/pilcrowOnPaper/lucia/pull/443) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Release version 1.0! - -## 0.8.1 - -### Patch changes - -- [#450](https://github.com/pilcrowOnPaper/lucia/pull/450) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix Twitch provider - -## 0.8.0 - -### Minor changes - -- [#430](https://github.com/pilcrowOnPaper/lucia/pull/430) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : [Breaking] Rename `LinkedInTokens.expiresIn` to `LinkedInUser.accessTokenExpiresIn` - -### Patch changes - -- [#430](https://github.com/pilcrowOnPaper/lucia/pull/430) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Fix types - -- [#430](https://github.com/pilcrowOnPaper/lucia/pull/430) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update providers - - - Add `GithubTokens.refresh_token`, `GithubTokens.refresh_token_expires_in`, `expires_in` - - - Add `https://www.googleapis.com/auth/userinfo.profile` scope to Google provider by default - -## 0.7.3 - -### Patch changes - -- [#431](https://github.com/pilcrowOnPaper/lucia/pull/431) by [@Jings](https://github.com/Jings) : missing facebook oauth index export - -## 0.7.2 - -### Patch changes - -- [#424](https://github.com/pilcrowOnPaper/lucia/pull/424) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : - Update dependencies - -## 0.7.1 - -### Patch changes - -- [#411](https://github.com/pilcrowOnPaper/lucia/pull/411) by [@Jings](https://github.com/Jings) : Add Auth0 as an oauth provider - -## 0.7.0 - -### Minor changes - -- [#398](https://github.com/pilcrowOnPaper/lucia/pull/398) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : **Breaking** Use `lucia-auth@0.9.0` - - - Replaced `createKey()` with `createPersistentKey()` - -## 0.6.4 - -### Patch changes - -- [#401](https://github.com/pilcrowOnPaper/lucia/pull/401) by [@Jings](https://github.com/Jings) : Added linkedin as an oauth provider - -## 0.6.3 - -### Patch changes - -- [#391](https://github.com/pilcrowOnPaper/lucia/pull/391) by [@BenocxX](https://github.com/BenocxX) : Fix the default scope for the Discord provider - -## 0.6.2 - -### Patch changes - -- [#392](https://github.com/pilcrowOnPaper/lucia/pull/392) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update peer dependency - -## 0.6.1 - -### Patch changes - -- [#388](https://github.com/pilcrowOnPaper/lucia/pull/388) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : remove unnecessary code - -## 0.6.0 - -### Minor changes - -- [#385](https://github.com/pilcrowOnPaper/lucia/pull/385) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : **Breaking changes!!** Major rewrite of the package. - - - New Discord and Facebook provider - - - Import providers from `@lucia-auth/oauth/providers` (no more default imports) - - - New `provider` API! - - - Email scope is no longer added by default for any providers - - - Reduced `providerUser` size for Patreon provider - - - `OAuthProvider.getAuthorizationUrl` returns a promise and `[URL, string]` (`URL` used to be `string`) - -## 0.5.4 - -### Patch changes - -- [#381](https://github.com/pilcrowOnPaper/lucia/pull/381) by [@pilcrowOnPaper](https://github.com/pilcrowOnPaper) : Update links in README and package.json - -## 0.5.3 - -- Update peer dependency - -## 0.5.2 - -- Update peer dependency - -## 0.5.1 - -- Add `expiresIn`, `refreshToken` to `validateCallback` (Twitch) - -## 0.5.0 - -- [Breaking] Require `lucia-auth` 0.5.0 - -- [Breaking] Update `createUser` parameter - -- `createKey` method in `validateCallback` result - -## 0.4.0 - -- [Breaking] Require `lucia-auth` 0.4.3 - -- Log request errors on dev mode - -## 0.3.2 - -- [Fix] Fix runtime errors - -## 0.3.1 - -- Add `User-Agent` header to all requests - -## 0.3.0 - -- [Breaking] Rename type `GetUserType` to `LuciaUser`; remove `GetCreateUserAttributesType` - -- `userAttributes` params for `createUser` is optional if `Lucia.UserAttributes` is empty - -- Make `Buffer` dependency optional - -## 0.2.7 - -- Fix type issues with `existingUser` and `createUser` for `validateCallback` - -## 0.2.6 - -- Update peer dependency - -## 0.2.5 - -- Add Reddit provider - -## 0.2.4 - -- Update peer dependency - -## 0.2.3 - -- Add Patreon provider - -## 0.2.2 - -- Update dependency - -## 0.2.1 - -- Remove crypto dependency [#236](https://github.com/pilcrowOnPaper/lucia/issues/236) - -## 0.2.0 - -- [Breaking] `getAuthorizationUrl` generates and adds `state` params to the authorization url - -- [Breaking] `getAuthorizationUrl` returns a tuple - -## 0.1.4 - -- Add Twitch provider - -## 0.1.3 - -- Add support for `lucia-auth` 0.2.x - -## 0.1.2 - -- Fix imports - -## 0.1.1 - -- Update peer dependency diff --git a/packages/oauth/README.md b/packages/oauth/README.md deleted file mode 100644 index 428ebf3b0..000000000 --- a/packages/oauth/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# @lucia-auth/oauth - -OAuth integration for Lucia v2. - -**[Documentation](https://lucia-auth.com/oauth)** - -**[Lucia documentation](https://lucia-auth.com)** - -**[Changelog](https://github.com/pilcrowOnPaper/lucia/blob/main/packages/oauth/CHANGELOG.md)** - -## Installation - -```bash -npm i @lucia-auth/oauth -pnpm add @lucia-auth/oauth -yarn add @lucia-auth/oauth -``` diff --git a/packages/oauth/package.json b/packages/oauth/package.json deleted file mode 100644 index 0f595a202..000000000 --- a/packages/oauth/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@lucia-auth/oauth", - "version": "3.5.3", - "description": "OAuth integration for Lucia", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "module": "dist/index.js", - "type": "module", - "files": [ - "/dist/", - "CHANGELOG.md" - ], - "scripts": { - "build": "shx rm -rf ./dist/* && tsc", - "auri.build": "pnpm build", - "test": "vitest run" - }, - "keywords": [ - "lucia", - "lucia", - "authentication", - "auth", - "oauth" - ], - "repository": { - "type": "git", - "url": "https://github.com/pilcrowOnPaper/lucia", - "directory": "packages/oauth" - }, - "author": "pilcrowonpaper", - "license": "MIT", - "exports": { - ".": "./dist/index.js", - "./providers": "./dist/providers/index.js" - }, - "typesVersions": { - "*": { - "providers": [ - "dist/providers/index.d.ts" - ] - } - }, - "devDependencies": { - "lucia": "latest", - "vitest": "^0.33.0", - "jose": "^4.14.4" - }, - "peerDependencies": { - "lucia": "^2.0.0" - } -} diff --git a/packages/oauth/src/ambient.d.ts b/packages/oauth/src/ambient.d.ts deleted file mode 100644 index c3bd83307..000000000 --- a/packages/oauth/src/ambient.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -declare namespace Lucia { - type Auth = import("lucia").Auth; - type DatabaseUserAttributes = {}; - type DatabaseSessionAttributes = {}; -} diff --git a/packages/oauth/src/core/oauth2.ts b/packages/oauth/src/core/oauth2.ts deleted file mode 100644 index bd74fb09c..000000000 --- a/packages/oauth/src/core/oauth2.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { - authorizationHeader, - createUrl, - handleRequest -} from "../utils/request.js"; -import { encodeBase64, encodeBase64Url } from "../utils/encode.js"; -import { generateRandomString } from "lucia/utils"; - -import type { ProviderUserAuth } from "./provider.js"; - -export abstract class OAuth2ProviderAuth< - _ProviderUserAuth extends ProviderUserAuth = ProviderUserAuth, - _Auth = _ProviderUserAuth extends ProviderUserAuth - ? _Auth - : never -> { - protected auth: _Auth; - - constructor(auth: _Auth) { - this.auth = auth; - } - - abstract validateCallback: (code: string) => Promise<_ProviderUserAuth>; - abstract getAuthorizationUrl: () => Promise< - readonly [url: URL, state: string | null] - >; -} - -export abstract class OAuth2ProviderAuthWithPKCE< - _ProviderUserAuth extends ProviderUserAuth = ProviderUserAuth, - _Auth = _ProviderUserAuth extends ProviderUserAuth - ? _Auth - : never -> { - protected auth: _Auth; - - constructor(auth: _Auth) { - this.auth = auth; - } - - abstract validateCallback: ( - code: string, - codeVerifier: string - ) => Promise<_ProviderUserAuth>; - abstract getAuthorizationUrl: () => Promise< - readonly [url: URL, codeVerifier: string, state: string | null] - >; -} - -export const createOAuth2AuthorizationUrl = async ( - url: string | URL, - options: { - clientId: string; - scope: string[]; - redirectUri?: string; - } -): Promise => { - const state = generateState(); - const authorizationUrl = createUrl(url, { - response_type: "code", - client_id: options.clientId, - scope: options.scope.join(" "), - state, - redirect_uri: options.redirectUri - }); - return [authorizationUrl, state] as const; -}; - -export const createOAuth2AuthorizationUrlWithPKCE = async ( - url: string | URL, - options: { - clientId: string; - scope: string[]; - codeChallengeMethod: "S256"; - redirectUri?: string; - } -): Promise< - readonly [authorizationUrl: URL, codeVerifier: string, state: string] -> => { - const codeVerifier = generateRandomString( - 96, - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_.~" - ); - const codeChallenge = await generatePKCECodeChallenge("S256", codeVerifier); - const state = generateState(); - const authorizationUrl = createUrl(url, { - response_type: "code", - client_id: options.clientId, - scope: options.scope.join(" "), - state, - redirect_uri: options.redirectUri, - code_challenge_method: "S256", - code_challenge: codeChallenge - }); - return [authorizationUrl, codeVerifier, state] as const; -}; - -export const validateOAuth2AuthorizationCode = async <_ResponseBody extends {}>( - authorizationCode: string, - url: string | URL, - options: { - clientId: string; - redirectUri?: string; - codeVerifier?: string; - clientPassword?: { - clientSecret: string; - authenticateWith: "client_secret" | "http_basic_auth"; - }; - } -): Promise<_ResponseBody> => { - const body = new URLSearchParams({ - code: authorizationCode, - client_id: options.clientId, - grant_type: "authorization_code" - }); - if (options.redirectUri) { - body.set("redirect_uri", options.redirectUri); - } - if (options.codeVerifier) { - body.set("code_verifier", options.codeVerifier); - } - if ( - options.clientPassword && - options.clientPassword.authenticateWith === "client_secret" - ) { - body.set("client_secret", options.clientPassword.clientSecret); - } - - const headers = new Headers({ - "Content-Type": "application/x-www-form-urlencoded" - }); - if ( - options.clientPassword && - options.clientPassword.authenticateWith === "http_basic_auth" - ) { - headers.set( - "Authorization", - authorizationHeader( - "basic", - encodeBase64( - `${options.clientId}:${options.clientPassword.clientSecret}` - ) - ) - ); - } - - const request = new Request(new URL(url), { - method: "POST", - headers, - body - }); - return await handleRequest<_ResponseBody>(request); -}; -export const generateState = () => { - return generateRandomString(43); -}; - -// Generates code_challenge from code_verifier, as specified in RFC 7636. -export const generatePKCECodeChallenge = async ( - method: "S256", - verifier: string -) => { - if (method === "S256") { - const verifierBuffer = new TextEncoder().encode(verifier); - const challengeBuffer = await crypto.subtle.digest( - "SHA-256", - verifierBuffer - ); - return encodeBase64Url(challengeBuffer); - } - throw new TypeError("Invalid PKCE code challenge method"); -}; diff --git a/packages/oauth/src/core/oidc.ts b/packages/oauth/src/core/oidc.ts deleted file mode 100644 index ac6f527ee..000000000 --- a/packages/oauth/src/core/oidc.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { decodeBase64Url } from "../utils/encode.js"; - -const decoder = new TextDecoder(); - -// does not verify id tokens -export const decodeIdToken = <_Claims extends {}>( - idToken: string -): { - iss: string; - aud: string; - exp: number; -} & _Claims => { - const idTokenParts = idToken.split("."); - if (idTokenParts.length !== 3) throw new SyntaxError("Invalid ID Token"); - const base64UrlPayload = idTokenParts[1]; - const payload: unknown = JSON.parse( - decoder.decode(decodeBase64Url(base64UrlPayload)) - ); - if (!payload || typeof payload !== "object") { - throw new SyntaxError("Invalid ID Token"); - } - return payload as { - iss: string; - aud: string; - exp: number; - } & _Claims; -}; diff --git a/packages/oauth/src/core/provider.ts b/packages/oauth/src/core/provider.ts deleted file mode 100644 index f71c2fa6e..000000000 --- a/packages/oauth/src/core/provider.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { LuciaUser, LuciaDatabaseUserAttributes } from "../lucia.js"; -import type { Auth, LuciaError, Key } from "lucia"; - -export class ProviderUserAuth<_Auth extends Auth = Auth> { - private auth: _Auth; - private providerId: string; - private providerUserId: string; - - constructor(auth: _Auth, providerId: string, providerUserId: string) { - this.auth = auth; - this.providerId = providerId; - this.providerUserId = providerUserId; - } - - public getExistingUser = async (): Promise | null> => { - try { - const key = await this.auth.useKey( - this.providerId, - this.providerUserId, - null - ); - const user = await this.auth.getUser(key.userId); - return user as LuciaUser<_Auth>; - } catch (e) { - const error = e as Partial; - if (error?.message !== "AUTH_INVALID_KEY_ID") throw e; - return null; - } - }; - - public createKey = async (userId: string): Promise => { - return await this.auth.createKey({ - userId, - providerId: this.providerId, - providerUserId: this.providerUserId, - password: null - }); - }; - - public createUser = async (options: { - userId?: string; - attributes: LuciaDatabaseUserAttributes<_Auth>; - }): Promise> => { - const user = await this.auth.createUser({ - key: { - providerId: this.providerId, - providerUserId: this.providerUserId, - password: null - }, - ...options - }); - return user as LuciaUser<_Auth>; - }; -} - -export const providerUserAuth = <_Auth extends Auth = Auth>( - auth: _Auth, - providerId: string, - providerUserId: string -): ProviderUserAuth<_Auth> => { - return new ProviderUserAuth(auth, providerId, providerUserId); -}; diff --git a/packages/oauth/src/core/request.ts b/packages/oauth/src/core/request.ts deleted file mode 100644 index ddb041289..000000000 --- a/packages/oauth/src/core/request.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class OAuthRequestError extends Error { - public request: Request; - public response: Response; - public message = "OAUTH_REQUEST_FAILED" as const; - constructor(request: Request, response: Response) { - super("OAUTH_REQUEST_FAILED"); - this.request = request; - this.response = response; - } -} diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts deleted file mode 100644 index b86d21e6c..000000000 --- a/packages/oauth/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { - createOAuth2AuthorizationUrl, - createOAuth2AuthorizationUrlWithPKCE, - validateOAuth2AuthorizationCode -} from "./core/oauth2.js"; -export { decodeIdToken } from "./core/oidc.js"; -export { providerUserAuth } from "./core/provider.js"; -export { OAuthRequestError } from "./core/request.js"; - -export type { ProviderUserAuth } from "./core/provider.js"; -export type { - OAuth2ProviderAuth, - OAuth2ProviderAuthWithPKCE -} from "./core/oauth2.js"; diff --git a/packages/oauth/src/lucia.ts b/packages/oauth/src/lucia.ts deleted file mode 100644 index c8779eba7..000000000 --- a/packages/oauth/src/lucia.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Auth } from "lucia"; - -/* -'lucia' exports `User` and `GlobalDatabaseUserAttributes` -but these will use the user's .d.ts file - -if you try to test that with the monorepo it works fine -but will not work when published and installed -*/ - -export type LuciaUser<_Auth extends Auth> = ReturnType< - _Auth["transformDatabaseUser"] ->; - -export type LuciaDatabaseUserAttributes<_Auth extends Auth> = Parameters< - _Auth["createUser"] ->[0]["attributes"]; diff --git a/packages/oauth/src/providers/apple.ts b/packages/oauth/src/providers/apple.ts deleted file mode 100644 index 8ecc08d4c..000000000 --- a/packages/oauth/src/providers/apple.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { decodeIdToken } from "../core/oidc.js"; -import { getPKCS8Key } from "../utils/crypto.js"; -import { createES256SignedJWT } from "../utils/jwt.js"; - -import type { Auth } from "lucia"; - -type Config = { - redirectUri: string; - clientId: string; - teamId: string; - keyId: string; - certificate: string; - responseMode?: "query" | "form_post"; - scope?: string[]; -}; - -const PROVIDER_ID = "apple"; -const APPLE_AUD = "https://appleid.apple.com"; - -export const apple = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): AppleAuth<_Auth> => { - return new AppleAuth(auth, config); -}; - -export class AppleAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - AppleUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - const [url, state] = await createOAuth2AuthorizationUrl( - "https://appleid.apple.com/auth/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: scopeConfig - } - ); - url.searchParams.set("response_mode", this.config.responseMode ?? "query"); - return [url, state]; - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const appleTokens = await this.validateAuthorizationCode(code); - const idTokenPayload = decodeIdToken<{ - sub: string; - email?: string; - email_verified?: boolean; - }>(appleTokens.idToken); - const appleUser: AppleUser = { - sub: idTokenPayload.sub, - email: idTokenPayload.email, - email_verified: idTokenPayload.email_verified - }; - return new AppleUserAuth(this.auth, appleUser, appleTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const clientSecret = await createSecretId({ - certificate: this.config.certificate, - teamId: this.config.teamId, - clientId: this.config.clientId, - keyId: this.config.keyId - }); - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token?: string; - expires_in: number; - id_token: string; - }>(code, "https://appleid.apple.com/auth/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? null, - accessTokenExpiresIn: tokens.expires_in, - idToken: tokens.id_token - }; - }; -} - -export class AppleUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public appleTokens: AppleTokens; - public appleUser: AppleUser; - - constructor(auth: _Auth, appleUser: AppleUser, appleTokens: AppleTokens) { - super(auth, PROVIDER_ID, appleUser.sub); - this.appleTokens = appleTokens; - this.appleUser = appleUser; - } -} - -const createSecretId = async (config: { - certificate: string; - teamId: string; - clientId: string; - keyId: string; -}): Promise => { - const now = Math.floor(Date.now() / 1000); - const payload = { - iss: config.teamId, - iat: now, - exp: now + 60 * 3, - aud: APPLE_AUD, - sub: config.clientId - }; - const privateKey = getPKCS8Key(config.certificate); - const jwt = await createES256SignedJWT( - { - alg: "ES256", - kid: config.keyId - }, - payload, - privateKey - ); - return jwt; -}; - -export type AppleTokens = { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresIn: number; - idToken: string; -}; - -export type AppleUser = { - email?: string; - email_verified?: boolean; - sub: string; -}; diff --git a/packages/oauth/src/providers/atlassian.ts b/packages/oauth/src/providers/atlassian.ts deleted file mode 100644 index c7c86f3fa..000000000 --- a/packages/oauth/src/providers/atlassian.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "atlassian"; - -export const atlassian = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): AtlassianAuth<_Auth> => { - return new AtlassianAuth(auth, config); -}; - -export class AtlassianAuth< - _Auth extends Auth = Auth -> extends OAuth2ProviderAuth> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - const [url, state] = await createOAuth2AuthorizationUrl( - "https://auth.atlassian.com/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["read:me", ...scopeConfig] - } - ); - url.searchParams.set("audience", "api.atlassian.com"); - url.searchParams.set("prompt", "consent"); - return [url, state]; - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const atlassianTokens = await this.validateAuthorizationCode(code); - const atlassianUser = await getAtlassianUser(atlassianTokens.accessToken); - return new AtlassianUserAuth(this.auth, atlassianUser, atlassianTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token?: string; - }>(code, "https://auth.atlassian.com/oauth/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - authenticateWith: "client_secret", - clientSecret: this.config.clientSecret - } - }); - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token ?? null - }; - }; -} - -export class AtlassianUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public atlassianTokens: AtlassianTokens; - public atlassianUser: AtlassianUser; - - constructor( - auth: _Auth, - atlassianUser: AtlassianUser, - atlassianTokens: AtlassianTokens - ) { - super(auth, PROVIDER_ID, atlassianUser.account_id); - - this.atlassianTokens = atlassianTokens; - this.atlassianUser = atlassianUser; - } -} - -const getAtlassianUser = async ( - accessToken: string -): Promise => { - const request = new Request("https://api.atlassian.com/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const atlassianUser = await handleRequest(request); - return atlassianUser; -}; - -export type AtlassianTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string | null; -}; - -export type AtlassianUser = { - account_type: string; - account_id: string; - email: string; - name: string; - picture: string; - account_status: string; - nickname: string; - zoneinfo: string; - locale: string; - extended_profile?: Record; -}; diff --git a/packages/oauth/src/providers/auth0.ts b/packages/oauth/src/providers/auth0.ts deleted file mode 100644 index 1db218870..000000000 --- a/packages/oauth/src/providers/auth0.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { - handleRequest, - authorizationHeader, - originFromDomain -} from "../utils/request.js"; - -import type { Auth } from "lucia"; - -const PROVIDER_ID = "auth0"; - -type Config = { - clientId: string; - clientSecret: string; - appDomain: string; - redirectUri: string; - scope?: string[]; -}; - -export const auth0 = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): Auth0Auth<_Auth> => { - return new Auth0Auth(auth, config); -}; - -export class Auth0Auth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - Auth0UserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - new URL("/authorize", originFromDomain(this.config.appDomain)), - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["openid", "profile", ...scopeConfig] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const auth0Tokens = await this.validateAuthorizationCode(code); - const auth0User = await getAuth0User( - this.config.appDomain, - auth0Tokens.accessToken - ); - return new Auth0UserAuth(this.auth, auth0User, auth0Tokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token: string; - id_token: string; - token_type: string; - }>(code, new URL("/oauth/token", this.config.appDomain), { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - tokenType: tokens.token_type - }; - }; -} - -export class Auth0UserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public auth0Tokens: Auth0Tokens; - public auth0User: Auth0User; - - constructor(auth: _Auth, auth0User: Auth0User, auth0Tokens: Auth0Tokens) { - super(auth, PROVIDER_ID, auth0User.id); - this.auth0Tokens = auth0Tokens; - this.auth0User = auth0User; - } -} - -const getAuth0User = async (appDomain: string, accessToken: string) => { - const request = new Request(new URL("/userinfo", appDomain), { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const auth0Profile = await handleRequest(request); - const auth0User: Auth0User = { - id: auth0Profile.sub.split("|")[1], - ...auth0Profile - }; - return auth0User; -}; - -export type Auth0Tokens = { - accessToken: string; - refreshToken: string; - idToken: string; - tokenType: string; -}; - -type Auth0UserInfoResult = { - sub: string; - name: string; - picture: string; - locale: string; - updated_at: string; - given_name?: string; - family_name?: string; - middle_name?: string; - nickname?: string; - preferred_username?: string; - profile?: string; - email?: string; - email_verified?: boolean; - gender?: string; - birthdate?: string; - zoneinfo?: string; - phone_number?: string; - phone_number_verified?: boolean; -}; - -export type Auth0User = { - id: string; - sub: string; - name: string; - picture: string; - locale: string; - updated_at: string; - given_name?: string; - family_name?: string; - middle_name?: string; - nickname?: string; - preferred_username?: string; - profile?: string; - email?: string; - email_verified?: boolean; - gender?: string; - birthdate?: string; - zoneinfo?: string; - phone_number?: string; - phone_number_verified?: boolean; -}; diff --git a/packages/oauth/src/providers/azure-ad.ts b/packages/oauth/src/providers/azure-ad.ts deleted file mode 100644 index 921b9683c..000000000 --- a/packages/oauth/src/providers/azure-ad.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { generateRandomString } from "lucia/utils"; -import { - OAuth2ProviderAuthWithPKCE, - createOAuth2AuthorizationUrlWithPKCE, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; - -import type { Auth } from "lucia"; -import { authorizationHeader, handleRequest } from "../utils/request.js"; - -type Config = { - clientId: string; - clientSecret: string; - tenant: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "azure_ad"; - -export const azureAD = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): AzureADAuth<_Auth> => { - return new AzureADAuth(auth, config); -}; - -export class AzureADAuth< - _Auth extends Auth = Auth -> extends OAuth2ProviderAuthWithPKCE> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, codeVerifier: string, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - const [url, codeVerifier, state] = - await createOAuth2AuthorizationUrlWithPKCE( - `https://login.microsoftonline.com/${this.config.tenant}/oauth2/v2.0/authorize`, - { - clientId: this.config.clientId, - codeChallengeMethod: "S256", - scope: ["openid", "profile", ...scopeConfig], - redirectUri: this.config.redirectUri - } - ); - url.searchParams.set("nonce", generateRandomString(40)); - return [url, codeVerifier, state]; - }; - - public validateCallback = async ( - code: string, - code_verifier: string - ): Promise> => { - const azureADTokens = await this.validateAuthorizationCode( - code, - code_verifier - ); - const azureADUser = await getAzureADUser(azureADTokens.accessToken); - return new AzureADUserAuth(this.auth, azureADUser, azureADTokens); - }; - - private validateAuthorizationCode = async ( - code: string, - codeVerifier: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - id_token: string; - access_token: string; - expires_in: number; - refresh_token?: string; - }>( - code, - `https://login.microsoftonline.com/${this.config.tenant}/oauth2/v2.0/token`, - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - codeVerifier, - clientPassword: { - authenticateWith: "client_secret", - clientSecret: this.config.clientSecret - } - } - ); - return { - idToken: tokens.id_token, - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token ?? null - }; - }; -} - -const getAzureADUser = async (accessToken: string): Promise => { - const azureADUserRequest = new Request( - "https://graph.microsoft.com/oidc/userinfo", - { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - } - ); - return await handleRequest(azureADUserRequest); -}; - -export class AzureADUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public azureADTokens: AzureADTokens; - public azureADUser: AzureADUser; - - constructor( - auth: _Auth, - azureADUser: AzureADUser, - azureADTokens: AzureADTokens - ) { - super(auth, PROVIDER_ID, azureADUser.sub); - this.azureADTokens = azureADTokens; - this.azureADUser = azureADUser; - } -} - -export type AzureADTokens = { - idToken: string; - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string | null; -}; - -export type AzureADUser = { - sub: string; - name: string; - family_name: string; - given_name: string; - picture: string; - email?: string; -}; diff --git a/packages/oauth/src/providers/bitbucket.ts b/packages/oauth/src/providers/bitbucket.ts deleted file mode 100644 index 50664f1fb..000000000 --- a/packages/oauth/src/providers/bitbucket.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; -}; - -const PROVIDER_ID = "bitbucket"; - -export const bitbucket = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): BitbucketAuth<_Auth> => { - return new BitbucketAuth(auth, config); -}; - -export class BitbucketAuth< - _Auth extends Auth = Auth -> extends OAuth2ProviderAuth> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://bitbucket.org/site/oauth2/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["account"] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const bitbucketTokens = await this.validateAuthorizationCode(code); - const bitbucketUser = await getBitbucketUser(bitbucketTokens.accessToken); - return new BitbucketUserAuth(this.auth, bitbucketUser, bitbucketTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - }>(code, "https://bitbucket.org/site/oauth2/access_token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - authenticateWith: "http_basic_auth", - clientSecret: this.config.clientSecret - } - }); - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token - }; - }; -} - -export class BitbucketUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public bitbucketTokens: BitbucketTokens; - public bitbucketUser: BitbucketUser; - - constructor( - auth: _Auth, - bitbucketUser: BitbucketUser, - bitbucketTokens: BitbucketTokens - ) { - super(auth, PROVIDER_ID, bitbucketUser.uuid); - - this.bitbucketTokens = bitbucketTokens; - this.bitbucketUser = bitbucketUser; - } -} - -const getBitbucketUser = async ( - accessToken: string -): Promise => { - const request = new Request("https://api.bitbucket.org/2.0/user", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const bitbucketUser = await handleRequest(request); - return bitbucketUser; -}; - -export type BitbucketTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; -}; - -export type BitbucketUser = { - type: string; - links: { - avatar: - | {} - | { - href: string; - name: string; - }; - }; - created_on: string; - display_name: string; - username: string; - uuid: string; -}; diff --git a/packages/oauth/src/providers/box.ts b/packages/oauth/src/providers/box.ts deleted file mode 100644 index 040612854..000000000 --- a/packages/oauth/src/providers/box.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; -}; - -const PROVIDER_ID = "box"; - -export const box = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): BoxAuth<_Auth> => { - return new BoxAuth(auth, config); -}; - -export class BoxAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - BoxUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://account.box.com/api/oauth2/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: [] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const boxTokens = await this.validateAuthorizationCode(code); - const boxUser = await getBoxUser(boxTokens.accessToken); - return new BoxUserAuth(this.auth, boxUser, boxTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - }>(code, "https://api.box.com/oauth2/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - authenticateWith: "client_secret", - clientSecret: this.config.clientSecret - } - }); - return { - accessToken: tokens.access_token - }; - }; -} - -export class BoxUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public boxTokens: BoxTokens; - public boxUser: BoxUser; - - constructor(auth: _Auth, boxUser: BoxUser, boxTokens: BoxTokens) { - super(auth, PROVIDER_ID, boxUser.id); - - this.boxTokens = boxTokens; - this.boxUser = boxUser; - } -} - -const getBoxUser = async (accessToken: string): Promise => { - const request = new Request("https://api.box.com/2.0/users/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const boxUser = await handleRequest(request); - return boxUser; -}; - -export type BoxTokens = { - accessToken: string; -}; - -export type BoxUser = { - id: string; - type: "user"; - address: string; - avatar_url: string; - can_see_managed_users: boolean; - created_at: string; - enterprise: { - id: string; - type: string; - name: string; - }; - external_app_user_id: string; - hostname: string; - is_exempt_from_device_limits: boolean; - is_exempt_from_login_verification: boolean; - is_external_collab_restricted: boolean; - is_platform_access_only: boolean; - is_sync_enabled: boolean; - job_title: string; - language: string; - login: string; - max_upload_size: number; - modified_at: string; - my_tags: [string]; - name: string; - notification_email: { - email: string; - is_confirmed: boolean; - }; - phone: string; - role: string; - space_amount: number; - space_used: number; - status: - | "active" - | "inactive" - | "cannot_delete_edit" - | "cannot_delete_edit_upload"; - timezone: string; - tracking_codes: { - type: string; - name: string; - value: string; - }[]; -}; diff --git a/packages/oauth/src/providers/cognito.ts b/packages/oauth/src/providers/cognito.ts deleted file mode 100644 index f0434f48c..000000000 --- a/packages/oauth/src/providers/cognito.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { decodeIdToken } from "../core/oidc.js"; -import { ProviderUserAuth } from "../core/provider.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - userPoolDomain: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "cognito"; - -export const cognito = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): CognitoAuth<_Auth> => { - return new CognitoAuth(auth, config); -}; - -export class CognitoAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - CognitoUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - new URL("/oauth2/authorize", this.config.userPoolDomain), - { - clientId: this.config.clientId, - scope: ["openid", ...scopeConfig], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const cognitoTokens = await this.validateAuthorizationCode(code); - const cognitoUser = getCognitoUser(cognitoTokens.idToken); - return new CognitoUserAuth(this.auth, cognitoUser, cognitoTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token: string; - id_token: string; - expires_in: number; - token_type: string; - }>(code, new URL("/oauth2/token", this.config.userPoolDomain), { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - authenticateWith: "client_secret", - clientSecret: this.config.clientSecret - } - }); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - accessTokenExpiresIn: tokens.expires_in, - tokenType: tokens.token_type - }; - }; -} - -export class CognitoUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public cognitoTokens: CognitoTokens; - public cognitoUser: CognitoUser; - - constructor( - auth: _Auth, - cognitoUser: CognitoUser, - cognitoTokens: CognitoTokens - ) { - super(auth, PROVIDER_ID, cognitoUser["cognito:username"]); - this.cognitoTokens = cognitoTokens; - this.cognitoUser = cognitoUser; - } -} - -const getCognitoUser = (idToken: string): CognitoUser => { - const cognitoUser = decodeIdToken(idToken); - return cognitoUser; -}; - -export type CognitoTokens = { - accessToken: string; - refreshToken: string; - idToken: string; - accessTokenExpiresIn: number; - tokenType: string; -}; - -export type CognitoUser = { - sub: string; - "cognito:username": string; - "cognito:groups": string[]; - address?: { - formatted?: string; - }; - birthdate?: string; - email?: string; - email_verified?: boolean; - family_name?: string; - gender?: string; - given_name?: string; - locale?: string; - middle_name?: string; - name?: string; - nickname?: string; - phone_number?: string; - phone_number_verified?: boolean; - picture?: string; - preferred_username?: string; - profile?: string; - website?: string; - zoneinfo?: string; - updated_at?: number; -}; diff --git a/packages/oauth/src/providers/discord.ts b/packages/oauth/src/providers/discord.ts deleted file mode 100644 index c8ba17500..000000000 --- a/packages/oauth/src/providers/discord.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri: string; -}; - -const PROVIDER_ID = "discord"; - -export const discord = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): DiscordAuth<_Auth> => { - return new DiscordAuth(auth, config); -}; - -export class DiscordAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - DiscordUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - "https://discord.com/oauth2/authorize", - { - clientId: this.config.clientId, - scope: ["identify", ...scopeConfig], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const discordTokens = await this.validateAuthorizationCode(code); - const discordUser = await getDiscordUser(discordTokens.accessToken); - return new DiscordUserAuth(this.auth, discordUser, discordTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - }>(code, "https://discord.com/api/oauth2/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - accessTokenExpiresIn: tokens.expires_in - }; - }; -} - -export class DiscordUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public discordTokens: DiscordTokens; - public discordUser: DiscordUser; - - constructor( - auth: _Auth, - discordUser: DiscordUser, - discordTokens: DiscordTokens - ) { - super(auth, PROVIDER_ID, discordUser.id); - - this.discordTokens = discordTokens; - this.discordUser = discordUser; - } -} - -const getDiscordUser = async (accessToken: string): Promise => { - // do not use oauth/users/@me because it ignores intents, use oauth/users/@me instead - const request = new Request("https://discord.com/api/users/@me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const discordUser = await handleRequest(request); - return discordUser; -}; - -export type DiscordTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; - -export type DiscordUser = { - id: string; - username: string; - discriminator: string; - global_name: string | null; - avatar: string | null; - bot?: boolean; - system?: boolean; - mfa_enabled?: boolean; - verified?: boolean; - email?: string | null; - flags?: number; - banner?: string | null; - accent_color?: number | null; - premium_type?: number; - public_flags?: number; - locale?: string; - avatar_decoration?: string | null; -}; diff --git a/packages/oauth/src/providers/dropbox.ts b/packages/oauth/src/providers/dropbox.ts deleted file mode 100644 index 713d7f294..000000000 --- a/packages/oauth/src/providers/dropbox.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - tokenAccessType?: "online" | "offline"; -}; - -const PROVIDER_ID = "dropbox"; - -export const dropbox = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): DropboxAuth<_Auth> => { - return new DropboxAuth(auth, config); -}; - -export class DropboxAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - DropboxUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - const [url, state] = await createOAuth2AuthorizationUrl( - "https://www.dropbox.com/oauth2/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["account_info.read", ...scopeConfig] - } - ); - const tokenAccessType = this.config.tokenAccessType ?? "online"; - url.searchParams.set("token_access_type", tokenAccessType); - return [url, state]; - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const dropboxTokens = await this.validateAuthorizationCode(code); - const dropboxUser = await getDropboxUser(dropboxTokens.accessToken); - return new DropboxUserAuth(this.auth, dropboxUser, dropboxTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token?: string; - }>(code, "https://api.dropboxapi.com/oauth2/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - authenticateWith: "client_secret", - clientSecret: this.config.clientSecret - } - }); - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token ?? null - }; - }; -} - -export class DropboxUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public dropboxTokens: DropboxTokens; - public dropboxUser: DropboxUser; - - constructor( - auth: _Auth, - dropboxUser: DropboxUser, - dropboxTokens: DropboxTokens - ) { - super(auth, PROVIDER_ID, dropboxUser.account_id); - - this.dropboxTokens = dropboxTokens; - this.dropboxUser = dropboxUser; - } -} - -const getDropboxUser = async (accessToken: string): Promise => { - const request = new Request( - "https://api.dropboxapi.com/2/users/get_current_account", - { - method: "POST", - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - } - ); - const dropboxUser = await handleRequest(request); - return dropboxUser; -}; - -export type DropboxTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string | null; -}; - -export type DropboxUser = PairedDropBoxUser | UnpairedDropboxUser; - -type PairedDropBoxUser = BaseDropboxUser & { - is_paired: true; - team: { - id: string; - name: string; - office_addin_policy: Record; - sharing_policies: Record>; - }; -}; - -type UnpairedDropboxUser = BaseDropboxUser & { - is_paired: false; -}; - -type BaseDropboxUser = { - account_id: string; - country: string; - disabled: boolean; - email: string; - email_verified: boolean; - - locale: string; - name: { - abbreviated_name: string; - display_name: string; - familiar_name: string; - given_name: string; - surname: string; - }; - profile_photo_url: string; -}; diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts deleted file mode 100644 index 03084c758..000000000 --- a/packages/oauth/src/providers/facebook.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { - handleRequest, - authorizationHeader, - createUrl -} from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "facebook"; - -export const facebook = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): FacebookAuth<_Auth> => { - return new FacebookAuth(auth, config); -}; - -export class FacebookAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - FacebookUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://www.facebook.com/v16.0/dialog/oauth", - { - clientId: this.config.clientId, - scope: this.config.scope ?? [], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const facebookTokens = await this.validateAuthorizationCode(code); - const facebookUser = await getFacebookUser(facebookTokens.accessToken); - return new FacebookUserAuth(this.auth, facebookUser, facebookTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - }>(code, "https://graph.facebook.com/v16.0/oauth/access_token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - accessTokenExpiresIn: tokens.expires_in - }; - }; -} - -export class FacebookUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public facebookTokens: FacebookTokens; - public facebookUser: FacebookUser; - - constructor( - auth: _Auth, - facebookUser: FacebookUser, - facebookTokens: FacebookTokens - ) { - super(auth, PROVIDER_ID, facebookUser.id); - - this.facebookTokens = facebookTokens; - this.facebookUser = facebookUser; - } -} - -const getFacebookUser = async (accessToken: string): Promise => { - const requestUrl = createUrl("https://graph.facebook.com/me", { - access_token: accessToken, - fields: ["id", "name", "picture", "email"].join(",") - }); - const request = new Request(requestUrl, { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const facebookUser = await handleRequest(request); - return facebookUser; -}; - -export type FacebookTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; - -export type FacebookUser = { - id: string; - name: string; - email?: string; - picture: { - data: { - height: number; - is_silhouette: boolean; - url: string; - width: number; - }; - }; -}; diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts deleted file mode 100644 index 89faa2c04..000000000 --- a/packages/oauth/src/providers/github.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri?: string; -}; - -const PROVIDER_ID = "github"; - -export const github = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): GithubAuth<_Auth> => { - return new GithubAuth(auth, config); -}; - -export class GithubAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - GithubUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://github.com/login/oauth/authorize", - { - clientId: this.config.clientId, - scope: this.config.scope ?? [], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const githubTokens = await this.validateAuthorizationCode(code); - const githubUser = await getGithubUser(githubTokens.accessToken); - return new GithubUserAuth(this.auth, githubUser, githubTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = - await validateOAuth2AuthorizationCode( - code, - "https://github.com/login/oauth/access_token", - { - clientId: this.config.clientId, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - } - ); - if ("refresh_token" in tokens) { - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token, - refreshTokenExpiresIn: tokens.refresh_token_expires_in - }; - } - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: null - }; - }; -} - -const getGithubUser = async (accessToken: string): Promise => { - const githubUserRequest = new Request("https://api.github.com/user", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - return await handleRequest(githubUserRequest); -}; - -export class GithubUserAuth< - _Auth extends Auth -> extends ProviderUserAuth<_Auth> { - public githubTokens: GithubTokens; - public githubUser: GithubUser; - - constructor(auth: _Auth, githubUser: GithubUser, githubTokens: GithubTokens) { - super(auth, PROVIDER_ID, githubUser.id.toString()); - - this.githubTokens = githubTokens; - this.githubUser = githubUser; - } -} - -type AccessTokenResponseBody = - | { - access_token: string; - } - | { - access_token: string; - refresh_token: string; - expires_in: number; - refresh_token_expires_in: number; - }; - -export type GithubTokens = - | { - accessToken: string; - accessTokenExpiresIn: null; - } - | { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; - refreshTokenExpiresIn: number; - }; - -export type GithubUser = PublicGithubUser | PrivateGithubUser; - -type PublicGithubUser = { - avatar_url: string; - bio: string | null; - blog: string | null; - company: string | null; - created_at: string; - email: string | null; - events_url: string; - followers: number; - followers_url: string; - following: number; - following_url: string; - gists_url: string; - gravatar_id: string | null; - hireable: boolean | null; - html_url: string; - id: number; - location: string | null; - login: string; - name: string | null; - node_id: string; - organizations_url: string; - public_gists: number; - public_repos: number; - received_events_url: string; - repos_url: string; - site_admin: boolean; - starred_url: string; - subscriptions_url: string; - type: string; - updated_at: string; - url: string; - - twitter_username?: string | null; - plan?: { - name: string; - space: number; - private_repos: number; - collaborators: number; - }; - suspended_at?: string | null; -}; - -type PrivateGithubUser = PublicGithubUser & { - collaborators: number; - disk_usage: number; - owned_private_repos: number; - private_gists: number; - total_private_repos: number; - two_factor_authentication: boolean; - - business_plus?: boolean; - ldap_dn?: string; -}; diff --git a/packages/oauth/src/providers/gitlab.ts b/packages/oauth/src/providers/gitlab.ts deleted file mode 100644 index 334646c97..000000000 --- a/packages/oauth/src/providers/gitlab.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - serverUrl?: string; -}; - -const PROVIDER_ID = "gitlab"; - -export const gitlab = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): GitlabAuth<_Auth> => { - return new GitlabAuth(auth, config); -}; - -export class GitlabAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - GitlabUserAuth<_Auth> -> { - private config: Config; - private readonly serverUrl: string; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - this.serverUrl = config.serverUrl || "https://gitlab.com"; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - `${this.serverUrl}/oauth/authorize`, - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["read_user", ...scopeConfig] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const gitlabTokens = await this.validateAuthorizationCode(code); - const gitlabUser = await getGitlabUser( - gitlabTokens.accessToken, - this.serverUrl - ); - return new GitlabUserAuth(this.auth, gitlabUser, gitlabTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - }>(code, `${this.serverUrl}/oauth/token`, { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - authenticateWith: "client_secret", - clientSecret: this.config.clientSecret - } - }); - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token - }; - }; -} - -export class GitlabUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public gitlabTokens: GitlabTokens; - public gitlabUser: GitlabUser; - - constructor(auth: _Auth, gitlabUser: GitlabUser, gitlabTokens: GitlabTokens) { - super(auth, PROVIDER_ID, gitlabUser.id.toString()); - - this.gitlabTokens = gitlabTokens; - this.gitlabUser = gitlabUser; - } -} - -const getGitlabUser = async ( - accessToken: string, - serverUrl: string -): Promise => { - const request = new Request(`${serverUrl}/api/v4/user`, { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const gitlabUser = await handleRequest(request); - return gitlabUser; -}; - -export type GitlabTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; -}; - -export type GitlabUser = { - id: number; - username: string; - email: string; - name: string; - state: string; - avatar_url: string; - web_url: string; - created_at: string; - bio: string; - public_email: string; - skype: string; - linkedin: string; - twitter: string; - discord: string; - website_url: string; - organization: string; - job_title: string; - pronouns: string; - bot: boolean; - work_information: string | null; - followers: number; - following: number; - local_time: string; - last_sign_in_at: string; - confirmed_at: string; - theme_id: number; - last_activity_on: string; - color_scheme_id: number; - projects_limit: number; - current_sign_in_at: string; - identities: { provider: string; extern_uid: string }[]; - can_create_group: boolean; - can_create_project: boolean; - two_factor_enabled: boolean; - external: boolean; - private_profile: boolean; - commit_email: string; -}; diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts deleted file mode 100644 index c22fcc5fc..000000000 --- a/packages/oauth/src/providers/google.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - accessType?: "online" | "offline"; -}; - -const PROVIDER_ID = "google"; - -export const google = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): GoogleAuth<_Auth> => { - return new GoogleAuth(auth, config); -}; - -export class GoogleAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - GoogleUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - const [url, state] = await createOAuth2AuthorizationUrl( - "https://accounts.google.com/o/oauth2/v2/auth", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: [ - "https://www.googleapis.com/auth/userinfo.profile", - ...scopeConfig - ] - } - ); - const accessType = this.config.accessType ?? "online"; // ( default ) online - url.searchParams.set("access_type", accessType); - return [url, state]; - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const googleTokens = await this.validateAuthorizationCode(code); - const googleUser = await getGoogleUser(googleTokens.accessToken); - return new GoogleUserAuth(this.auth, googleUser, googleTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token?: string; - expires_in: number; - }>(code, "https://oauth2.googleapis.com/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? null, - accessTokenExpiresIn: tokens.expires_in - }; - }; -} - -export class GoogleUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public googleTokens: GoogleTokens; - public googleUser: GoogleUser; - - constructor(auth: _Auth, googleUser: GoogleUser, googleTokens: GoogleTokens) { - super(auth, PROVIDER_ID, googleUser.sub); - - this.googleTokens = googleTokens; - this.googleUser = googleUser; - } -} - -const getGoogleUser = async (accessToken: string): Promise => { - const request = new Request("https://www.googleapis.com/oauth2/v3/userinfo", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const googleUser = await handleRequest(request); - return googleUser; -}; - -export type GoogleTokens = { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresIn: number; -}; - -export type GoogleUser = { - sub: string; - name: string; - given_name: string; - family_name: string; - picture: string; - locale: string; - email?: string; - email_verified?: boolean; - hd?: string; -}; diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts deleted file mode 100644 index 86bacabd5..000000000 --- a/packages/oauth/src/providers/index.ts +++ /dev/null @@ -1,201 +0,0 @@ -export { apple } from "./apple.js"; -export type { - AppleAuth, - AppleTokens, - AppleUser, - AppleUserAuth -} from "./apple.js"; - -export { atlassian } from "./atlassian.js"; -export type { - AtlassianAuth, - AtlassianTokens, - AtlassianUser, - AtlassianUserAuth -} from "./atlassian.js"; - -export { auth0 } from "./auth0.js"; -export type { - Auth0Auth, - Auth0Tokens, - Auth0User, - Auth0UserAuth -} from "./auth0.js"; - -export { azureAD } from "./azure-ad.js"; -export type { - AzureADAuth, - AzureADTokens, - AzureADUser, - AzureADUserAuth -} from "./azure-ad.js"; - -export { bitbucket } from "./bitbucket.js"; -export type { - BitbucketAuth, - BitbucketTokens, - BitbucketUser, - BitbucketUserAuth -} from "./bitbucket.js"; - -export { box } from "./box.js"; -export type { BoxAuth, BoxTokens, BoxUser, BoxUserAuth } from "./box.js"; - -export { cognito } from "./cognito.js"; -export type { - CognitoAuth, - CognitoTokens, - CognitoUser, - CognitoUserAuth -} from "./cognito.js"; - -export { discord } from "./discord.js"; -export type { - DiscordAuth, - DiscordTokens, - DiscordUser, - DiscordUserAuth -} from "./discord.js"; - -export { dropbox } from "./dropbox.js"; -export type { - DropboxAuth, - DropboxTokens, - DropboxUser, - DropboxUserAuth -} from "./dropbox.js"; - -export { facebook } from "./facebook.js"; -export type { - FacebookAuth, - FacebookTokens, - FacebookUser, - FacebookUserAuth -} from "./facebook.js"; - -export { github } from "./github.js"; -export type { - GithubAuth, - GithubTokens, - GithubUser, - GithubUserAuth -} from "./github.js"; - -export { gitlab } from "./gitlab.js"; -export type { - GitlabAuth, - GitlabTokens, - GitlabUser, - GitlabUserAuth -} from "./gitlab.js"; - -export { google } from "./google.js"; -export type { - GoogleAuth, - GoogleTokens, - GoogleUser, - GoogleUserAuth -} from "./google.js"; - -export { kakao } from "./kakao.js"; -export type { - KakaoAuth, - KakaoTokens, - KakaoUser, - KakaoUserAuth -} from "./kakao.js"; - -export { keycloak } from "./keycloak.js"; -export type { - KeycloakAuth, - KeycloakTokens, - KeycloakUser, - KeycloakRole, - KeycloakUserAuth -} from "./keycloak.js"; - -export { lichess } from "./lichess.js"; -export type { - LichessAuth, - LichessTokens, - LichessUser, - LichessUserAuth -} from "./lichess.js"; - -export { line } from "./line.js"; -export type { LineAuth, LineTokens, LineUser, LineUserAuth } from "./line.js"; - -export { linkedIn } from "./linkedin.js"; -export type { - LinkedInAuth, - LinkedInTokens, - LinkedInUser, - LinkedInUserAuth -} from "./linkedin.js"; - -export { osu } from "./osu.js"; -export type { OsuAuth, OsuTokens, OsuUser, OsuUserAuth } from "./osu.js"; - -export { patreon } from "./patreon.js"; -export type { - PatreonAuth, - PatreonTokens, - PatreonUser, - PatreonUserAuth -} from "./patreon.js"; - -export { reddit } from "./reddit.js"; -export type { - RedditAuth, - RedditTokens, - RedditUser, - RedditUserAuth -} from "./reddit.js"; - -export { salesforce } from "./salesforce.js"; -export type { - SalesforceAuth, - SalesforceTokens, - SalesforceUser, - SalesforceUserAuth -} from "./salesforce.js"; - -export { slack } from "./slack.js"; -export type { - SlackAuth, - SlackTokens, - SlackUser, - SlackUserAuth -} from "./slack.js"; - -export { spotify } from "./spotify.js"; -export type { - SpotifyAuth, - SpotifyTokens, - SpotifyUser, - SpotifyUserAuth -} from "./spotify.js"; - -export { strava } from "./strava.js"; -export type { - StravaAuth, - StravaTokens, - StravaUser, - StravaUserAuth -} from "./strava.js"; - -export { twitch } from "./twitch.js"; -export type { - TwitchAuth, - TwitchTokens, - TwitchUser, - TwitchUserAuth -} from "./twitch.js"; - -export { twitter } from "./twitter.js"; -export type { - TwitterAuth, - TwitterTokens, - TwitterUser, - TwitterUserAuth -} from "./twitter.js"; diff --git a/packages/oauth/src/providers/kakao.ts b/packages/oauth/src/providers/kakao.ts deleted file mode 100644 index 5657f4a2f..000000000 --- a/packages/oauth/src/providers/kakao.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - createOAuth2AuthorizationUrl, - OAuth2ProviderAuth, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { authorizationHeader, handleRequest } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; // Kakao doesn't require clientSecret but it's recommended to use it - redirectUri: string; -}; - -const PROVIDER_ID = "kakao"; - -export const kakao = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): KakaoAuth<_Auth> => { - return new KakaoAuth(auth, config); -}; - -export class KakaoAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - KakaoUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://kauth.kakao.com/oauth/authorize", - { - clientId: this.config.clientId, - scope: [], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const kakaoTokens = await this.validateAuthorizationCode(code); - const kakaoUser = await getKakaoUser(kakaoTokens.accessToken); - return new KakaoUserAuth(this.auth, kakaoUser, kakaoTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const result = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - refresh_token_expires_in: number; - }>(code, "https://kauth.kakao.com/oauth/token", { - clientId: this.config.clientId, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - return { - accessToken: result.access_token, - expiresIn: result.expires_in, - refreshToken: result.refresh_token, - refreshTokenExpiresIn: result.refresh_token_expires_in - }; - }; -} - -const getKakaoUser = async (accessToken: string): Promise => { - const kakaoUserRequest = new Request("https://kapi.kakao.com/v2/user/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - return await handleRequest(kakaoUserRequest); -}; - -export class KakaoUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - public kakaoTokens: KakaoTokens; - public kakaoUser: KakaoUser; - - constructor(auth: _Auth, kakaoUser: KakaoUser, kakaoTokens: KakaoTokens) { - super(auth, PROVIDER_ID, kakaoUser.id.toString()); - - this.kakaoTokens = kakaoTokens; - this.kakaoUser = kakaoUser; - } -} - -export type KakaoTokens = { - accessToken: string; - expiresIn: number; - refreshToken: string; - refreshTokenExpiresIn: number; -}; - -export type KakaoUser = { - id: number; - has_signed_up?: boolean; - connected_at?: string; - synced_at?: string; - properties?: Record; - kakao_account?: KakaoAccount; - for_partner?: Partner; -}; - -type KakaoAccount = { - profile_needs_agreement?: boolean; - profile_nickname_needs_agreement?: boolean; - profile_image_needs_agreement?: boolean; - profile?: Profile; - email_needs_agreement?: boolean; - is_email_valid?: boolean; - is_email_verified?: boolean; - email?: string; - name_needs_agreement?: boolean; - name?: string; - age_range_needs_agreement?: boolean; - ag_range?: - | "1~9" - | "10~14" - | "15~19" - | "20~29" - | "30~39" - | "40~49" - | "50~59" - | "60~69" - | "70~79" - | "80~89" - | "90~"; - birthyear_needs_agreement?: boolean; - birthyear?: string; // "YYYY"; - birthday_needs_agreement?: boolean; - birthday?: string; // "MMDD"; - birthday_type?: "SOLAR" | "LUNAR"; - gender_needs_agreement?: boolean; - gender?: "female" | "male"; - phone_number_needs_agreement?: boolean; - phone_number?: string; - ci_needs_agreement?: boolean; - ci?: string; - ci_authenticated_at?: string; -}; - -type Profile = { - nickname?: string; - thumbnail_image_url?: string; - profile_image_url?: string; - is_default_image?: boolean; -}; - -type Partner = { - uuid?: string; -}; diff --git a/packages/oauth/src/providers/keycloak.ts b/packages/oauth/src/providers/keycloak.ts deleted file mode 100644 index 5dab5cd46..000000000 --- a/packages/oauth/src/providers/keycloak.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { - OAuth2ProviderAuthWithPKCE, - createOAuth2AuthorizationUrlWithPKCE, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { decodeIdToken } from "../index.js"; -import { - handleRequest, - authorizationHeader, - originFromDomain -} from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - domain: string; - realm: string; - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri?: string; -}; - -const PROVIDER_ID = "keycloak"; - -export const keycloak = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): KeycloakAuth<_Auth> => { - return new KeycloakAuth(auth, config); -}; - -export class KeycloakAuth< - _Auth extends Auth = Auth -> extends OAuth2ProviderAuthWithPKCE> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, codeVerifier: string, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrlWithPKCE( - new URL( - `/realms/${this.config.realm}/protocol/openid-connect/auth`, - originFromDomain(this.config.domain) - ), - { - clientId: this.config.clientId, - scope: ["profile", "openid", ...scopeConfig], - redirectUri: this.config.redirectUri, - codeChallengeMethod: "S256" - } - ); - }; - - public validateCallback = async ( - code: string, - code_verifier: string - ): Promise> => { - const keycloakTokens = await this.validateAuthorizationCode( - code, - code_verifier - ); - const keycloakUser = await getKeycloakUser( - this.config.domain, - this.config.realm, - keycloakTokens.accessToken - ); - const keycloakRoles = getKeycloakRoles(keycloakTokens.accessToken); - return new KeycloakUserAuth( - this.auth, - keycloakUser, - keycloakTokens, - keycloakRoles - ); - }; - - private validateAuthorizationCode = async ( - code: string, - codeVerifier: string - ): Promise => { - const rawTokens = - await validateOAuth2AuthorizationCode( - code, - new URL( - `/realms/${this.config.realm}/protocol/openid-connect/token`, - originFromDomain(this.config.domain) - ), - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - codeVerifier, - clientPassword: { - authenticateWith: "http_basic_auth", - clientSecret: this.config.clientSecret - } - } - ); - - return this.claimTokens(rawTokens); - }; - - private claimTokens = (tokens: AccessTokenResponseBody): KeycloakTokens => { - if ("refresh_token" in tokens) { - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - authTime: tokens.auth_time, - issuedAtTime: tokens.issued_at_time, - expiresAt: tokens.expires_at, - refreshToken: tokens.refresh_token, - refreshTokenExpiresIn: tokens.refresh_expires_in - }; - } - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - authTime: tokens.auth_time, - issuedAtTime: tokens.issued_at_time, - expiresAt: tokens.expires_at, - refreshToken: null, - refreshTokenExpiresIn: null - }; - }; -} - -const getKeycloakUser = async ( - domain: string, - realm: string, - accessToken: string -): Promise => { - const keycloakUserRequest = new Request( - new URL( - `/realms/${realm}/protocol/openid-connect/userinfo`, - originFromDomain(domain) - ), - { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - } - ); - return await handleRequest(keycloakUserRequest); -}; - -const getKeycloakRoles = (accessToken: string): KeycloakRole[] => { - const tokenDecoded: Claims = decodeIdToken(accessToken); - const keycloakRoles: KeycloakRole[] = []; - - if ("realm_access" in tokenDecoded) { - for (const role of tokenDecoded.realm_access.roles) { - keycloakRoles.push({ - role_type: "realm", - client: null, - role: role - }); - } - } - if ("resource_access" in tokenDecoded) { - for (const [key, client] of Object.entries(tokenDecoded.resource_access)) { - for (const role of client.roles) { - keycloakRoles.push({ - role_type: "resource", - client: key, - role: role - }); - } - } - } - - return keycloakRoles; -}; - -export class KeycloakUserAuth< - _Auth extends Auth -> extends ProviderUserAuth<_Auth> { - public keycloakTokens: KeycloakTokens; - public keycloakUser: KeycloakUser; - public keycloakRoles: KeycloakRole[]; - - constructor( - auth: _Auth, - keycloakUser: KeycloakUser, - keycloakTokens: KeycloakTokens, - keycloakRoles: KeycloakRole[] - ) { - super(auth, PROVIDER_ID, keycloakUser.sub); - - this.keycloakTokens = keycloakTokens; - this.keycloakUser = keycloakUser; - this.keycloakRoles = keycloakRoles; - } -} - -type AccessTokenResponseBody = - | { - access_token: string; - expires_in: number; - auth_time: number; - issued_at_time: number; - expires_at: number; - } - | { - access_token: string; - expires_in: number; - auth_time: number; - issued_at_time: number; - expires_at: number; - refresh_token: string; - refresh_expires_in: number; - }; - -export type KeycloakTokens = { - accessToken: string; - accessTokenExpiresIn: number; - authTime: number; - issuedAtTime: number; - expiresAt: number; - refreshToken: string | null; - refreshTokenExpiresIn: number | null; -}; - -export type Claims = { - exp: number; - iat: number; - auth_time: number; - realm_access: { roles: string[] }; - resource_access: { [key: string]: { roles: string[] } }; -}; - -export type KeycloakUser = { - exp: number; - iat: number; - auth_time: number; - jti: string; - iss: string; - aud: string; - sub: string; // user_id - typ: string; - azp: string; - session_state: string; - at_hash: string; - acr: string; - sid: string; - email_verified: boolean; - name: string; - preferred_username: string; - given_name: string; - locale: string; - family_name: string; - email: string; - picture: string; - user: any; -}; - -export type KeycloakRole = { - role_type: "realm" | "resource"; - client: null | string; // null if realm_access - role: string; -}; diff --git a/packages/oauth/src/providers/lichess.ts b/packages/oauth/src/providers/lichess.ts deleted file mode 100644 index cb5cbe9f7..000000000 --- a/packages/oauth/src/providers/lichess.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - OAuth2ProviderAuthWithPKCE, - createOAuth2AuthorizationUrlWithPKCE, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "lichess"; - -export const lichess = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): LichessAuth<_Auth> => { - return new LichessAuth(auth, config); -}; - -export class LichessAuth< - _Auth extends Auth = Auth -> extends OAuth2ProviderAuthWithPKCE> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, codeVerifier: string, state: string] - > => { - return await createOAuth2AuthorizationUrlWithPKCE( - "https://lichess.org/oauth", - { - clientId: this.config.clientId, - codeChallengeMethod: "S256", - scope: this.config.scope ?? [], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string, - code_verifier: string - ): Promise> => { - const lichessTokens = await this.validateAuthorizationCode( - code, - code_verifier - ); - const lichessUser = await getLichessUser(lichessTokens.accessToken); - return new LichessUserAuth(this.auth, lichessUser, lichessTokens); - }; - - private validateAuthorizationCode = async ( - code: string, - codeVerifier: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - }>(code, "https://lichess.org/api/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - codeVerifier - }); - - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in - }; - }; -} - -const getLichessUser = async (accessToken: string): Promise => { - const request = new Request("https://lichess.org/api/account", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const lichessUser = await handleRequest(request); - return lichessUser; -}; - -export class LichessUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public lichessTokens: LichessTokens; - public lichessUser: LichessUser; - - constructor( - auth: _Auth, - lichessUser: LichessUser, - lichessTokens: LichessTokens - ) { - super(auth, PROVIDER_ID, lichessUser.id); - - this.lichessTokens = lichessTokens; - this.lichessUser = lichessUser; - } -} - -export type LichessTokens = { - accessToken: string; - accessTokenExpiresIn: number; -}; - -export type LichessUser = { - id: string; - username: string; -}; diff --git a/packages/oauth/src/providers/line.ts b/packages/oauth/src/providers/line.ts deleted file mode 100644 index d86aadc50..000000000 --- a/packages/oauth/src/providers/line.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { decodeIdToken } from "../core/oidc.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "line"; - -export const line = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): LineAuth<_Auth> => { - return new LineAuth(auth, config); -}; - -export class LineAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - LineUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - "https://access.line.me/oauth2/v2.1/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["profile", "openid", ...scopeConfig] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const lineTokens = await this.validateAuthorizationCode(code); - const lineUser = await getLineUser( - lineTokens.accessToken, - lineTokens.idToken - ); - return new LineUserAuth(this.auth, lineUser, lineTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - id_token: string; - }>(code, "https://api.line.me/oauth2/v2.1/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - authenticateWith: "client_secret", - clientSecret: this.config.clientSecret - } - }); - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token - }; - }; -} - -export class LineUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public lineTokens: LineTokens; - public lineUser: LineUser; - - constructor(auth: _Auth, lineUser: LineUser, lineTokens: LineTokens) { - super(auth, PROVIDER_ID, lineUser.userId); - - this.lineTokens = lineTokens; - this.lineUser = lineUser; - } -} - -const getLineUser = async ( - accessToken: string, - idToken: string -): Promise => { - const request = new Request("https://api.line.me/v2/profile", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const partialLineUser = await handleRequest>(request); - const idTokenClaims = decodeIdToken<{ email?: string }>(idToken); - return { - email: idTokenClaims.email ?? null, - ...partialLineUser - }; -}; - -export type LineTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; - idToken: string; -}; - -export type LineUser = { - userId: string; - displayName: string; - pictureUrl: string; - statusMessage: string; - email: string | null; -}; diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts deleted file mode 100644 index 5617631b3..000000000 --- a/packages/oauth/src/providers/linkedin.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -const PROVIDER_ID = "linkedin"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -export const linkedIn = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): LinkedInAuth<_Auth> => { - return new LinkedInAuth(auth, config); -}; - -export class LinkedInAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - LinkedInUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - "https://www.linkedin.com/oauth/v2/authorization", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["profile", "openid", ...scopeConfig] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const linkedInTokens = await this.validateAuthorizationCode(code); - const linkedInUser = await getLinkedInUser(linkedInTokens.accessToken); - return new LinkedInUserAuth(this.auth, linkedInUser, linkedInTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - refresh_token_expires_in: number; - scope: string; - }>(code, "https://www.linkedin.com/oauth/v2/accessToken", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token, - refreshTokenExpiresIn: tokens.refresh_token_expires_in, - scope: tokens.scope - }; - }; -} - -export class LinkedInUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public linkedInTokens: LinkedInTokens; - public linkedInUser: LinkedInUser; - - constructor( - auth: _Auth, - linkedInUser: LinkedInUser, - linkedInTokens: LinkedInTokens - ) { - super(auth, PROVIDER_ID, linkedInUser.sub); - - this.linkedInTokens = linkedInTokens; - this.linkedInUser = linkedInUser; - } -} - -const getLinkedInUser = async (accessToken: string): Promise => { - const request = new Request("https://api.linkedin.com/v2/userinfo", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - return handleRequest(request); -}; - -export type LinkedInTokens = { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; - refreshTokenExpiresIn: number; - scope: string; -}; - -export type LinkedInUser = { - sub: string; - name: string; - email: string; - email_verified: boolean; - given_name: string; - family_name: string; - locale: { - country: string; - language: string; - }; - picture: string; -}; diff --git a/packages/oauth/src/providers/osu.ts b/packages/oauth/src/providers/osu.ts deleted file mode 100644 index b087e9a56..000000000 --- a/packages/oauth/src/providers/osu.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "osu"; - -export const osu = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): OsuAuth<_Auth> => { - return new OsuAuth(auth, config); -}; - -export class OsuAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - OsuUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - "https://osu.ppy.sh/oauth/authorize", - { - clientId: this.config.clientId, - scope: ["identify", ...scopeConfig], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const osuTokens = await this.validateAuthorizationCode(code); - const osuUser = await getOsuUser(osuTokens.accessToken); - return new OsuUserAuth(this.auth, osuUser, osuTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - expires_in: number; - refresh_token: string; - token_type: string; - }>(code, "https://osu.ppy.sh/oauth/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - accessTokenExpiresIn: tokens.expires_in - }; - }; -} - -export class OsuUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public osuTokens: OsuTokens; - public osuUser: OsuUser; - - constructor(auth: _Auth, osuUser: OsuUser, osuTokens: OsuTokens) { - super(auth, PROVIDER_ID, osuUser.id.toString()); - - this.osuTokens = osuTokens; - this.osuUser = osuUser; - } -} - -const getOsuUser = async (accessToken: string): Promise => { - const request = new Request("https://osu.ppy.sh/api/v2/me/osu", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const osuUser = await handleRequest(request); - return osuUser; -}; - -export type OsuTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; - -export type OsuUser = { - avatar_url: string; - country_code: string; - default_group: string; - id: number; - is_active: boolean; - is_bot: boolean; - is_deleted: boolean; - is_online: boolean; - is_supporter: boolean; - last_visit: string; - pm_friends_only: boolean; - profile_colour: string | null; - username: string; - country: { - code: string; - name: string; - }; - cover: { - custom_url: string | null; - url: string; - id: string | null; - }; - discord: string | null; - has_supported: boolean; - interests: string | null; - join_date: string; - kudosu: { - available: number; - total: number; - }; - location: string | null; - max_blocks: number; - max_friends: number; - occupation: string | null; - playmode: OsuGameMode; - playstyle: ("mouse" | "keyboard" | "tablet" | "touch")[]; - post_count: number; - profile_order: ( - | "me" - | "recent_activity" - | "beatmaps" - | "historical" - | "kudosu" - | "top_ranks" - | "medals" - )[]; - title: string | null; - title_url: string | null; - twitter: string | null; - website: string | null; - is_restricted: boolean; - account_history: { - description: string | null; - id: number; - length: number; - permanent: boolean; - timestamp: string; - type: "note" | "restriction" | "silence"; - }[]; - active_tournament_banner: { - id: number; - tournament_id: number; - image: string; - } | null; - badges: { - awarded_at: string; - description: string; - image_url: string; - url: string; - }[]; - beatmap_playcounts_count: number; - favourite_beatmapset_count: number; - follower_count: number; - graveyard_beatmapset_count: number; - groups: { - colour: string | null; - has_listing: boolean; - has_playmodes: boolean; - id: number; - identifier: string; - is_probationary: boolean; - name: string; - short_name: string; - playmodes: OsuGameMode[] | null; - }[]; - loved_beatmapset_count: number; - mapping_follower_count: number; - monthly_playcounts: { - start_date: string; - count: number; - }[]; - page: { - html: string; - raw: string; - }; - pending_beatmapset_count: number; - previous_usernames: string[]; - rank_highest: { - rank: number; - updated_at: string; - } | null; - rank_history: { - mode: OsuGameMode; - data: number[]; - }; - ranked_beatmapset_count: number; - replays_watched_counts: { - start_date: string; - count: number; - }[]; - scores_best_count: number; - scores_first_count: number; - scores_recent_count: number; - statistics: OsuUserStatistics; - statistics_rulesets: Record; - support_level: number; - user_achievements: { - achieved_at: string; - achievement_id: number; - }[]; -}; - -type OsuUserStatistics = { - grade_counts: { - a: number; - s: number; - sh: number; - ss: number; - ssh: number; - }; - hit_accuracy: number; - is_ranked: boolean; - level: { - current: number; - progress: number; - }; - maximum_combo: number; - play_count: number; - play_time: number; - pp: number; - global_rank: number; - ranked_score: number; - replays_watched_by_others: number; - total_hits: number; - total_score: number; - country_rank: number; -}; - -type OsuGameMode = "fruits" | "mania" | "osu" | "taiko"; diff --git a/packages/oauth/src/providers/patreon.ts b/packages/oauth/src/providers/patreon.ts deleted file mode 100644 index 273aa1aec..000000000 --- a/packages/oauth/src/providers/patreon.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { - handleRequest, - authorizationHeader, - createUrl -} from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "patreon"; - -export const patreon = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): PatreonAuth<_Auth> => { - return new PatreonAuth(auth, config); -}; - -export class PatreonAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - PatreonUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - "https://www.patreon.com/oauth2/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["identity", ...scopeConfig] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const patreonTokens = await this.validateAuthorizationCode(code); - const patreonUser = await getPatreonUser(patreonTokens.accessToken); - return new PatreonUserAuth(this.auth, patreonUser, patreonTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token?: string; - expires_in: number; - }>(code, "https://www.patreon.com/api/oauth2/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? null, - accessTokenExpiresIn: tokens.expires_in - }; - }; -} - -export class PatreonUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public patreonTokens: PatreonTokens; - public patreonUser: PatreonUser; - - constructor( - auth: _Auth, - patreonUser: PatreonUser, - patreonTokens: PatreonTokens - ) { - super(auth, PROVIDER_ID, patreonUser.id); - this.patreonTokens = patreonTokens; - this.patreonUser = patreonUser; - } -} - -const getPatreonUser = async (accessToken: string): Promise => { - const requestUrl = createUrl( - "https://www.patreon.com/api/oauth2/v2/identity", - { - "fields[user]": - "about,email,full_name,hide_pledges,image_url,is_email_verified,url" - } - ); - const request = new Request(requestUrl, { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const { data: patreonUser } = await handleRequest<{ - data: PatreonUser; - }>(request); - - return patreonUser; -}; - -export type PatreonTokens = { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresIn: number; -}; - -export type PatreonUser = { - id: string; - attributes: { - about: string | null; - created: string; - email?: string; - full_name: string; - hide_pledges: boolean | null; - image_url: string; - is_email_verified: boolean; - url: string; - }; -}; diff --git a/packages/oauth/src/providers/reddit.ts b/packages/oauth/src/providers/reddit.ts deleted file mode 100644 index 4b2515590..000000000 --- a/packages/oauth/src/providers/reddit.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; - tokenDuration: "permanent" | "temporary"; -}; - -const PROVIDER_ID = "reddit"; - -export const reddit = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): RedditAuth<_Auth> => { - return new RedditAuth(auth, config); -}; - -export class RedditAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - RedditUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const [url, state] = await createOAuth2AuthorizationUrl( - "https://www.reddit.com/api/v1/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: this.config.scope ?? [] - } - ); - const tokenDuration = this.config.tokenDuration ?? "permanent"; - url.searchParams.set("duration", tokenDuration); - return [url, state]; - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const redditTokens = await this.validateAuthorizationCode(code); - const redditUser = await getRedditUser(redditTokens.accessToken); - return new RedditUserAuth(this.auth, redditUser, redditTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - }>(code, "https://www.reddit.com/api/v1/access_token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "http_basic_auth" - } - }); - - return { - accessToken: tokens.access_token - }; - }; -} - -export class RedditUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public redditTokens: RedditTokens; - public redditUser: RedditUser; - - constructor(auth: _Auth, redditUser: RedditUser, redditTokens: RedditTokens) { - super(auth, PROVIDER_ID, redditUser.id); - - this.redditTokens = redditTokens; - this.redditUser = redditUser; - } -} - -const getRedditUser = async (accessToken: string): Promise => { - const request = new Request("https://oauth.reddit.com/api/v1/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const redditUser = await handleRequest(request); - return redditUser; -}; - -export type RedditTokens = { - accessToken: string; -}; - -export type RedditUser = { - is_employee: boolean; - seen_layout_switch: boolean; - has_visited_new_profile: boolean; - pref_no_profanity: boolean; - has_external_account: boolean; - pref_geopopular: string; - seen_redesign_modal: boolean; - pref_show_trending: boolean; - subreddit: { - default_set: boolean; - user_is_contributor: boolean; - banner_img: string; - restrict_posting: boolean; - user_is_banned: boolean; - free_form_reports: boolean; - community_icon: string; - show_media: boolean; - icon_color: string; - user_is_muted: boolean; - display_name: string; - header_img: string; - title: string; - coins: number; - previous_names: string[]; - over_18: boolean; - icon_size: [number, number]; - primary_color: string; - icon_img: string; - description: string; - allowed_media_in_comments: any[]; - submit_link_label: string; - header_size: any; - restrict_commenting: boolean; - subscribers: number; - submit_text_label: string; - is_default_icon: boolean; - link_flair_position: string; - display_name_prefixed: string; - key_color: string; - name: string; - is_default_banner: boolean; - url: string; - quarantine: boolean; - banner_size: [number, number]; - user_is_moderator: boolean; - accept_followers: boolean; - public_description: string; - link_flair_enabled: boolean; - disable_contributor_requests: boolean; - subreddit_type: string; - user_is_subscriber: boolean; - }; - pref_show_presence: boolean; - snoovatar_img: string; - snoovatar_size: [number, number]; - gold_expiration: any; - has_gold_subscription: boolean; - is_sponsor: boolean; - num_friends: number; - features: { - mod_service_mute_writes: boolean; - promoted_trend_blanks: boolean; - show_amp_link: boolean; - chat: boolean; - is_email_permission_required: true; - mod_awards: boolean; - expensive_coins_package: boolean; - mweb_xpromo_revamp_v2: { - owner: string; - variant: string; - experiment_id: number; - }; - awards_on_streams: boolean; - mweb_xpromo_modal_listing_click_daily_dismissible_ios: true; - chat_subreddit: boolean; - cookie_consent_banner: boolean; - modlog_copyright_removal: boolean; - do_not_track: boolean; - images_in_comments: boolean; - mod_service_mute_reads: boolean; - chat_user_settings: boolean; - use_pref_account_deployment: boolean; - mweb_xpromo_interstitial_comments_ios: boolean; - mweb_xpromo_modal_listing_click_daily_dismissible_android: boolean; - premium_subscriptions_table: boolean; - mweb_xpromo_interstitial_comments_android: true; - crowd_control_for_post: boolean; - mweb_nsfw_xpromo: { owner: string; variant: string; experiment_id: number }; - noreferrer_to_noopener: boolean; - chat_group_rollout: boolean; - resized_styles_images: boolean; - spez_modal: boolean; - mweb_sharing_clipboard: { - owner: string; - variant: string; - experiment_id: number; - }; - }; - can_edit_name: boolean; - verified: boolean; - pref_autoplay: boolean; - coins: number; - has_paypal_subscription: boolean; - has_subscribed_to_premium: boolean; - id: string; - has_stripe_subscription: boolean; - oauth_client_id: string; - can_create_subreddit: boolean; - over_18: boolean; - is_gold: boolean; - is_mod: boolean; - awarder_karma: number; - suspension_expiration_utc: any; - has_verified_email: boolean; - is_suspended: boolean; - pref_video_autoplay: boolean; - has_android_subscription: boolean; - in_redesign_beta: boolean; - icon_img: string; - pref_nightmode: boolean; - awardee_karma: number; - hide_from_robots: boolean; - password_set: boolean; - link_karma: number; - force_password_reset: boolean; - total_karma: number; - seen_give_award_tooltip: boolean; - inbox_count: number; - seen_premium_adblock_modal: boolean; - pref_top_karma_subreddits: boolean; - pref_show_snoovatar: boolean; - name: string; - pref_clickgadget: number; - created: number; - gold_creddits: number; - created_utc: number; - has_ios_subscription: boolean; - pref_show_twitter: boolean; - in_beta: boolean; - comment_karma: number; - accept_followers: boolean; - has_subscribed: boolean; - linked_identities: any[]; - seen_subreddit_chat_ftux: boolean; -}; diff --git a/packages/oauth/src/providers/salesforce.ts b/packages/oauth/src/providers/salesforce.ts deleted file mode 100644 index 387b3d22b..000000000 --- a/packages/oauth/src/providers/salesforce.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "salesforce"; - -export const salesforce = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): SalesforceAuth<_Auth> => { - return new SalesforceAuth(auth, config); -}; - -export class SalesforceAuth< - _Auth extends Auth = Auth -> extends OAuth2ProviderAuth> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrl( - "https://login.salesforce.com/services/oauth2/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: ["openid", "id", "profile", ...scopeConfig] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const salesforceTokens = await this.validateAuthorizationCode(code); - const salesforceUser = await getSalesforceUser( - salesforceTokens.accessToken - ); - return new SalesforceUserAuth(this.auth, salesforceUser, salesforceTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token?: string; - id_token: string; - }>(code, "https://login.salesforce.com/services/oauth2/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? null, - idToken: tokens.id_token - }; - }; -} - -export class SalesforceUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public salesforceTokens: SalesforceTokens; - public salesforceUser: SalesforceUser; - - constructor( - auth: _Auth, - salesforceUser: SalesforceUser, - salesforceTokens: SalesforceTokens - ) { - super(auth, PROVIDER_ID, salesforceUser.user_id); - - this.salesforceTokens = salesforceTokens; - this.salesforceUser = salesforceUser; - } -} - -const getSalesforceUser = async ( - accessToken: string -): Promise => { - const request = new Request( - "https://login.salesforce.com/services/oauth2/userinfo", - { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - } - ); - const salesforceUser = await handleRequest(request); - return salesforceUser; -}; - -export type SalesforceTokens = { - accessToken: string; - idToken: string; - refreshToken: string | null; -}; - -export type SalesforceUser = { - sub: string; // URL - user_id: string; - organization_id: string; - name: string; - email?: string; - email_verified: boolean; - given_name: string; - family_name: string; - zoneinfo: string; - photos: { - picture: string; - thumbnail: string; - }; - profile: string; - picture: string; - address?: Record; - urls: Record; - active: boolean; - user_type: string; - language: string; - locale: string; - utcOffset: number; - updated_at: string; -}; diff --git a/packages/oauth/src/providers/slack.ts b/packages/oauth/src/providers/slack.ts deleted file mode 100644 index 3fbf54abb..000000000 --- a/packages/oauth/src/providers/slack.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { generateRandomString } from "lucia/utils"; -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "slack"; - -export const slack = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): SlackAuth<_Auth> => { - return new SlackAuth(auth, config); -}; - -export class SlackAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - SlackUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - const [url, state] = await createOAuth2AuthorizationUrl( - "https://slack.com/openid/connect/authorize", - { - clientId: this.config.clientId, - scope: ["openid", "profile", ...scopeConfig], - redirectUri: this.config.redirectUri - } - ); - url.searchParams.set("nonce", generateRandomString(40)); - return [url, state]; - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const slackTokens = await this.validateAuthorizationCode(code); - const slackUserRequest = new Request( - "https://slack.com/api/openid.connect.userInfo", - { - headers: { - Authorization: authorizationHeader("bearer", slackTokens.accessToken) - } - } - ); - const slackUser = await handleRequest(slackUserRequest); - return new SlackUserAuth(this.auth, slackUser, slackTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - id_token: string; - }>(code, "https://slack.com/api/openid.connect.token", { - clientId: this.config.clientId, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - }, - redirectUri: this.config.redirectUri - }); - return { - accessToken: tokens.access_token, - idToken: tokens.id_token - }; - }; -} - -export class SlackUserAuth<_Auth extends Auth> extends ProviderUserAuth<_Auth> { - public slackTokens: SlackTokens; - public slackUser: SlackUser; - - constructor(auth: _Auth, slackUser: SlackUser, slackTokens: SlackTokens) { - super(auth, PROVIDER_ID, slackUser.sub); - - this.slackTokens = slackTokens; - this.slackUser = slackUser; - } -} - -export type SlackTokens = { - accessToken: string; - idToken: string; -}; - -export type SlackUser = { - sub: string; - "https://slack.com/user_id": string; - "https://slack.com/team_id": string; - email?: string; - email_verified: boolean; - date_email_verified: number; - name: string; - picture: string; - given_name: string; - family_name: string; - locale: string; - "https://slack.com/team_name": string; - "https://slack.com/team_domain": string; - "https://slack.com/user_image_24": string; - "https://slack.com/user_image_32": string; - "https://slack.com/user_image_48": string; - "https://slack.com/user_image_72": string; - "https://slack.com/user_image_192": string; - "https://slack.com/user_image_512": string; - "https://slack.com/team_image_34": string; - "https://slack.com/team_image_44": string; - "https://slack.com/team_image_68": string; - "https://slack.com/team_image_88": string; - "https://slack.com/team_image_102": string; - "https://slack.com/team_image_132": string; - "https://slack.com/team_image_230": string; - "https://slack.com/team_image_default": true; -}; diff --git a/packages/oauth/src/providers/spotify.ts b/packages/oauth/src/providers/spotify.ts deleted file mode 100644 index 4e9865059..000000000 --- a/packages/oauth/src/providers/spotify.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "spotify"; - -export const spotify = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): SpotifyAuth<_Auth> => { - return new SpotifyAuth(auth, config); -}; - -export class SpotifyAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - SpotifyUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://accounts.spotify.com/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: this.config.scope ?? [] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const spotifyTokens = await this.validateAuthorizationCode(code); - const spotifyUser = await getSpotifyUser(spotifyTokens.accessToken); - return new SpotifyUserAuth(this.auth, spotifyUser, spotifyTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - token_type: string; - scope: string; - expires_in: number; - refresh_token: string; - }>(code, "https://accounts.spotify.com/api/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "http_basic_auth" - } - }); - - return { - accessToken: tokens.access_token, - tokenType: tokens.token_type, - scope: tokens.scope, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token - }; - }; -} - -export class SpotifyUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public spotifyTokens: SpotifyTokens; - public spotifyUser: SpotifyUser; - - constructor( - auth: _Auth, - spotifyUser: SpotifyUser, - spotifyTokens: SpotifyTokens - ) { - super(auth, PROVIDER_ID, spotifyUser.id); - - this.spotifyTokens = spotifyTokens; - this.spotifyUser = spotifyUser; - } -} - -const getSpotifyUser = async (accessToken: string): Promise => { - // https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile - const request = new Request("https://api.spotify.com/v1/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - return handleRequest(request); -}; - -export type SpotifyTokens = { - accessToken: string; - tokenType: string; - scope: string; - accessTokenExpiresIn: number; - refreshToken: string; -}; - -export type SpotifyUser = { - country?: string; - display_name: string | null; - email?: string; - explicit_content: { - filter_enabled?: boolean; - filter_locked?: boolean; - }; - external_urls: { - spotify: string; - }; - followers: { - href: string | null; - total: number; - }; - href: string; - id: string; - images: [ - { - url: string; - height: number | null; - width: number | null; - } - ]; - product?: string; - type: string; - uri: string; -}; diff --git a/packages/oauth/src/providers/strava.ts b/packages/oauth/src/providers/strava.ts deleted file mode 100644 index 67e1f6e4d..000000000 --- a/packages/oauth/src/providers/strava.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri?: string; -}; - -const PROVIDER_ID = "strava"; - -export const strava = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): StravaAuth<_Auth> => { - return new StravaAuth(auth, config); -}; - -export class StravaAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - StravaUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://www.strava.com/oauth/authorize", - { - clientId: this.config.clientId, - scope: this.config.scope ?? ["read"], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const [stravaUser, stravaTokens] = await this.validateAuthorizationCode( - code - ); - return new StravaUserAuth(this.auth, stravaUser, stravaTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise<[stravaUser: StravaUser, stravaTokens: StravaTokens]> => { - const { athlete: user, ...tokens } = - await validateOAuth2AuthorizationCode( - code, - "https://www.strava.com/oauth/token", - { - clientId: this.config.clientId, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - } - ); - if ("refresh_token" in tokens) { - return [ - user, - { - accessToken: tokens.access_token, - accessTokenExpiresIn: tokens.expires_in, - refreshToken: tokens.refresh_token - } - ]; - } - return [ - user, - { - accessToken: tokens.access_token - } - ]; - }; -} - -export class StravaUserAuth< - _Auth extends Auth -> extends ProviderUserAuth<_Auth> { - public stravaTokens: StravaTokens; - public stravaUser: StravaUser; - - constructor(auth: _Auth, stravaUser: StravaUser, stravaTokens: StravaTokens) { - super(auth, PROVIDER_ID, stravaUser.id.toString()); - - this.stravaTokens = stravaTokens; - this.stravaUser = stravaUser; - } -} - -type AccessTokenResponseBody = - | { - access_token: string; - athlete: StravaUser; - } - | { - access_token: string; - refresh_token: string; - expires_in: number; - expires_at: number; - athlete: StravaUser; - }; - -export type StravaTokens = - | { - accessToken: string; - } - | { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; - }; - -export type StravaUser = { - id: number; - username: string; - resource_state: number; - firstname: string; - lastname: string; - bio: string; - city: string; - country: string; - sex: string; - premium: boolean; - summit: boolean; - created_at: string; - updated_at: string; - badge_type_id: number; - weight: number; - profile_medium: string; - profile: string; -}; diff --git a/packages/oauth/src/providers/twitch.ts b/packages/oauth/src/providers/twitch.ts deleted file mode 100644 index 256648814..000000000 --- a/packages/oauth/src/providers/twitch.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - OAuth2ProviderAuth, - createOAuth2AuthorizationUrl, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - scope?: string[]; - redirectUri: string; -}; - -const PROVIDER_ID = "twitch"; - -export const twitch = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): TwitchAuth<_Auth> => { - return new TwitchAuth(auth, config); -}; - -export class TwitchAuth<_Auth extends Auth = Auth> extends OAuth2ProviderAuth< - TwitchUserAuth<_Auth> -> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, state: string] - > => { - return await createOAuth2AuthorizationUrl( - "https://id.twitch.tv/oauth2/authorize", - { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - scope: this.config.scope ?? [] - } - ); - }; - - public validateCallback = async ( - code: string - ): Promise> => { - const twitchTokens = await this.validateAuthorizationCode(code); - const twitchUser = await getTwitchUser( - this.config.clientId, - twitchTokens.accessToken - ); - return new TwitchUserAuth(this.auth, twitchUser, twitchTokens); - }; - - private validateAuthorizationCode = async ( - code: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token: string; - expires_in: number; - }>(code, "https://id.twitch.tv/oauth2/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - clientPassword: { - clientSecret: this.config.clientSecret, - authenticateWith: "client_secret" - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - accessTokenExpiresIn: tokens.expires_in - }; - }; -} - -export class TwitchUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public twitchTokens: TwitchTokens; - public twitchUser: TwitchUser; - - constructor(auth: _Auth, twitchUser: TwitchUser, twitchTokens: TwitchTokens) { - super(auth, PROVIDER_ID, twitchUser.id); - - this.twitchTokens = twitchTokens; - this.twitchUser = twitchUser; - } -} - -const getTwitchUser = async ( - clientId: string, - accessToken: string -): Promise => { - // https://dev.twitch.tv/docs/api/reference/#get-users - const request = new Request("https://api.twitch.tv/helix/users", { - headers: { - "Client-ID": clientId, - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const twitchUsersResponse = await handleRequest<{ - data: TwitchUser[]; - }>(request); - return twitchUsersResponse.data[0]; -}; - -export type TwitchTokens = { - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; -}; - -export type TwitchUser = { - id: string; - login: string; - display_name: string; - type: "" | "admin" | "staff" | "global_mod"; - broadcaster_type: "" | "affiliate" | "partner"; - description: string; - profile_image_url: string; - offline_image_url: string; - view_count: number; - email?: string; - created_at: string; -}; diff --git a/packages/oauth/src/providers/twitter.ts b/packages/oauth/src/providers/twitter.ts deleted file mode 100644 index c80624699..000000000 --- a/packages/oauth/src/providers/twitter.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - OAuth2ProviderAuthWithPKCE, - createOAuth2AuthorizationUrlWithPKCE, - validateOAuth2AuthorizationCode -} from "../core/oauth2.js"; -import { ProviderUserAuth } from "../core/provider.js"; -import { handleRequest, authorizationHeader } from "../utils/request.js"; - -import type { Auth } from "lucia"; - -type Config = { - clientId: string; - clientSecret: string; - redirectUri: string; - scope?: string[]; -}; - -const PROVIDER_ID = "twitter"; - -export const twitter = <_Auth extends Auth = Auth>( - auth: _Auth, - config: Config -): TwitterAuth<_Auth> => { - return new TwitterAuth(auth, config); -}; - -export class TwitterAuth< - _Auth extends Auth = Auth -> extends OAuth2ProviderAuthWithPKCE> { - private config: Config; - - constructor(auth: _Auth, config: Config) { - super(auth); - this.config = config; - } - - public getAuthorizationUrl = async (): Promise< - readonly [url: URL, codeVerifier: string, state: string] - > => { - const scopeConfig = this.config.scope ?? []; - return await createOAuth2AuthorizationUrlWithPKCE( - "https://twitter.com/i/oauth2/authorize", - { - clientId: this.config.clientId, - codeChallengeMethod: "S256", - scope: ["tweet.read", "users.read", ...scopeConfig], - redirectUri: this.config.redirectUri - } - ); - }; - - public validateCallback = async ( - code: string, - code_verifier: string - ): Promise> => { - const twitterTokens = await this.validateAuthorizationCode( - code, - code_verifier - ); - const twitterUser = await getTwitterUser(twitterTokens.accessToken); - return new TwitterUserAuth(this.auth, twitterUser, twitterTokens); - }; - - private validateAuthorizationCode = async ( - code: string, - codeVerifier: string - ): Promise => { - const tokens = await validateOAuth2AuthorizationCode<{ - access_token: string; - refresh_token?: string; - }>(code, "https://api.twitter.com/2/oauth2/token", { - clientId: this.config.clientId, - redirectUri: this.config.redirectUri, - codeVerifier, - clientPassword: { - authenticateWith: "http_basic_auth", - clientSecret: this.config.clientSecret - } - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token ?? null - }; - }; -} - -export class TwitterUserAuth< - _Auth extends Auth = Auth -> extends ProviderUserAuth<_Auth> { - public twitterTokens: TwitterTokens; - public twitterUser: TwitterUser; - - constructor( - auth: _Auth, - twitterUser: TwitterUser, - twitterTokens: TwitterTokens - ) { - super(auth, PROVIDER_ID, twitterUser.id); - this.twitterTokens = twitterTokens; - this.twitterUser = twitterUser; - } -} - -const getTwitterUser = async (accessToken: string): Promise => { - const request = new Request("https://api.twitter.com/2/users/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const twitterUserResult = await handleRequest<{ - data: TwitterUser; - }>(request); - return twitterUserResult.data; -}; - -export type TwitterTokens = { - accessToken: string; - refreshToken: string | null; -}; - -export type TwitterUser = { - id: string; - name: string; - username: string; -}; diff --git a/packages/oauth/src/utils/crypto.ts b/packages/oauth/src/utils/crypto.ts deleted file mode 100644 index a045364db..000000000 --- a/packages/oauth/src/utils/crypto.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getPKCS8Key = (pkcs8: string) => { - return [ - "\n", - pkcs8 - .replace(/-----BEGIN PRIVATE KEY-----/, "") - .replace(/-----END PRIVATE KEY-----/, ""), - "\n" - ].join(""); -}; diff --git a/packages/oauth/src/utils/encode.ts b/packages/oauth/src/utils/encode.ts deleted file mode 100644 index 22ecf32eb..000000000 --- a/packages/oauth/src/utils/encode.ts +++ /dev/null @@ -1,53 +0,0 @@ -const isDeno = () => { - return typeof window !== "undefined" && "Deno" in window; -}; - -export const encodeBase64 = ( - data: string | ArrayLike | ArrayBufferLike -) => { - // ORDER IMPORTANT - // buffer API exists in deno - - // ignore deprecation for `btoa()` - if (isDeno()) { - // deno - if (typeof data === "string") return btoa(data); - return btoa(String.fromCharCode(...new Uint8Array(data))); - } - if (typeof Buffer === "function") { - // node or bun - const bufferData = typeof data === "string" ? data : new Uint8Array(data); - return Buffer.from(bufferData).toString("base64"); - } - if (typeof data === "string") return btoa(data); - return btoa(String.fromCharCode(...new Uint8Array(data))); -}; - -export const encodeBase64Url = ( - data: string | ArrayLike | ArrayBufferLike -) => { - return encodeBase64(data) - .replaceAll("=", "") - .replaceAll("+", "-") - .replaceAll("/", "_"); -}; - -export const decodeBase64 = (data: string) => { - // ORDER IMPORTANT - // buffer API exists in deno - - // ignore deprecation for `btoa()` - if (isDeno()) { - // deno - return Uint8Array.from(atob(data).split(""), (x) => x.charCodeAt(0)); - } - if (typeof Buffer === "function") { - // node or bun - return new Uint8Array(Buffer.from(data, "base64")); - } - return Uint8Array.from(atob(data).split(""), (x) => x.charCodeAt(0)); -}; - -export const decodeBase64Url = (data: string) => { - return decodeBase64(data.replaceAll("-", "+").replaceAll("_", "/")); -}; diff --git a/packages/oauth/src/utils/jwt.test.ts b/packages/oauth/src/utils/jwt.test.ts deleted file mode 100644 index 0982a661d..000000000 --- a/packages/oauth/src/utils/jwt.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { importPKCS8 } from "jose"; -import { test, expect } from "vitest"; -import { SignJWT, jwtVerify } from "jose"; -import { createES256SignedJWT } from "./jwt.js"; -import { getPKCS8Key } from "../utils/crypto.js"; - -test("createES256SignedJWT()", async () => { - const keyId = "KEY_ID"; - const issuer = "TEAM_ID"; - const audience = "https://appleid.apple.com"; - - const certificate = `-----BEGIN PRIVATE KEY----- -MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgMKZfBMuF5CyRyXAi -QaYew+j3U+bQ5oRtqb/3SujLlZGgCgYIKoZIzj0DAQehRANCAAQoYTJsktscfGFw -Am40TH2648sGmHS5qvti8tvRcXF6v5Gu0fecAPooXDFqn63ZfStZjQm/3cMsPWwD -Ko3QryFm ------END PRIVATE KEY-----`; - - const now = Math.floor(new Date().getTime()); - const payload = { - iss: issuer, - iat: now, - exp: now + 60 * 3, - aud: audience, - sub: "CLIENT_ID" - }; - - const getJoseJwt = async () => { - const cert = await importPKCS8(certificate, "ES256"); - const jwt = await new SignJWT(payload) - .setProtectedHeader({ alg: "ES256", kid: keyId }) - .sign(cert); - - return jwt; - }; - - const getJWT = async () => { - const privateKey = getPKCS8Key(certificate); - const jwt = await createES256SignedJWT( - { - alg: "ES256", - kid: keyId - }, - payload, - privateKey - ); - return jwt; - }; - - const verifyJWT = async (jwt: string) => { - await jwtVerify(jwt, await importPKCS8(certificate, "ES256"), { - issuer, - audience, - algorithms: ["ES256"] - }); - }; - - // ie. expect the promise to resolve without any errors - await expect(verifyJWT(await getJoseJwt())).resolves.to.not.toThrowError(); - await expect(verifyJWT(await getJWT())).resolves.to.not.toThrowError(); -}); diff --git a/packages/oauth/src/utils/jwt.ts b/packages/oauth/src/utils/jwt.ts deleted file mode 100644 index f7534646f..000000000 --- a/packages/oauth/src/utils/jwt.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { decodeBase64, encodeBase64Url } from "../utils/encode.js"; - -const encoder = new TextEncoder(); - -export const createES256SignedJWT = async ( - protectedHeader: { - alg: "ES256"; - kid?: string; - }, - payload: Record, - privateKey: string -) => { - const cryptoKey = await crypto.subtle.importKey( - "pkcs8", - decodeBase64(privateKey), - { - name: "ECDSA", - namedCurve: "P-256" - }, - true, - ["sign"] - ); - const base64UrlHeader = encodeBase64Url(JSON.stringify(protectedHeader)); - const base64UrlPayload = encodeBase64Url(JSON.stringify(payload)); - const signatureBody = [base64UrlHeader, base64UrlPayload].join("."); - const signatureBuffer = await crypto.subtle.sign( - { - name: "ECDSA", - hash: "SHA-256" - }, - cryptoKey, - encoder.encode(signatureBody) - ); - const signature = encodeBase64Url(signatureBuffer); - const jwt = [signatureBody, signature].join("."); - return jwt; -}; diff --git a/packages/oauth/src/utils/request.ts b/packages/oauth/src/utils/request.ts deleted file mode 100644 index 9f152813c..000000000 --- a/packages/oauth/src/utils/request.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { OAuthRequestError } from "../core/request.js"; - -export const handleRequest = async <_ResponseBody extends {}>( - request: Request -): Promise<_ResponseBody> => { - request.headers.set("User-Agent", "lucia"); - request.headers.set("Accept", "application/json"); - const response = await fetch(request); - if (!response.ok) { - throw new OAuthRequestError(request, response); - } - return (await response.json()) as _ResponseBody; -}; - -export const createUrl = ( - url: string | URL, - urlSearchParams: Record -): URL => { - const newUrl = new URL(url); - for (const [key, value] of Object.entries(urlSearchParams)) { - if (!value) continue; - newUrl.searchParams.set(key, value); - } - return newUrl; -}; - -export const authorizationHeader = ( - type: "bearer" | "basic", - token: string -): string => { - if (type === "basic") { - return ["Basic", token].join(" "); - } - if (type === "bearer") { - return ["Bearer", token].join(" "); - } - throw new TypeError("Invalid token type"); -}; - -export const originFromDomain = (domain: string): string => { - if (domain.startsWith("https://") || domain.startsWith("http://")) { - return domain; - } - return "https://" + domain; -}; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 541d78ad3..3a22a3ad0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - "packages/*" - "documentation/**" + - "documentation-v3/**"

qEIEEraMLk+K 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 z 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� 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 z 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 diff --git a/documentation/integrations/og/logo.png b/documentation/integrations/og/logo.png deleted file mode 100644 index 81ca14d2d79f4a64b4ea00af9607b7df9e0fb930..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1830 zcmY*a3s_QV8a`;^C26#2l`It~(=p|eV+o@)qiA4x$uUK3S3RaIgF*oYJzml#5RqoI zG_i82HJh4{N-eU~O+`(kR2;jgd7I4CscdbSyyYIub$0*r{O9}r?|Z-ZyPW@dPHt%M zE`)`h1pojD8kG_TNe5^A68d145F|)9m~j}?5`@q4!O(+3QGOh5B5FGpkHuri7AO?Tn|mOM z6h;YH4u?)Yn7{M+2T3@bKp?;hJg^)t14kedi8%Z=+_r7*5W<~zgw2l^y0dwkK9T&F zhmy!k;4%;LnH)CCz#9*8Qu#g@j3Lp=`cx;Mne=BSHgCBts6m{;gCk(^xD{?F)Z1Vs zg>adPP-X)^nc%&I{6E`r9&el>`TrF2snaDZ)GFD+8@Dnxvc>wp4VwbMs>d|SH{o>i zo5c?}Mf=t@KdGw=?Zi+TV8hlM&%VmiWOunB_om|yjVun`-`P+Q+zCSy2rY82u53wp zZLd++g%3t(tFpD|hS2DX>Q2*Y)u8(Ao4VeCR_A!Z55Bq_8%&@L*^f@MM6%MEkUElRQ{kz-lU)7=x$#__?{^( z+RiR?$lMH-EVK4=J6-ec*867#^65tL{P*_)XJ^3;v6(&*&FxcptWg3eUK0@fvZYVQ zh#6Ve^YE;0hNp8~Z59`CXW_~gD+2@XqiyavHfEA)Ie#VI__@U6oacw`j#aQ2+V?VY zix&e7A(>8YuDqoYU7q*Dr~mTvYjxKC5{K&i!}aUVD_zv5pbpT7HN<%df9d>keYOg?R7 zC^(V{c0PKGoo4!t-vP~w!)3|%>u)sJ)Z~ZNqC<_e1sjW<0@hIW09qs z+l^lzo}%Ua>x<(J#vf0<*TKbVB@fxgDJGRC~PUjBcR9;^Zi|>f`vLq8V zrEqm%tBN)^@Z)r%Uei5AWIz)>y^#Wk)8oLl>{u&jz}JhYQ-+(3nnXHf>h<-`&;MNv zXFRdU3b_d`Ra|UNfG0`ac(%DE%^?G-cQ*h4~w+QHs2mB zRi5#<{c?JE(E5$zSk;lMYEIznhzGMGvNL)M;)W8smc$oq0#xrYgeiOs^X~FBO2#&_ z?g}NQ^nKedRWSLdTGxWdGc*Y1YiZ*sj%&*PX8-VDICj2|Q$zV^AG>}tFR4(IMmXO5 z8JyBTp7S_53+t6$WWz|Gy?dLbwcM27)_*k{W&_{;$9k-?y=E#%bd$}z>R2|as6dW4 znJ`Z5UVPGXIsEINCfd9C9YeO3ST9y=CcF8sxxIaP+`CQcALI7^erFY9bZV&4$aW!9 hRh{bU_QPkQqd?ga+E>Pa*_3l)`Ktf` diff --git a/documentation/integrations/search/index.ts b/documentation/integrations/search/index.ts deleted file mode 100644 index 1cd072e80..000000000 --- a/documentation/integrations/search/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { AstroIntegration } from "astro"; - -export default () => { - const integration: AstroIntegration = { - name: "lucia:search", - hooks: { - "astro:config:setup": ({ injectScript }) => { - process.env.BUILD_ID = crypto.randomUUID(); - if (import.meta.env.DEV) { - injectScript("page", `localStorage.removeItem("search:content")`); - } - } - } - }; - return integration; -}; diff --git a/documentation/package.json b/documentation/package.json deleted file mode 100644 index 78decccdd..000000000 --- a/documentation/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "lucia-documentation", - "type": "module", - "version": "0.0.1", - "repository": { - "type": "git", - "url": "https://github.com/lucia-auth/lucia", - "directory": "documentation" - }, - "author": "pilcrowOnPaper", - "license": "MIT", - "scripts": { - "dev": "astro dev --port 3000", - "start": "astro dev", - "build": "astro build", - "preview": "astro preview", - "astro": "astro", - "auri.deploy": "curl -X POST $CF_DEPLOY_HOOK" - }, - "dependencies": { - "@astrojs/tailwind": "^5.0.0", - "astro": "^3.0.8", - "shiki": "^0.14.2", - "tailwindcss": "^3.0.24" - }, - "devDependencies": { - "@astrojs/markdown-remark": "^3.0.0", - "@napi-rs/canvas": "^0.1.42", - "@types/hast": "^2.3.4", - "canvaskit-wasm": "^0.38.2", - "sharp": "^0.32.4" - } -} diff --git a/documentation/public/guidebook-logo.svg b/documentation/public/guidebook-logo.svg deleted file mode 100644 index 5ddcd145d..000000000 --- a/documentation/public/guidebook-logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - \ No newline at end of file diff --git a/documentation/public/logo.svg b/documentation/public/logo.svg deleted file mode 100644 index e04dfd2da..000000000 --- a/documentation/public/logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - \ No newline at end of file diff --git a/documentation/public/og/index.jpg b/documentation/public/og/index.jpg deleted file mode 100644 index 5b0ac0e385811aad8260a3ffd86193a85dd40536..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24977 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 ROJArQhb8^xq4|5^{{YvwV9@{o diff --git a/documentation/src/components/CodeBlock.astro b/documentation/src/components/CodeBlock.astro deleted file mode 100644 index 7d708e88f..000000000 --- a/documentation/src/components/CodeBlock.astro +++ /dev/null @@ -1,21 +0,0 @@ ---- -import { Code } from "astro/components"; - -import type { Lang, ILanguageRegistration } from "shiki"; - -type Props = { - code: string; - lang: Lang | ILanguageRegistration; -}; ---- - - - diff --git a/documentation/src/components/Header.astro b/documentation/src/components/Header.astro deleted file mode 100644 index 0739cbfb5..000000000 --- a/documentation/src/components/Header.astro +++ /dev/null @@ -1,133 +0,0 @@ ---- -import MenuIcon from "@icons/MenuIcon.astro"; -import MoreIcon from "@icons/MoreIcon.astro"; -import SearchIcon from "../icons/Search.astro"; -import Search from "./Search.astro"; - -import { comparePathname } from "@utils/url"; - -const blogPage = comparePathname(Astro.url.pathname, "/blog"); -const guidebook = comparePathname(Astro.url.pathname, "/guidebook"); - -const menuButtonVisible = !blogPage && !guidebook; ---- - - -