generalize auth provider stuff
This commit is contained in:
parent
e0594df7c8
commit
5770f07039
|
@ -4,24 +4,71 @@
|
||||||
// file lmao. also hopefully i will rewrite this to be better at extending to
|
// file lmao. also hopefully i will rewrite this to be better at extending to
|
||||||
// other oauth providers in the future
|
// other oauth providers in the future
|
||||||
|
|
||||||
|
import type {AuthProviderImplementation} from '.';
|
||||||
|
|
||||||
import {env} from '$env/dynamic/private';
|
import {env} from '$env/dynamic/private';
|
||||||
|
|
||||||
/** The redirect URI for Discord to send the user back to. */
|
const redirectURI = `${env.HOST}/auth/discord/callback`;
|
||||||
const discordRedirectURI = `${env.HOST}/auth/discord/callback`;
|
const scopes = ['identify'];
|
||||||
|
|
||||||
/** The base of the URI that starts the OAuth flow. State is attached later. */
|
export default {
|
||||||
// Long URIs suck
|
redirectURI,
|
||||||
const discordAuthURIBase = 'https://discordapp.com/api/oauth2/authorize'
|
buildAuthURI: state =>
|
||||||
+ `?client_id=${env.DISCORD_CLIENT_ID}`
|
'https://discordapp.com/api/oauth2/authorize'
|
||||||
+ '&response_type=code'
|
+ `?client_id=${env.DISCORD_CLIENT_ID}`
|
||||||
+ `&redirect_uri=${encodeURIComponent(discordRedirectURI)}`
|
+ '&response_type=code'
|
||||||
+ `&scope=${encodeURIComponent(['identify', 'guilds'].join(' '))}`
|
+ `&redirect_uri=${encodeURIComponent(redirectURI)}`
|
||||||
+ '&prompt=consent'; // Special Discord-only thing to always show prompt
|
+ `&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. */
|
// use access token to get user info
|
||||||
function authURI (state: string) {
|
const userInfoResponse = await fetch(
|
||||||
return `${discordAuthURIBase}&state=${encodeURIComponent(state)}`;
|
'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. */
|
/** Generates a formdata body from key-value pairs. */
|
||||||
function formData (content: Record<string, string>) {
|
function formData (content: Record<string, string>) {
|
||||||
|
@ -29,114 +76,3 @@ function formData (content: Record<string, string>) {
|
||||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||||
.join('&');
|
.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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
22
src/lib/server/auth/index.ts
Normal file
22
src/lib/server/auth/index.ts
Normal 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,
|
||||||
|
};
|
|
@ -1,11 +1,8 @@
|
||||||
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
|
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 {ulidMonotonic} from '$lib/server/ulid';
|
||||||
import {User} from './User';
|
|
||||||
|
|
||||||
export enum AuthProvider {
|
|
||||||
DISCORD,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class AuthMethod {
|
export class AuthMethod {
|
||||||
|
|
|
@ -1,51 +1,19 @@
|
||||||
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
|
import {BeforeInsert, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
|
||||||
import {EncryptionTransformer} from 'typeorm-encrypted';
|
|
||||||
|
|
||||||
import {AuthProvider} from '$lib/server/entity/AuthMethod';
|
import {AuthMethod} from '$lib/server/entity/AuthMethod';
|
||||||
import {ulidMonotonic} from '$lib/server/ulid';
|
import {ulidMonotonic} from '$lib/server/ulid';
|
||||||
|
|
||||||
import {env} from '$env/dynamic/private';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class AuthSession {
|
export class AuthSession {
|
||||||
/** The ID of the session */
|
/** 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})
|
@PrimaryColumn({type: 'varchar', length: 26, nullable: false})
|
||||||
id = ulidMonotonic();
|
id = ulidMonotonic();
|
||||||
|
|
||||||
/** The authentication provider used to start this session */
|
@ManyToOne(() => AuthMethod, authMethod => authMethod.id, {nullable: false})
|
||||||
@Column({type: 'enum', enum: AuthProvider, nullable: false})
|
authMethod!: AuthMethod;
|
||||||
provider!: AuthProvider;
|
|
||||||
|
|
||||||
/** The access token */
|
// TODO expiration of sessions?
|
||||||
@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()
|
@BeforeInsert()
|
||||||
private beforeInsert () {
|
private beforeInsert () {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
|
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';
|
import {ulidMonotonic} from '$lib/server/ulid';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import {discordAvatarURL} from '$lib/server/auth/discord';
|
|
||||||
import {getDataSource} from '$lib/server/db';
|
import {getDataSource} from '$lib/server/db';
|
||||||
import {AuthSession} from '$lib/server/entity/AuthSession';
|
import {AuthSession} from '$lib/server/entity/AuthSession';
|
||||||
|
|
||||||
|
@ -14,38 +13,22 @@ async function findSession (sessionID?: string) {
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({cookies}) => {
|
export const load: LayoutServerLoad = async ({cookies}) => {
|
||||||
const sessionID = cookies.get('sessionid');
|
const sessionID = cookies.get('sessionid');
|
||||||
let session = await findSession(sessionID);
|
const dataSource = await getDataSource();
|
||||||
if (!session) {
|
const sessionsRepo = dataSource.getRepository(AuthSession);
|
||||||
return {};
|
const session = sessionID
|
||||||
}
|
? await sessionsRepo.findOne({
|
||||||
console.log('i am happening');
|
where: {id: sessionID},
|
||||||
|
relations: {
|
||||||
const response = await fetch('https://discordapp.com/api/v6/users/@me', {
|
authMethod: {
|
||||||
headers: {
|
user: true,
|
||||||
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}`,
|
|
||||||
},
|
},
|
||||||
};
|
})
|
||||||
}
|
: null;
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('have data', data.username);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: session
|
||||||
username: data.username,
|
? JSON.parse(JSON.stringify(session.authMethod.user))
|
||||||
discriminator: data.discriminator,
|
: null,
|
||||||
id: data.id,
|
|
||||||
avatarURL: discordAvatarURL(data),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,8 +5,7 @@
|
||||||
|
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<h1>Success!</h1>
|
<h1>Success!</h1>
|
||||||
<p>You are {data.user.username}#{data.user.discriminator} and your ID is {data.user.id}</p>
|
<p>You are {data.user.name} and your ID is {data.user.id}</p>
|
||||||
<img src={data.user.avatarURL} alt="Discord avatar of {data.user.username}">
|
|
||||||
{:else}
|
{:else}
|
||||||
<h1>Hello</h1>
|
<h1>Hello</h1>
|
||||||
<p>I do not know you</p>
|
<p>I do not know you</p>
|
||||||
|
|
38
src/routes/auth/[provider]/+page.server.ts
Normal file
38
src/routes/auth/[provider]/+page.server.ts
Normal 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));
|
||||||
|
};
|
108
src/routes/auth/[provider]/callback/+page.server.ts
Normal file
108
src/routes/auth/[provider]/callback/+page.server.ts
Normal 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, '/');
|
||||||
|
};
|
|
@ -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));
|
|
||||||
}
|
|
|
@ -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, '/');
|
|
||||||
};
|
|
Loading…
Reference in a new issue