diff --git a/src/lib/server/auth/discord.ts b/src/lib/server/auth/discord.ts index d240e34..58d12f5 100644 --- a/src/lib/server/auth/discord.ts +++ b/src/lib/server/auth/discord.ts @@ -4,24 +4,71 @@ // file lmao. also hopefully i will rewrite this to be better at extending to // other oauth providers in the future +import type {AuthProviderImplementation} from '.'; + import {env} from '$env/dynamic/private'; -/** The redirect URI for Discord to send the user back to. */ -const discordRedirectURI = `${env.HOST}/auth/discord/callback`; +const redirectURI = `${env.HOST}/auth/discord/callback`; +const scopes = ['identify']; -/** The base of the URI that starts the OAuth flow. State is attached later. */ -// Long URIs suck -const discordAuthURIBase = 'https://discordapp.com/api/oauth2/authorize' - + `?client_id=${env.DISCORD_CLIENT_ID}` - + '&response_type=code' - + `&redirect_uri=${encodeURIComponent(discordRedirectURI)}` - + `&scope=${encodeURIComponent(['identify', 'guilds'].join(' '))}` - + '&prompt=consent'; // Special Discord-only thing to always show prompt +export default { + redirectURI, + buildAuthURI: state => + 'https://discordapp.com/api/oauth2/authorize' + + `?client_id=${env.DISCORD_CLIENT_ID}` + + '&response_type=code' + + `&redirect_uri=${encodeURIComponent(redirectURI)}` + + `&scope=${encodeURIComponent(scopes.join(' '))}` + + '&prompt=consent' // Special Discord-only thing to always show prompt + + `&state=${encodeURIComponent(state)}`, + async getUserIdentifier (code, {fetch}) { + // exchange code for tokens + const tokenResponse = await fetch( + 'https://discordapp.com/api/oauth2/token', + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData({ + client_id: env.DISCORD_CLIENT_ID, + client_secret: env.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: redirectURI, + // Discord-specific: scope is required here and must match + scope: scopes.join(' '), + }), + }, + ); + if (tokenResponse.status !== 200) { + throw new Error(`error requesting tokens: ${tokenResponse.status}`); + } + const tokensData = await tokenResponse.json(); + if (tokensData.error) { + throw new Error(`error requesting tokens: ${tokensData.error}`); + } -/** Generates an auth URI to redirect the user to given a state. */ -function authURI (state: string) { - return `${discordAuthURIBase}&state=${encodeURIComponent(state)}`; -} + // use access token to get user info + const userInfoResponse = await fetch( + 'https://discordapp.com/api/v6/users/@me', + { + headers: { + Authorization: `Bearer ${tokensData.access_token}`, + }, + }, + ); + if (userInfoResponse.status !== 200) { + throw new Error( + `error requesting user info: ${tokenResponse.status}`, + ); + } + const userInfo = await userInfoResponse.json(); + + // return the user's service-unique ID + return userInfo.id; + }, +} satisfies AuthProviderImplementation; /** Generates a formdata body from key-value pairs. */ function formData (content: Record) { @@ -29,114 +76,3 @@ function formData (content: Record) { .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join('&'); } - -/** - * Generates an avatar URL for the user. Discord avatars are weird so I split - * this out. - * @see https://discordapp.com/developers/docs/reference#image-formatting - */ -export function discordAvatarURL (userInfo: { - id: string; - avatar?: string; - discriminator: string; -}) { - if (userInfo.avatar) { - return `https://cdn.discordapp.com/avatars/${userInfo.id}/${userInfo.avatar}.png`; - } - return `https://cdn.discordapp.com/embed/avatars/${ - parseInt(userInfo.discriminator, 10) % 5 - }.png`; -} - -/** OAuth tokens returned by Discord. */ -export interface DiscordTokens { - /** The access token used to authorize to Discord */ - accessToken: string; - /** The refresh token used to get a new access token when the current one expires */ - refreshToken: string; - /** The string "Bearer"; other token types aren't relevant here */ - tokenType: string; - /** space-separated list of authorized scopes */ - scope: string; - /** Expiration date of the access token */ - expiresAt: Date; -} - -/** Exchanges a code for an access/refresh token pair. */ -export async function fetchDiscordTokens ( - code: string, -): Promise { - const response = await fetch('https://discordapp.com/api/oauth2/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: formData({ - client_id: env.DISCORD_CLIENT_ID, - client_secret: env.DISCORD_CLIENT_SECRET, - grant_type: 'authorization_code', - code, - redirect_uri: discordRedirectURI, - scope: 'identify', // Discord-specific: scope is required here too and must match - }), - }); - - if (response.status !== 200) { - throw new Error( - `Discord gave non-200 response status when requesting tokens: ${response.status}`, - ); - } - - const data = await response.json(); - if (data.error) { - throw new Error( - `Discord gave an error when requesting tokens: ${data.error}`, - ); - } - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - tokenType: data.token_type, - scope: data.scope, - expiresAt: new Date(Date.now() + data.expires_in * 1000), - }; -} - -/** Information about a Discord user. */ -export interface DiscordUserInfo { - id: string; - username: string; - discriminator: string; - avatarURL: string; -} - -/** - * Fetches information about the user given their access token. - * @param {string} accessToken - * @returns {Promise} Object has keys `name`, `avatarURL`, and - * `created`. - */ -export async function fetchDiscordUserInfo ( - fetch: any, - accessToken: string, -): Promise { - const response = await fetch('https://discordapp.com/api/v6/users/@me', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (response.status !== 200) { - throw new Error( - `Discord gave non-200 status when fetching user info: ${response.status}`, - ); - } - - const data = await response.json(); - return { - username: data.username, - discriminator: data.discriminator, - id: data.id, - avatarURL: discordAvatarURL(data), - }; -} diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts new file mode 100644 index 0000000..31fae5b --- /dev/null +++ b/src/lib/server/auth/index.ts @@ -0,0 +1,22 @@ +import type {ServerLoadEvent} from '@sveltejs/kit'; +import DiscordAuthImplementation from './discord'; + +export enum AuthProvider { + DISCORD, +} + +export interface AuthProviderImplementation { + redirectURI: string; + buildAuthURI: (state: string) => string; + getUserIdentifier: ( + code: string, + event: ServerLoadEvent, + ) => Promise; +} + +export const authProviderImplementations: Record< + AuthProvider, + AuthProviderImplementation +> = { + [AuthProvider.DISCORD]: DiscordAuthImplementation, +}; diff --git a/src/lib/server/entity/AuthMethod.ts b/src/lib/server/entity/AuthMethod.ts index dfe3667..9f2f927 100644 --- a/src/lib/server/entity/AuthMethod.ts +++ b/src/lib/server/entity/AuthMethod.ts @@ -1,11 +1,8 @@ import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm'; +import {AuthProvider} from '$lib/server/auth'; +import {User} from '$lib/server/entity/User'; import {ulidMonotonic} from '$lib/server/ulid'; -import {User} from './User'; - -export enum AuthProvider { - DISCORD, -} @Entity() export class AuthMethod { diff --git a/src/lib/server/entity/AuthSession.ts b/src/lib/server/entity/AuthSession.ts index c3a8589..f5cf201 100644 --- a/src/lib/server/entity/AuthSession.ts +++ b/src/lib/server/entity/AuthSession.ts @@ -1,51 +1,19 @@ -import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm'; -import {EncryptionTransformer} from 'typeorm-encrypted'; +import {BeforeInsert, Entity, ManyToOne, PrimaryColumn} from 'typeorm'; -import {AuthProvider} from '$lib/server/entity/AuthMethod'; +import {AuthMethod} from '$lib/server/entity/AuthMethod'; import {ulidMonotonic} from '$lib/server/ulid'; -import {env} from '$env/dynamic/private'; - @Entity() export class AuthSession { /** The ID of the session */ - // TODO should we be using ulids for this or something totally random + // TODO should we be using ulids for this or something crypt-random @PrimaryColumn({type: 'varchar', length: 26, nullable: false}) id = ulidMonotonic(); - /** The authentication provider used to start this session */ - @Column({type: 'enum', enum: AuthProvider, nullable: false}) - provider!: AuthProvider; + @ManyToOne(() => AuthMethod, authMethod => authMethod.id, {nullable: false}) + authMethod!: AuthMethod; - /** The access token */ - @Column({ - type: 'varchar', - length: 300, - nullable: false, - transformer: new EncryptionTransformer({ - algorithm: 'aes-256-cbc', - key: env.ENCRYPTION_KEY!, - ivLength: 16, - }), - }) - accessToken!: string; - - /** The refresh token */ - @Column({ - type: 'varchar', - length: 300, - nullable: true, - transformer: new EncryptionTransformer({ - algorithm: 'aes-256-cbc', - key: env.ENCRYPTION_KEY!, - ivLength: 16, - }), - }) - refreshToken!: string; - - /** When the access token expires */ - @Column({type: 'timestamptz', nullable: true}) - accessTokenExpiresAt!: Date; + // TODO expiration of sessions? @BeforeInsert() private beforeInsert () { diff --git a/src/lib/server/entity/AuthState.ts b/src/lib/server/entity/AuthState.ts index adff3a4..30f7351 100644 --- a/src/lib/server/entity/AuthState.ts +++ b/src/lib/server/entity/AuthState.ts @@ -1,7 +1,7 @@ import * as crypto from 'node:crypto'; import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm'; -import {AuthProvider} from '$lib/server/entity/AuthMethod'; +import {AuthProvider} from '$lib/server/auth'; import {ulidMonotonic} from '$lib/server/ulid'; @Entity() diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 2c4bb3c..2dff7f8 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,4 +1,3 @@ -import {discordAvatarURL} from '$lib/server/auth/discord'; import {getDataSource} from '$lib/server/db'; import {AuthSession} from '$lib/server/entity/AuthSession'; @@ -14,38 +13,22 @@ async function findSession (sessionID?: string) { export const load: LayoutServerLoad = async ({cookies}) => { const sessionID = cookies.get('sessionid'); - let session = await findSession(sessionID); - if (!session) { - return {}; - } - console.log('i am happening'); - - const response = await fetch('https://discordapp.com/api/v6/users/@me', { - headers: { - Authorization: `Bearer ${session.accessToken}`, - }, - }); - - if (response.status !== 200) { - console.log(await response.text()); - return { - error: { - code: 'user_info_fetch_failed', - description: - `Discord gave non-200 status when fetching user info: ${response.status}`, + const dataSource = await getDataSource(); + const sessionsRepo = dataSource.getRepository(AuthSession); + const session = sessionID + ? await sessionsRepo.findOne({ + where: {id: sessionID}, + relations: { + authMethod: { + user: true, + }, }, - }; - } - - const data = await response.json(); - console.log('have data', data.username); + }) + : null; return { - user: { - username: data.username, - discriminator: data.discriminator, - id: data.id, - avatarURL: discordAvatarURL(data), - }, + user: session + ? JSON.parse(JSON.stringify(session.authMethod.user)) + : null, }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e01ab07..ea6fab9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,8 +5,7 @@ {#if data.user}

