diff --git a/README.md b/README.md index d101fef0..c440baa0 100644 --- a/README.md +++ b/README.md @@ -42,4 +42,4 @@ TBA If you need credentials for the `.env` file, contact the project lead ([Minh](https://github.com/minhxNguyen7/)). -After changes to the .env file, run `pnpm run check` to update SvelteKit's auto-generated environment variable types. +After changes to the .env file, run `pnpm run check` to update SvelteKit's auto-generated environment variable types. diff --git a/package.json b/package.json index 44ec1ad0..5ea0a85b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@iconify/json": "^2.2.160", "@lucia-auth/adapter-prisma": "3.0.2", + "@lucia-auth/oauth": "^3.5.3", "@prisma/client": "5.6.0", "lucia": "2.7.4", "svelty-picker": "^5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9757405..143c0afd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@lucia-auth/adapter-prisma': specifier: 3.0.2 version: 3.0.2(@prisma/client@5.6.0)(lucia@2.7.4) + '@lucia-auth/oauth': + specifier: ^3.5.3 + version: 3.5.3(lucia@2.7.4) '@prisma/client': specifier: 5.6.0 version: 5.6.0(prisma@5.6.0) @@ -2530,6 +2533,14 @@ packages: lucia: 2.7.4 dev: false + /@lucia-auth/oauth@3.5.3(lucia@2.7.4): + resolution: {integrity: sha512-3blBldenf2zXsSJTZUwzB+NxI+0KJljfMHT0WR7hyLNwi4Ijss7F2cQqk2xW1lGH3lzdmsV93ZsY9Yv8ekBRUw==} + peerDependencies: + lucia: ^2.0.0 + dependencies: + lucia: 2.7.4 + dev: false + /@lukeed/ms@2.0.1: resolution: {integrity: sha512-Xs/4RZltsAL7pkvaNStUQt7netTkyxrS0K+RILcVr3TRMS/ToOg4I6uNfhB9SlGsnWBym4U+EaXq0f0cEMNkHA==} engines: {node: '>=8'} diff --git a/prisma/dev.db b/prisma/dev.db index d0cd72ce..b13b1a9f 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9eff4e71..af305c7b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -26,9 +26,11 @@ export const handle: Handle = async ({ event, resolve }) => { if (event.locals?.auth) { const session = await event.locals.auth.validate(); const user = session?.user; + if (user) { event.locals.user = user; } + if (event.route.id?.startsWith("/(protected)")) { if (!user) throw redirect(302, "/auth"); // if (!user.verified) throw redirect(302, "/auth/verify/email"); diff --git a/src/lib/server/lucia.ts b/src/lib/server/lucia.ts index 710ea0c2..1962e0a5 100644 --- a/src/lib/server/lucia.ts +++ b/src/lib/server/lucia.ts @@ -1,8 +1,14 @@ import { prisma } from "@lucia-auth/adapter-prisma"; +import { google } from "@lucia-auth/oauth/providers"; import { lucia } from "lucia"; import { sveltekit } from "lucia/middleware"; import { dev } from "$app/environment"; +import { + GOOGLE_OAUTH_CLIENT_ID, + GOOGLE_OAUTH_CLIENT_SECRET, + GOOGLE_OAUTH_REDIRECT_URI, +} from "$env/static/private"; import { prisma as client } from "$lib/server/prisma"; export const auth = lucia({ @@ -23,8 +29,21 @@ export const auth = lucia({ verified: data.verified, receiveEmail: data.receiveEmail, token: data.token, + + googleId: data.google_id, + username: data.username, }; }, }); +export const googleAuth = google(auth, { + clientId: GOOGLE_OAUTH_CLIENT_ID!, + clientSecret: GOOGLE_OAUTH_CLIENT_SECRET!, + redirectUri: GOOGLE_OAUTH_REDIRECT_URI!, + scope: [ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + ], +}); + export type Auth = typeof auth; diff --git a/src/routes/auth/+page.svelte b/src/routes/auth/+page.svelte index 4eda3326..e14d0a21 100644 --- a/src/routes/auth/+page.svelte +++ b/src/routes/auth/+page.svelte @@ -24,6 +24,15 @@ {/if} +
+ + Continue with Google + +
+ Sign In Sign Up diff --git a/src/routes/auth/login/google/+server.ts b/src/routes/auth/login/google/+server.ts new file mode 100644 index 00000000..219661ca --- /dev/null +++ b/src/routes/auth/login/google/+server.ts @@ -0,0 +1,31 @@ +import { dev } from "$app/environment"; +import { googleAuth } from "$lib/server/lucia"; + +export const GET = async ({ cookies, locals }) => { + const session = await locals.auth.validate(); + + if (session) { + return new Response(null, { + status: 302, + headers: { + Location: "/", + }, + }); + } + const [url, state] = await googleAuth.getAuthorizationUrl(); + + // Store state. + cookies.set("google_oauth_state", state, { + httpOnly: true, + secure: !dev, + path: "/", + maxAge: 30 * 24 * 60 * 60, + }); + + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + }, + }); +}; diff --git a/src/routes/auth/login/google/callback/+server.ts b/src/routes/auth/login/google/callback/+server.ts new file mode 100644 index 00000000..491aa2d5 --- /dev/null +++ b/src/routes/auth/login/google/callback/+server.ts @@ -0,0 +1,114 @@ +import { OAuthRequestError } from "@lucia-auth/oauth"; +import type { GoogleUser } from "@lucia-auth/oauth/providers"; + +import { auth, googleAuth } from "$lib/server/lucia"; + +const getUser = async (googleUser: GoogleUser) => { + if (!googleUser.email) { + return null; + } + + try { + const dbUser = await auth.getUser(googleUser.email); + if (dbUser) { + return dbUser; + } + } catch (error) { + /* If a user cannot be found, an error is raised and caught here. */ + console.log("User not found in database", error); + } + + const token = crypto.randomUUID(); + const user = await auth.createUser({ + userId: googleUser.email.toLowerCase(), + key: { + providerId: "google", + providerUserId: googleUser.email.toLowerCase(), + password: null, + }, + attributes: { + email: googleUser.email.toLowerCase(), + firstName: googleUser.given_name ?? "", + lastName: googleUser.family_name ?? "", + // role: "USER", + verified: false, + receiveEmail: true, + token: token, + }, + }); + + return user; +}; + +export const GET = async ({ url, cookies, locals }) => { + /** + * Check for a session. if it exists, + * redirect to a page of your liking. + */ + const session = await locals.auth.validate(); + if (session) { + return new Response(null, { + status: 302, + headers: { + Location: "/auth", + }, + }); + } + + /** + * Validate state of the request. + */ + const storedState = cookies.get("google_oauth_state") ?? null; + const state = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + if (!storedState || !state || storedState !== state || !code) { + return new Response(null, { + status: 400, + }); + } + + try { + const { googleUser } = await googleAuth.validateCallback(code); + const user = await getUser(googleUser); + + if (!user) { + /** + * You should probably redirect the user to a page and show a + * message that the account could not be created. + * + * This is a very rare case, but it can happen. + */ + return new Response(null, { + status: 500, + }); + } + + const session = await auth.createSession({ + userId: user.userId, + attributes: {}, + }); + + locals.auth.setSession(session); + + return new Response(null, { + status: 302, + headers: { + Location: "/auth", + }, + }); + } catch (e) { + console.log(e); + + // Invalid code. + if (e instanceof OAuthRequestError) { + return new Response(null, { + status: 400, + }); + } + + // All other errors. + return new Response(null, { + status: 500, + }); + } +};