generalize auth provider stuff

This commit is contained in:
Erin 2023-11-12 12:20:44 -05:00
parent e0594df7c8
commit 5770f07039
12 changed files with 253 additions and 339 deletions

View file

@ -4,69 +4,28 @@
// 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'
export default {
redirectURI,
buildAuthURI: state =>
'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 an auth URI to redirect the user to given a state. */
function authURI (state: string) {
return `${discordAuthURIBase}&state=${encodeURIComponent(state)}`;
}
/** Generates a formdata body from key-value pairs. */
function formData (content: Record<string, string>) {
return Object.entries(content)
.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<DiscordTokens> {
const response = await fetch('https://discordapp.com/api/oauth2/token', {
+ `&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',
@ -76,67 +35,44 @@ export async function fetchDiscordTokens (
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
redirect_uri: redirectURI,
// Discord-specific: scope is required here and must match
scope: scopes.join(' '),
}),
});
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>} Object has keys `name`, `avatarURL`, and
* `created`.
*/
export async function fetchDiscordUserInfo (
fetch: any,
accessToken: string,
): Promise<DiscordUserInfo> {
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}`,
);
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}`);
}
const data = await response.json();
return {
username: data.username,
discriminator: data.discriminator,
id: data.id,
avatarURL: discordAvatarURL(data),
};
// 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<string, string>) {
return Object.entries(content)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
}

View file

@ -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<string>;
}
export const authProviderImplementations: Record<
AuthProvider,
AuthProviderImplementation
> = {
[AuthProvider.DISCORD]: DiscordAuthImplementation,
};

View file

@ -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 {

View file

@ -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 () {

View file

@ -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()

View file

@ -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}`,
const dataSource = await getDataSource();
const sessionsRepo = dataSource.getRepository(AuthSession);
const session = sessionID
? await sessionsRepo.findOne({
where: {id: sessionID},
relations: {
authMethod: {
user: true,
},
});
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 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,
};
};

View file

@ -5,8 +5,7 @@
{#if data.user}
<h1>Success!</h1>
<p>You are {data.user.username}#{data.user.discriminator} and your ID is {data.user.id}</p>
<img src={data.user.avatarURL} alt="Discord avatar of {data.user.username}">
<p>You are {data.user.name} and your ID is {data.user.id}</p>
{:else}
<h1>Hello</h1>
<p>I do not know you</p>

View file

@ -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));
};

View file

@ -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, '/');
};

View file

@ -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));
}

View file

@ -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, '/');
};