diff --git a/docs/pages/tutorials/github-oauth/astro.md b/docs/pages/tutorials/github-oauth/astro.md index c65a78b9a..077f313fb 100644 --- a/docs/pages/tutorials/github-oauth/astro.md +++ b/docs/pages/tutorials/github-oauth/astro.md @@ -133,7 +133,6 @@ import { OAuth2RequestError } from "arctic"; import { generateId } from "lucia"; import type { APIContext } from "astro"; -import type { DatabaseUser } from "../../../lib/db"; export async function GET(context: APIContext): Promise { const code = context.url.searchParams.get("code"); @@ -210,7 +209,7 @@ const username = user.username; 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 "../../auth"; +import { lucia } from "@lib/auth"; import type { APIContext } from "astro"; export async function POST(context: APIContext): Promise { diff --git a/docs/pages/tutorials/github-oauth/index.md b/docs/pages/tutorials/github-oauth/index.md index 2adf5ca86..fc7b34dc6 100644 --- a/docs/pages/tutorials/github-oauth/index.md +++ b/docs/pages/tutorials/github-oauth/index.md @@ -9,4 +9,5 @@ The tutorials go over how to implement a basic GitHub OAuth and covers the basic - [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/nuxt.md b/docs/pages/tutorials/github-oauth/nuxt.md new file mode 100644 index 000000000..4ed7562be --- /dev/null +++ b/docs/pages/tutorials/github-oauth/nuxt.md @@ -0,0 +1,268 @@ +--- +title: "GitHub OAuth in Nuxt" +--- + +# Tutorial: GitHub OAuth in Nuxt + +Before starting, make sure you've setup 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; + } + 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 a [`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 not. 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/username-and-password/astro.md b/docs/pages/tutorials/username-and-password/astro.md index 23cfd7f5d..40217148d 100644 --- a/docs/pages/tutorials/username-and-password/astro.md +++ b/docs/pages/tutorials/username-and-password/astro.md @@ -74,7 +74,7 @@ Create an API route in `pages/api/signup.ts`. First do a very basic input valida ```ts // pages/api/signup.ts -import { lucia } from "../../auth"; +import { lucia } from "@lib/auth"; import { generateId } from "lucia"; import { Argon2id } from "oslo/password"; @@ -158,7 +158,7 @@ Create an API route as `pages/api/signup.ts`. First do a very basic input valida ```ts // pages/api/login.ts -import { lucia } from "../../auth"; +import { lucia } from "@lib/auth"; import { Argon2id } from "oslo/password"; import type { APIContext } from "astro"; @@ -226,7 +226,7 @@ const username = user.username; 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 "../../auth"; +import { lucia } from "@lib/auth"; import type { APIContext } from "astro"; export async function POST(context: APIContext): Promise { diff --git a/docs/pages/tutorials/username-and-password/index.md b/docs/pages/tutorials/username-and-password/index.md index 244f4c904..cb922f5cc 100644 --- a/docs/pages/tutorials/username-and-password/index.md +++ b/docs/pages/tutorials/username-and-password/index.md @@ -9,4 +9,5 @@ The tutorials go over how to implement a basic username and password auth and co - [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-pages.md b/docs/pages/tutorials/username-and-password/nextjs-pages.md index 7fce49d8f..1281897c3 100644 --- a/docs/pages/tutorials/username-and-password/nextjs-pages.md +++ b/docs/pages/tutorials/username-and-password/nextjs-pages.md @@ -65,14 +65,16 @@ export default function Page() { async function onSubmit(e: FormEvent) { e.preventDefault(); const formElement = e.target as HTMLFormElement; - await fetch(formElement.action, { + const response = await fetch(formElement.action, { method: formElement.method, body: JSON.stringify(Object.fromEntries(new FormData(formElement).entries())), headers: { "Content-Type": "application/json" } }); - router.push("/"); + if (response.ok) { + router.push("/"); + } } return ( @@ -171,14 +173,16 @@ export default function Page() { async function onSubmit(e: FormEvent) { e.preventDefault(); const formElement = e.target as HTMLFormElement; - await fetch(formElement.action, { + const response = await fetch(formElement.action, { method: formElement.method, body: JSON.stringify(Object.fromEntries(new FormData(formElement).entries())), headers: { "Content-Type": "application/json" } }); - router.push("/"); + if (response.ok) { + router.push("/"); + } } 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..036de5929 --- /dev/null +++ b/docs/pages/tutorials/username-and-password/nuxt.md @@ -0,0 +1,315 @@ +--- +title: "Tutorial: Username and password auth in Nuxt" +--- + +# Tutorial: Username and password auth in Nuxt + +Before starting, make sure you've setup 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 `password` column to your user table. + +| column | type | attributes | +| ---------- | -------- | ---------- | +| `username` | `string` | unique | +| `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; + } + 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) { + 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 + + + +```