diff --git a/frontend/package.json b/frontend/package.json index 64744e8..ffe8a5c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.0.1", + "version": "0.1.0", "private": true, "scripts": { "dev": "vite dev", @@ -19,12 +19,14 @@ "@tailwindcss/forms": "^0.5.4", "@tailwindcss/typography": "^0.5.9", "@types/chart.js": "^2.9.37", + "@types/cookie": "^0.5.1", "@types/uuid": "^9.0.2", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", "@urql/svelte": "^4.0.3", "autoprefixer": "^10.4.14", "chart.js": "^4.3.0", + "cookie": "^0.5.0", "daisyui": "^3.2.1", "dotenv": "^16.3.1", "eslint": "^8.28.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d771f88..7ebd1c0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ devDependencies: '@types/chart.js': specifier: ^2.9.37 version: 2.9.37 + '@types/cookie': + specifier: ^0.5.1 + version: 0.5.1 '@types/uuid': specifier: ^9.0.2 version: 9.0.2 @@ -44,6 +47,9 @@ devDependencies: chart.js: specifier: ^4.3.0 version: 4.3.0 + cookie: + specifier: ^0.5.0 + version: 0.5.0 daisyui: specifier: ^3.2.1 version: 3.2.1 diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 8b6c350..cefe3a6 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -2,15 +2,18 @@ // for information about these interfaces declare global { namespace App { - // interface Error { - // status: number; - // message: string; - // } + // interface Error {} interface Locals { session: boolean; } - // interface PageData {} + interface PageData { + user?: { + id: string; + username: string; + avatar: string; + }; + } // interface Platform {} } } diff --git a/frontend/src/app.html b/frontend/src/app.html index 8b457cb..89ea9b4 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -7,6 +7,6 @@ %sveltekit.head% - %sveltekit.body% +
%sveltekit.body%
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index dcd815b..4887f85 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,42 +1,29 @@ -import { env } from '$env/dynamic/private'; -import type { OAuth2Response } from '$lib/types'; -import { error, redirect, type Handle } from '@sveltejs/kit'; +import { redirect, type Handle } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; const protectedRoutes = ['/']; const auth: Handle = async ({ resolve, event }) => { + console.log(`handle auth for route: ${event.url.pathname}`); + const refreshToken = event.cookies.get('refresh-token'); - const accessToken = event.cookies.get('access-token'); + let accessToken = event.cookies.get('access-token'); + + console.log(`refresh token: ${refreshToken}`); + console.log(`access token: ${accessToken}`); if (!accessToken && refreshToken) { - const rsp = await fetch('https://discord.com/api/oauth2/token', { - method: 'POST', + const rsp = await event.fetch('/auth/discord', { + method: 'PUT', headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/json' }, - body: new URLSearchParams({ - client_id: env.CLIENT_ID, - client_secret: env.CLIENT_SECRET, - grant_type: 'refresh_token', - refresh_token: refreshToken - }) + body: JSON.stringify({ refreshToken }) }); - if (!rsp.ok) { - console.error(`failed to refresh token: ${rsp.status}`); - event.cookies.delete('refresh-token'); - throw redirect(303, '/login'); - } + if (!rsp.ok) throw redirect(302, '/login'); - const { access_token, expires_in }: OAuth2Response = await rsp.json(); - event.cookies.set('access-token', access_token, { - maxAge: expires_in, - expires: new Date(Date.now() + expires_in), - httpOnly: true, - sameSite: true, - path: '/' - }); + accessToken = event.cookies.get('access-token'); } // * grab the access token again, in case it was just refreshed @@ -45,81 +32,19 @@ const auth: Handle = async ({ resolve, event }) => { return await resolve(event); }; -const handleAuth: Handle = async ({ resolve, event }) => { - if (event.locals.session && event.url.pathname.includes('/auth')) throw redirect(303, '/'); - else if (event.locals.session && event.url.pathname === '/logout') { - event.cookies.delete('access-token'); - event.cookies.delete('refresh-token'); - - throw redirect(303, '/login'); - } else if (event.locals.session) return await resolve(event); - - if (event.url.origin !== env.ORIGIN) { - console.error(`invalid origin. ${event.url.origin}`); - throw error(403, 'invalid origin'); - } - - if (event.url.pathname === '/auth/discord') { - const params = new URLSearchParams({ - client_id: env.CLIENT_ID, - redirect_uri: `${env.ORIGIN}/auth/callback/discord`, - response_type: 'code', - scope: 'identify' - }); - throw redirect(302, `https://discord.com/api/oauth2/authorize?${params.toString()}`); - } else if (event.url.pathname === '/auth/callback/discord') { - const code = event.url.searchParams.get('code'); - if (!code) { - console.error(`failed to get code in callback url: ${event.url}`); - throw redirect(303, '/login'); - } - - const rsp = await fetch('https://discord.com/api/oauth2/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - client_id: env.CLIENT_ID, - client_secret: env.CLIENT_SECRET, - grant_type: 'authorization_code', - redirect_uri: `${env.ORIGIN}/auth/callback/discord`, - code - }) - }); - - if (!rsp.ok) throw redirect(303, '/login'); - - const { access_token, refresh_token, expires_in }: OAuth2Response = await rsp.json(); - event.cookies.set('access-token', access_token, { - maxAge: expires_in, - expires: new Date(Date.now() + expires_in), - httpOnly: true, - sameSite: true, - path: '/' - }); - event.cookies.set('refresh-token', refresh_token, { - maxAge: Date.now() + 60 * 60 * 24 * 30, - expires: new Date(Date.now() + 60 * 60 * 24 * 30), - httpOnly: true, - sameSite: true, - path: '/' - }); - - console.info('successfully authenticated user'); - throw redirect(303, '/'); - } - - return await resolve(event); -}; - const guard: Handle = async ({ resolve, event }) => { if (protectedRoutes.includes(event.url.pathname) && !event.locals.session) { console.warn(`authentication failed for: ${event.url.pathname}`); - throw redirect(303, '/login'); - } else if (event.url.pathname === '/login' && event.locals.session) throw redirect(303, '/'); + throw redirect(302, '/login'); + } else if ( + (event.url.pathname === '/login' || event.url.pathname.includes('/auth')) && + event.locals.session + ) { + console.log('already authenticated. redirecting to home page'); + throw redirect(302, '/'); + } return await resolve(event); }; -export const handle: Handle = sequence(auth, guard, handleAuth); +export const handle: Handle = sequence(auth, guard); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ba393e4..b44dd28 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,6 +7,9 @@ import type { PageData } from './$types'; export let data: PageData; + + $: ({ username, avatar, id } = data); + setContextClient( new Client({ url: env.PUBLIC_GRAPHQL_ENDPOINT, @@ -24,13 +27,13 @@
- {#if data.id && data.avatar && data.username} + {#if id && avatar && username}
@@ -40,7 +43,7 @@
- user's avatar + user's avatar
{/if} diff --git a/frontend/src/routes/auth/callback/discord/+server.ts b/frontend/src/routes/auth/callback/discord/+server.ts new file mode 100644 index 0000000..a274338 --- /dev/null +++ b/frontend/src/routes/auth/callback/discord/+server.ts @@ -0,0 +1,62 @@ +import { env } from '$env/dynamic/private'; +import { env as publicEnv } from '$env/dynamic/public'; +import type { OAuth2Response } from '$lib/types'; +import { redirect } from '@sveltejs/kit'; +import { serialize } from 'cookie'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const code = event.url.searchParams.get('code'); + if (!code) { + console.error(`failed to get code in callback url: ${event.url}`); + throw redirect(302, '/login'); + } + + const rsp = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: env.CLIENT_ID, + client_secret: env.CLIENT_SECRET, + grant_type: 'authorization_code', + redirect_uri: `${publicEnv.PUBLIC_ORIGIN}/auth/callback/discord`, + code + }) + }); + + if (!rsp.ok) throw redirect(302, '/login'); + + const { access_token, refresh_token, expires_in }: OAuth2Response = await rsp.json(); + + const headers = new Headers(); + headers.set('Location', '/'); + headers.append( + 'Set-Cookie', + serialize('access-token', access_token, { + domain: publicEnv.PUBLIC_ORIGIN, + maxAge: expires_in, + expires: new Date(Date.now() + expires_in), + httpOnly: true, + sameSite: true, + path: '/' + }) + ); + headers.append( + 'Set-Cookie', + serialize('refresh-token', refresh_token, { + domain: publicEnv.PUBLIC_ORIGIN, + maxAge: 60 * 60 * 24 * 7, + expires: new Date(Date.now() + 60 * 60 * 24 * 7), + httpOnly: true, + sameSite: true, + path: '/' + }) + ); + + return new Response(null, { + status: 302, + headers + }); +}; diff --git a/frontend/src/routes/auth/discord/+server.ts b/frontend/src/routes/auth/discord/+server.ts new file mode 100644 index 0000000..1cc9186 --- /dev/null +++ b/frontend/src/routes/auth/discord/+server.ts @@ -0,0 +1,61 @@ +import { env } from '$env/dynamic/private'; +import { env as publicEnv } from '$env/dynamic/public'; +import type { OAuth2Response } from '$lib/types'; +import { serialize } from 'cookie'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = () => { + const params = new URLSearchParams({ + client_id: env.CLIENT_ID, + redirect_uri: `${publicEnv.PUBLIC_ORIGIN}/auth/callback/discord`, + response_type: 'code', + scope: 'identify' + }); + + return new Response(null, { + headers: { + Location: `https://discord.com/api/oauth2/authorize?${params.toString()}` + }, + status: 302 + }); +}; + +export const PUT: RequestHandler = async ({ request, fetch }) => { + const { refreshToken } = await request.json(); + + const rsp = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: env.CLIENT_ID, + client_secret: env.CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken + }) + }); + + if (!rsp.ok) return new Response(null, { status: 401 }); + + const { access_token, expires_in }: OAuth2Response = await rsp.json(); + + const headers = new Headers(); + headers.set('Location', '/'); + headers.append( + 'Set-Cookie', + serialize('access-token', access_token, { + domain: publicEnv.PUBLIC_ORIGIN, + maxAge: expires_in, + expires: new Date(Date.now() + expires_in), + httpOnly: true, + sameSite: true, + path: '/' + }) + ); + + return new Response(null, { + status: 302, + headers + }); +}; diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts new file mode 100644 index 0000000..b14918e --- /dev/null +++ b/frontend/src/routes/logout/+server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + event.cookies.delete('access-token'); + event.cookies.delete('refresh-token'); + + throw redirect(302, '/login'); +};