talkwho/src/lib/server/oauth.ts
2024-08-18 20:24:05 -04:00

253 lines
6.6 KiB
TypeScript

import {
Google,
Discord,
GitHub,
generateState,
generateCodeVerifier,
type GitHubTokens,
type DiscordTokens
} from 'arctic'
import { error, redirect, type Actions, type RequestHandler, type ServerLoad } from '@sveltejs/kit'
import { z } from 'zod'
import { db, publicUser } from './db'
import { env } from '$env/dynamic/private'
import { dev } from '$app/environment'
import { alphabet, generateRandomString } from 'oslo/crypto'
import { decodeJwt } from 'jose'
import { superValidate, type SuperForm } from 'sveltekit-superforms'
import { zod, type ValidationAdapter } from 'sveltekit-superforms/adapters'
import { cookieController, createSessionForUser } from './auth'
const {
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET,
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET
} = env
//TODO: oauth
const DEVURL = (prov: string) => `http://localhost:5173/oauth/${prov}/callback`
const PRODURL = (prov: string) => `https://spiel.place/oauth/${prov}/callback`
export const github = new GitHub(GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, {
redirectURI: dev ? DEVURL('github') : PRODURL('github')
})
export const discord = new Discord(
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET,
dev ? DEVURL('discord') : PRODURL('discord')
)
export const google = new Google(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
dev ? DEVURL('google') : PRODURL('google')
)
export function oauth_handler(): RequestHandler<{ provider: string }> {
return async ({ cookies, params: { provider: providerID }, url }) => {
let provider: Google | Discord | GitHub
let scopes: string[]
let noop = false
switch (providerID) {
case 'discord': {
provider = discord
scopes = ['identify']
break
}
case 'google': {
provider = google
scopes = ['profile']
break
}
case 'github': {
provider = github
scopes = []
break
}
default: {
noop = true
break
}
}
// @ts-expect-error: I know im using it before assignment that's the point
if (noop || !provider || !scopes) error(404, 'provider not found')
let redir: URL
const state = generateState()
let codeVerifier: string
if (provider instanceof Google) {
codeVerifier = generateCodeVerifier()
redir = await provider.createAuthorizationURL(state, codeVerifier, {
scopes
})
} else {
redir = await provider.createAuthorizationURL(state, {
scopes
})
}
cookies.set('state', state, {
secure: true,
path: '/',
httpOnly: true,
maxAge: 60 * 10
})
// @ts-expect-error: I know im using it before assignment that's the point
if (codeVerifier) {
cookies.set('code_verifier', codeVerifier, {
secure: true,
path: '/',
httpOnly: true,
maxAge: 60 * 10
})
}
redirect(302, redir)
}
}
export function oauth_callback(): ServerLoad<
{ provider: string },
any,
{
type: 'create' | 'link'
name: string
user: z.infer<typeof publicUser>
form: SuperForm<
ValidationAdapter<
{
token: string
},
{
token: string
}
>,
any
>
prov: string
}
> {
return async ({ cookies, params: { provider: providerID }, locals, url }) => {
let provider: Google | Discord | GitHub
let scopes: string[]
let noop = false
switch (providerID) {
case 'discord': {
provider = discord
scopes = ['identify']
break
}
case 'google': {
provider = google
scopes = ['profile']
break
}
case 'github': {
provider = github
scopes = []
break
}
default: {
noop = true
break
}
}
// @ts-expect-error: I know im using it before assignment that's the point
if (noop || !provider || !scopes) error(404, 'provider not found')
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const storedState = cookies.get('state')
const storedCodeVerifier = cookies.get('code_verifier')
if (
!code ||
!storedState ||
state !== storedState ||
(provider instanceof Google && !storedCodeVerifier)
) {
error(400, 'Invalid request')
}
let id: string
let name: string
if (provider instanceof Google) {
const tokens = await provider.validateAuthorizationCode(code, storedCodeVerifier)
console.log(tokens.idToken)
const { sub, name: Uname } = decodeJwt(tokens.idToken)
id = sub!
name = Uname as string
} else {
const tokens: GitHubTokens | DiscordTokens = await provider.validateAuthorizationCode(code)
if (providerID === 'discord') {
const response = await fetch('https://discord.com/api/users/@me', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`
}
})
if (!response.ok) error(500, 'Unable to contact provider')
const { id: userId, username } = await response.json()
id = userId
name = username
} else if (providerID === 'github') {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`
}
})
if (!response.ok) error(500, 'Unable to contact provider')
const { id: userId, login } = await response.json()
id = userId
name = login
}
}
const existingUser = (await db.user.getOneBySecondaryIndex(`oauth_${providerID}_id`, id))?.flat()
if(existingUser) {
const session = await createSessionForUser(existingUser.id)
const sessionCookie = cookieController.createCookie(session.unwrap().id)
cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
})
redirect(302, '/app')
}
const formToken = generateRandomString(12, alphabet('0-9', 'a-z'))
const form = await superValidate({ token: formToken }, zod(z.object({ token: z.string() })))
if (locals.user) {
// the user is already logged in, ask them if they want to link the account to their existing account, or log out and try again
await db.saved_oauth_data.set(formToken, {
oauth_id: id,
type: 'link'
})
return {
type: 'link',
name,
prov: providerID,
user: publicUser.safeParse(locals.user).data!,
form
}
} else {
// the user is NOT logged in, log them in, if there is no account linked to that provided user, ask them if they want to create an account
await db.saved_oauth_data.set(formToken, {
oauth_id: id,
type: 'create'
})
return {
type: 'create',
name,
prov: providerID,
user: publicUser.safeParse(locals.user).data!,
form
}
}
}
}
export function oauth_callback_actions(): Actions<{ provider: string }> {
return {
link: ({ request, locals, }) => {
// get existing account and link it
},
create: ({ request }) => {
// create new account with
}
}
}