initial commit, lots of random shit

This commit is contained in:
Erin 2023-11-11 19:09:24 -05:00
commit 7bb18f0a71
27 changed files with 3394 additions and 0 deletions

17
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
// See https://kit.svelte.dev/docs/types#app
import type {DataSource} from 'typeorm';
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
db: DataSource;
}
// interface PageData {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,142 @@
// stolen from
// https://github.com/r-anime/misato/blob/main/src/web/routes/discordAuth.js but
// it's fine to relicense because im the only one who ever contributed to that
// file lmao. also hopefully i will rewrite this to be better at extending to
// other oauth providers in the future
import {env} from '$env/dynamic/private';
/** The redirect URI for Discord to send the user back to. */
const discordRedirectURI = `${env.HOST}/auth/discord/callback`;
/** 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
/** 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', {
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>} 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}`,
);
}
const data = await response.json();
return {
username: data.username,
discriminator: data.discriminator,
id: data.id,
avatarURL: discordAvatarURL(data),
};
}

33
src/lib/server/db.ts Normal file
View file

@ -0,0 +1,33 @@
import {DataSource} from 'typeorm';
import {AuthMethod} from '$lib/server/entity/AuthMethod';
import {AuthSession} from '$lib/server/entity/AuthSession';
import {Edge} from '$lib/server/entity/Edge';
import {Node} from '$lib/server/entity/Node';
import {User} from '$lib/server/entity/User';
import {env} from '$env/dynamic/private';
import {AuthState} from './entity/AuthState';
const dataSource = new DataSource({
type: 'postgres',
url: env.DB_URL,
entities: [
AuthMethod,
AuthSession,
AuthState,
Node,
Edge,
User,
],
synchronize: true,
logging: false,
});
// initialize the data source immediately
const dataSourceInitializedPromise = dataSource.initialize();
export async function getDataSource () {
await dataSourceInitializedPromise;
return dataSource;
}

View file

@ -0,0 +1,28 @@
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {ulidMonotonic} from '$lib/server/ulid';
import {User} from './User';
export enum AuthProvider {
DISCORD,
}
@Entity()
export class AuthMethod {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@Column({type: 'enum', enum: AuthProvider, nullable: false})
provider!: AuthProvider;
@Column({type: 'varchar', length: 500, nullable: false})
userIdentifier!: string;
@ManyToOne(() => User, user => user.authMethods)
user!: User;
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,54 @@
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {EncryptionTransformer} from 'typeorm-encrypted';
import {AuthProvider} 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
@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;
/** 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;
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,20 @@
import * as crypto from 'node:crypto';
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {AuthProvider} from '$lib/server/entity/AuthMethod';
import {ulidMonotonic} from '$lib/server/ulid';
@Entity()
export class AuthState {
/** The ID of the state session */
@PrimaryColumn({type: 'varchar', length: 26, nullable: false})
id = ulidMonotonic();
/** The authentication provider this state has been sent to */
@Column({type: 'enum', enum: AuthProvider, nullable: false})
provider!: AuthProvider;
/** The state value itself */
@Column({type: 'varchar', length: 32, nullable: false})
state = crypto.randomBytes(16).toString('hex');
}

View file

@ -0,0 +1,29 @@
import {BeforeInsert, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {Node} from '$lib/server/entity/Node';
import {ulidMonotonic} from '$lib/server/ulid';
@Entity()
export class Edge {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@ManyToOne(() => Node, node => node.outgoingEdges, {
nullable: false,
cascade: true,
onDelete: 'CASCADE',
})
head!: Node;
@ManyToOne(() => Node, node => node.incomingEdges, {
nullable: false,
cascade: true,
onDelete: 'CASCADE',
})
tail!: Node;
@BeforeInsert()
beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,30 @@
import {BeforeInsert, Column, Entity, OneToMany, PrimaryColumn} from 'typeorm';
import {Edge} from '$lib/server/entity/Edge';
import {ulidMonotonic} from '$lib/server/ulid';
@Entity()
export class Node {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@Column({type: 'varchar', length: 500, nullable: false})
name!: string;
@OneToMany(type => Edge, edge => edge.head, {
nullable: false,
onDelete: 'CASCADE',
})
outgoingEdges!: Edge[];
@OneToMany(type => Edge, edge => edge.tail, {
nullable: false,
onDelete: 'CASCADE',
})
incomingEdges!: Edge[];
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,21 @@
import {BeforeInsert, Column, Entity, OneToMany, PrimaryColumn} from 'typeorm';
import {ulidMonotonic} from '$lib/server/ulid';
import {AuthMethod} from './AuthMethod';
@Entity()
export class User {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@Column({type: 'varchar', length: 500, nullable: false})
name!: string;
@OneToMany(() => AuthMethod, authMethod => authMethod.user)
authMethods!: AuthMethod[];
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

3
src/lib/server/ulid.ts Normal file
View file

@ -0,0 +1,3 @@
import {monotonicFactory} from 'ulid';
export const ulidMonotonic = monotonicFactory();

View file

@ -0,0 +1,51 @@
import {discordAvatarURL} from '$lib/server/auth/discord';
import {getDataSource} from '$lib/server/db';
import {AuthSession} from '$lib/server/entity/AuthSession';
import type {LayoutServerLoad} from './$types';
async function findSession (sessionID?: string) {
const dataSource = await getDataSource();
const sessionsRepo = dataSource.getRepository(AuthSession);
if (!sessionID) { return null; }
return await sessionsRepo.findOne({where: {id: sessionID}});
}
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 data = await response.json();
console.log('have data', data.username);
return {
user: {
username: data.username,
discriminator: data.discriminator,
id: data.id,
avatarURL: discordAvatarURL(data),
},
};
};

14
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,14 @@
<script lang="ts">
import type {PageData} from './$types';
export let data: PageData;
</script>
{#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}">
{:else}
<h1>Hello</h1>
<p>I do not know you</p>
<p>please <a href="/auth/discord">try again</a></p>
{/if}

View file

@ -0,0 +1,40 @@
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

@ -0,0 +1,97 @@
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, '/');
};

View file

@ -0,0 +1,10 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
{#if data.error}
<h1>Error!</h1>
<pre>{data.error.code}: {data.error.description}</pre>
<p>Please <a href="/auth/discord">try again</a></p>
{/if}