Success!

-

You are {data.user.username}#{data.user.discriminator} and your ID is {data.user.id}

- Discord avatar of {data.user.username} +

You are {data.user.name} and your ID is {data.user.id}

{:else}

Hello

I do not know you

diff --git a/src/routes/auth/[provider]/+page.server.ts b/src/routes/auth/[provider]/+page.server.ts new file mode 100644 index 0000000..1564daa --- /dev/null +++ b/src/routes/auth/[provider]/+page.server.ts @@ -0,0 +1,38 @@ +import {redirect} from '@sveltejs/kit'; +import * as crypto from 'node:crypto'; + +import {getDataSource} from '$lib/server/db'; +import {AuthState} from '$lib/server/entity/AuthState'; + +import {env} from '$env/dynamic/private'; +import { + AuthProvider, + type AuthProviderImplementation, + authProviderImplementations, +} from '$lib/server/auth'; +import type {PageServerLoad} from './$types'; + +export const load: PageServerLoad = async ({cookies, params}) => { + // figure out what auth provider the user wanted to try + let provider: AuthProvider; + if (params.provider === 'discord') { + provider = AuthProvider.DISCORD; + } else { + // idk thats not a valid provider, uhh, + throw redirect(302, '/'); + } + + const dataSource = await getDataSource(); + const providerImpl = authProviderImplementations[provider]; + + // store a state for this login attempt + const authStatesRepo = await dataSource.getRepository(AuthState); + const state = authStatesRepo.create({provider}); + await authStatesRepo.save(state); + + // set the state ID as a cookie so we can retrieve it later and compare + cookies.set('stateid', state.id, {path: '/auth/discord'}); + + // redirect to the provider with the state + throw redirect(302, providerImpl.buildAuthURI(state.state)); +}; diff --git a/src/routes/auth/[provider]/callback/+page.server.ts b/src/routes/auth/[provider]/callback/+page.server.ts new file mode 100644 index 0000000..4b95d77 --- /dev/null +++ b/src/routes/auth/[provider]/callback/+page.server.ts @@ -0,0 +1,108 @@ +import {getDataSource} from '$lib/server/db'; +import {AuthSession} from '$lib/server/entity/AuthSession'; +import {AuthState} from '$lib/server/entity/AuthState'; +import {redirect} from '@sveltejs/kit'; +import type {PageServerLoad} from './$types'; + +import {AuthProvider, authProviderImplementations} from '$lib/server/auth'; +import {AuthMethod} from '$lib/server/entity/AuthMethod'; +import {User} from '$lib/server/entity/User'; + +export const load: PageServerLoad = async event => { + // figure out what auth provider has redirected the user here + let provider: AuthProvider; + if (event.params.provider === 'discord') { + provider = AuthProvider.DISCORD; + } else { + // idk thats not a valid provider, uhh, + throw redirect(302, '/'); + } + + // check for errors from the provider + // TODO: this is still technically provider-specific and should be split out + // into the provider implementations since different providers can call back + // with different parameters + const errorCode = event.url.searchParams.get('error'); + const errorDescription = event.url.searchParams.get('error_description'); + + // if the user cancelled the login, redirect home gracefully + if (errorCode === 'access_denied') { + throw redirect(302, '/'); + } + + // if another error was encountered, return the error information only + if (errorCode) { + return { + error: { + code: errorCode, + description: errorDescription ?? '', + }, + }; + } + + const providerImpl = authProviderImplementations[provider]; + + // retrieve the state we stored for this session and compare against the + // state we received from the provider + const dataSource = await getDataSource(); + const statesRepo = dataSource.getRepository(AuthState); + + const stateID = event.cookies.get('stateid'); + let storedState: AuthState | null = null; + if (stateID) { + storedState = await statesRepo.findOne({where: {id: stateID}}); + } + + const receivedState = event.url.searchParams.get('state'); + + if (!storedState || !receivedState || storedState.state !== receivedState) { + return { + error: { + code: 'consumer_state_mismatch', + description: + `Expected state ${storedState?.state}, received ${receivedState}`, + }, + }; + } + + const code = event.url.searchParams.get('code'); + if (!code) { + return { + error: { + code: 'no_code', + description: 'No code was received', + }, + }; + } + + // trade in the code for a user identifier + const userIdentifier = await providerImpl.getUserIdentifier(code, event); + + // see if any users have registered matching authentication methods + const authMethodsRepo = dataSource.getRepository(AuthMethod); + let authMethod = await authMethodsRepo.findOne({ + where: { + provider, + userIdentifier, + }, + }); + + // if there is not yet anyone registered using this auth method, create a + // new user and a new auth method for that user + if (!authMethod) { + throw redirect(302, '/'); // TODO + } + + // Create a new auth session for this auth method and save it + const authSessionRepo = dataSource.getRepository(AuthSession); + const authSession = authSessionRepo.create({authMethod}); + await authSessionRepo.save(authSession); + event.cookies.set('sessionid', authSession.id, {path: '/'}); + + // remove the state we were using now that we're done with it + await statesRepo.remove(storedState); + event.cookies.delete('stateid'); + + // Woo we did it, redirect on to wherever we were trying to go before + throw redirect(302, '/'); +}; diff --git a/src/routes/auth/discord/callback/+page.svelte b/src/routes/auth/[provider]/callback/+page.svelte similarity index 100% rename from src/routes/auth/discord/callback/+page.svelte rename to src/routes/auth/[provider]/callback/+page.svelte diff --git a/src/routes/auth/discord/+page.server.ts b/src/routes/auth/discord/+page.server.ts deleted file mode 100644 index 3ea6dd2..0000000 --- a/src/routes/auth/discord/+page.server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {redirect} from '@sveltejs/kit'; -import * as crypto from 'node:crypto'; - -import {getDataSource} from '$lib/server/db'; -import {AuthState} from '$lib/server/entity/AuthState'; - -import {env} from '$env/dynamic/private'; -import {AuthProvider} from '$lib/server/entity/AuthMethod.js'; - -/** - * The redirect URI for Discord to send the user back to after auth. This must - * match what's configured for the OAuth application in the dev applications - * panel in order to fetch tokens. - */ -const discordRedirectURI = `${env.HOST}/auth/discord/callback`; - -/** The base of the URI that starts the OAuth flow. State is attached later. */ -const authURIBase = 'https://discordapp.com/api/oauth2/authorize' - + `?client_id=${env.DISCORD_CLIENT_ID}` - + '&response_type=code' - + `&redirect_uri=${encodeURIComponent(discordRedirectURI)}` - + `&scope=${encodeURIComponent(['identify', 'guilds'].join(' '))}` - + '&prompt=consent'; // Special Discord-only thing to always show prompt - -/** Generates a complete auth URI given a state. */ -const buildAuthURI = (state: string) => - `${authURIBase}&state=${encodeURIComponent(state)}`; - -export async function load ({cookies}) { - const dataSource = await getDataSource(); - - const authStatesRepo = await dataSource.getRepository(AuthState); - const state = authStatesRepo.create({ - provider: AuthProvider.DISCORD, - }); - await authStatesRepo.save(state); - - cookies.set('stateid', state.id, {path: '/auth/discord'}); - throw redirect(302, buildAuthURI(state.state)); -} diff --git a/src/routes/auth/discord/callback/+page.server.ts b/src/routes/auth/discord/callback/+page.server.ts deleted file mode 100644 index f6729c0..0000000 --- a/src/routes/auth/discord/callback/+page.server.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - discordAvatarURL, - type DiscordUserInfo, - fetchDiscordTokens, - fetchDiscordUserInfo, -} from '$lib/server/auth/discord'; -import {getDataSource} from '$lib/server/db'; -import {AuthProvider} from '$lib/server/entity/AuthMethod'; -import {AuthSession} from '$lib/server/entity/AuthSession'; -import {AuthState} from '$lib/server/entity/AuthState'; -import {redirect} from '@sveltejs/kit'; -import type {PageServerLoad} from './$types'; - -export const load: PageServerLoad = async ({cookies, url, fetch}) => { - const errorCode = url.searchParams.get('error'); - const errorDescription = url.searchParams.get('error_description'); - - // if the user cancelled the login, redirect home gracefully - if (errorCode === 'access_denied') { - throw redirect(302, '/'); - } - - // if another error was encountered, return the error information only - if (errorCode) { - return { - error: { - code: errorCode, - description: errorDescription ?? '', - }, - }; - } - - // retrieve the state we stored for this session and compare against the - // state we received from the provider - const dataSource = await getDataSource(); - const statesRepo = dataSource.getRepository(AuthState); - - const stateID = cookies.get('stateid'); - let storedState: AuthState | null = null; - if (stateID) { - storedState = await statesRepo.findOne({where: {id: stateID}}); - } - - const receivedState = url.searchParams.get('state'); - - if (!storedState || !receivedState || storedState.state !== receivedState) { - return { - error: { - code: 'consumer_state_mismatch', - description: - `Expected state ${storedState?.state}, received ${receivedState}`, - }, - }; - } - - // everything checks out - exchange code for tokens - const code = url.searchParams.get('code'); - if (!code) { - return { - error: { - code: 'no_code', - description: 'No code was received', - }, - }; - } - - let tokens; - try { - tokens = await fetchDiscordTokens(code); - } catch (tokenError) { - return { - error: { - code: 'token_exchange_failed', - description: - `Failed to exchange code for tokens: ${tokenError}`, - }, - }; - } - - // Create a new auth session from the tokens and save it - const authSessionRepo = dataSource.getRepository(AuthSession); - const authSession = authSessionRepo.create({ - provider: AuthProvider.DISCORD, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - accessTokenExpiresAt: tokens.expiresAt, - }); - await authSessionRepo.save(authSession); - cookies.set('sessionid', authSession.id, {path: '/'}); - - // remove the state we were using now that we're done with it - await statesRepo.remove(storedState); - cookies.delete('stateid'); - - // Woo we did it, redirect on to wherever we were trying to go before - throw redirect(302, '/'); -};