Add full OpenID connect provider support

This commit is contained in:
Jesse Wierzbinski 2023-12-06 12:10:22 -10:00
parent 14d96ac9e6
commit 947c1f4991
No known key found for this signature in database
47 changed files with 604 additions and 247 deletions

BIN
bun.lockb

Binary file not shown.

69
cli.ts
View file

@ -73,6 +73,19 @@ ${chalk.bold("Commands:")}
${chalk.bold("Example:")} ${chalk.bgGray(
`bun cli user search admin`
)}
${alignDots(
chalk.blue("connect-openid")
)} Connect an OpenID account to a local account
${alignDotsSmall(
chalk.green("username")
)} Username of the local account
${alignDotsSmall(chalk.green("issuerId"))} ID of the OpenID issuer
${alignDotsSmall(
chalk.green("serverId")
)} ID of the user on the OpenID server
${chalk.bold("Example:")} ${chalk.bgGray(
`bun cli user connect-openid admin google 123456789`
)}
${alignDots(chalk.blue("note"), 24)} Manage notes
${alignDots(chalk.blue("delete"))} Delete a note
${alignDotsSmall(chalk.green("id"))} ID of the note
@ -403,6 +416,62 @@ switch (command) {
break;
}
case "connect-openid": {
const username = args[4];
const issuerId = args[5];
const serverId = args[6];
if (!username || !issuerId || !serverId) {
console.log(
`${chalk.red(``)} Missing username, issuer or ID`
);
process.exit(1);
}
const user = await client.user.findFirst({
where: {
username: username,
},
});
if (!user) {
console.log(`${chalk.red(``)} User not found`);
process.exit(1);
}
const issuer = config.oidc.providers.find(
p => p.id === issuerId
);
if (!issuer) {
console.log(`${chalk.red(``)} Issuer not found`);
process.exit(1);
}
await client.user.update({
where: {
id: user.id,
},
data: {
linkedOpenIdAccounts: {
create: {
issuerId: issuerId,
serverId: serverId,
},
},
},
});
console.log(
`${chalk.green(
``
)} Connected OpenID account to user ${chalk.blue(
user.username
)}`
);
break;
}
default:
console.log(`Unknown command ${chalk.blue(command)}`);
break;

View file

@ -24,6 +24,18 @@ port = 40007
api_key = ""
enabled = true
# Delete this section if you don't want to use custom OAuth providers
# This is an example configuration
# The provider MUST support OpenID Connect with .well-known discovery
# Most notably, GitHub does not support this
[[oidc.providers]]
name = "CPlusPatch ID"
id = "cpluspatch-id"
url = "https://id.cpluspatch.com/application/o/lysand-testing/"
client_id = "XXXXXXXXXXXXXXXX"
client_secret = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
icon = "https://cpluspatch.com/images/icons/logo.svg"
[http]
base_url = "https://lysand.social"
bind = "http://localhost"

View file

@ -38,7 +38,7 @@
"migrate-dev": "bun prisma migrate dev",
"migrate": "bun prisma migrate deploy",
"lint": "eslint --config .eslintrc.cjs --ext .ts .",
"prisma": "bun run prisma.ts",
"prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma",
"generate": "bun prisma generate",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules",
@ -94,11 +94,13 @@
"linkifyjs": "^4.1.3",
"marked": "^9.1.2",
"meilisearch": "^0.36.0",
"oauth4webapi": "^2.4.0",
"prisma": "^5.6.0",
"prisma-redis-middleware": "^4.8.0",
"semver": "^7.5.4",
"sharp": "^0.33.0-rc.2",
"vite": "^5.0.4",
"vite-ssr": "^0.17.1",
"vue": "^3.3.9",
"vue-router": "^4.2.5",
"vue-tsc": "^1.8.24"

View file

@ -1,6 +1,6 @@
<script setup>
// Import Tailwind style reset
import '@unocss/reset/tailwind.css'
import '@unocss/reset/tailwind-compat.css'
</script>
<template>

View file

@ -0,0 +1,29 @@
<template>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">{{ label }}</label>
</div>
<div class="mt-2">
<input v-bind="$attrs" @input="checkValid" :class="['block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6',
isInvalid && 'invalid:!ring-red-600 invalid:ring-2']">
<span v-if="isInvalid" class="mt-1 text-xs text-red-600">{{ label }} is invalid</span>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{
label: string;
}>();
const isInvalid = ref(false);
const checkValid = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checkValidity()) {
isInvalid.value = false;
} else {
isInvalid.value = true;
}
}
</script>

View file

@ -1,28 +1,43 @@
<template>
<div class="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8">
<div class="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8">
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form class="space-y-6" method="POST" :action="`/auth/login?redirect_uri=${redirect_uri}&response_type=${response_type}&client_id=${client_id}&scope=${scope}`">
<form class="space-y-6" method="POST"
:action="`/auth/login?redirect_uri=${redirect_uri}&response_type=${response_type}&client_id=${client_id}&scope=${scope}`">
<div>
<label for="email" class="block text-sm font-medium leading-6 text-gray-900">Email address</label>
<div class="mt-2">
<input id="email" name="email" type="email" autocomplete="email" required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
</div>
<h1 class="font-bold text-2xl text-center tracking-tight">Login to your account</h1>
</div>
<div v-if="error && error !== 'undefined'" class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
<h3 class="font-bold">An error occured:</h3>
<p>{{ error }}</p>
</div>
<div>
<div class="flex items-center justify-between">
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
</div>
<div class="mt-2">
<input id="password" name="password" type="password" autocomplete="current-password" required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
<LoginInput label="Email" id="email" name="email" type="email" autocomplete="email" required />
</div>
<div>
<LoginInput label="Password" id="password" name="password" type="password"
autocomplete="current-password" required />
</div>
<div v-if="oauthProviders && oauthProviders.length > 0" class="w-full flex flex-col gap-3">
<h2 class="text-sm text-gray-700">Or sign in with</h2>
<div class="grid grid-cols-1 gap-4 w-full">
<a v-for="provider of oauthProviders" :key="provider.id"
:href="`/oauth/authorize-external?issuer=${provider.id}&redirect_uri=${redirect_uri}&response_type=${response_type}&clientId=${client_id}&scope=${scope}`"
class="flex flex-row rounded ring-1 gap-2 p-2 ring-black/10 hover:shadow duration-200">
<img :src="provider.icon" :alt="provider.name" class="w-8 h-8" />
<div class="flex flex-col gap-0 justify-center">
<h3 class="font-bold">{{ provider.name }}</h3>
</div>
</a>
</div>
</div>
<div>
<button type="submit"
class="flex w-full justify-center rounded-md !bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign
class="flex w-full justify-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:shadow-lg duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign
in</button>
</div>
</form>
@ -32,6 +47,8 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import LoginInput from "./components/LoginInput.vue"
import { onMounted, ref } from 'vue';
const query = useRoute().query;
@ -39,4 +56,20 @@ const redirect_uri = query.redirect_uri;
const response_type = query.response_type;
const client_id = query.client_id;
const scope = query.scope;
const error = decodeURIComponent(query.error as string);
const oauthProviders = ref<{
name: string;
icon: string;
id: string
}[] | null>(null);
const getOauthProviders = async () => {
const response = await fetch('/oauth/providers');
return await response.json() as any;
}
onMounted(async () => {
oauthProviders.value = await getOauthProviders();
})
</script>

View file

@ -1,5 +1,6 @@
import { createApp } from "vue";
import "./style.css";
import "virtual:uno.css";
import { createRouter, createWebHistory } from "vue-router";
import Login from "./login.vue";
import App from "./App.vue";
@ -8,7 +9,7 @@ const Home = { template: "<div>Home</div>" };
const routes = [
{ path: "/", component: Home },
{ path: "/oauth/login", component: Login },
{ path: "/oauth/authorize", component: Login },
];
const router = createRouter({

View file

@ -1,155 +0,0 @@
/* layer: preflights */
*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}
[type='text'], [type='email'], [type='url'], [type='password'], [type='number'], [type='date'], [type='datetime-local'], [type='month'], [type='search'], [type='tel'], [type='time'], [type='week'], [multiple], textarea, select { appearance: none;
background-color: #fff;
border-color: #6b7280;
border-width: 1px;
border-radius: 0;
padding-top: 0.5rem;
padding-right: 0.75rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
font-size: 1rem;
line-height: 1.5rem;
--un-shadow: 0 0 #0000; }
[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { outline: 2px solid transparent;
outline-offset: 2px;
--un-ring-inset: var(--un-empty,/*!*/ /*!*/);
--un-ring-offset-width: 0px;
--un-ring-offset-color: #fff;
--un-ring-color: #2563eb;
--un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);
--un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(1px + var(--un-ring-offset-width)) var(--un-ring-color);
box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);
border-color: #2563eb; }
input::placeholder, textarea::placeholder { color: #6b7280;
opacity: 1; }
::-webkit-datetime-edit-fields-wrapper { padding: 0; }
::-webkit-date-and-time-value { min-height: 1.5em; }
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-top: 0;
padding-bottom: 0; }
select { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
print-color-adjust: exact; }
[multiple] { background-image: initial;
background-position: initial;
background-repeat: unset;
background-size: initial;
padding-right: 0.75rem;
print-color-adjust: unset; }
[type='checkbox'], [type='radio'] { appearance: none;
padding: 0;
print-color-adjust: exact;
display: inline-block;
vertical-align: middle;
background-origin: border-box;
user-select: none;
flex-shrink: 0;
height: 1rem;
width: 1rem;
color: #2563eb;
background-color: #fff;
border-color: #6b7280;
border-width: 1px;
--un-shadow: 0 0 #0000; }
[type='checkbox'] { border-radius: 0; }
[type='radio'] { border-radius: 100%; }
[type='checkbox']:focus, [type='radio']:focus { outline: 2px solid transparent;
outline-offset: 2px;
--un-ring-inset: var(--un-empty,/*!*/ /*!*/);
--un-ring-offset-width: 2px;
--un-ring-offset-color: #fff;
--un-ring-color: #2563eb;
--un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);
--un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(2px + var(--un-ring-offset-width)) var(--un-ring-color);
box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow); }
[type='checkbox']:checked, [type='radio']:checked { border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat; }
[type='checkbox']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); }
[type='radio']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); }
[type='checkbox']:checked:hover, [type='checkbox']:checked:focus, [type='radio']:checked:hover, [type='radio']:checked:focus { border-color: transparent;
background-color: currentColor; }
[type='checkbox']:indeterminate { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat; }
[type='checkbox']:indeterminate:hover, [type='checkbox']:indeterminate:focus { border-color: transparent;
background-color: currentColor; }
[type='file'] { background: unset;
border-color: inherit;
border-width: 0;
border-radius: 0;
padding: 0;
font-size: unset;
line-height: inherit; }
[type='file']:focus { outline: 1px solid ButtonText , 1px auto -webkit-focus-ring-color; }
/* layer: default */
.visible{visibility:visible;}
.relative{position:relative;}
.mt-10{margin-top:2.5rem;}
.mt-2{margin-top:0.5rem;}
.block{display:block;}
.contents{display:contents;}
.list-item{display:list-item;}
.hidden{display:none;}
.h6{height:1.5rem;}
.min-h-screen{min-height:100vh;}
.w-full{width:100%;}
.flex{display:flex;}
.flex-col{flex-direction:column;}
.table{display:table;}
.border-collapse{border-collapse:collapse;}
.transform{transform:translateX(var(--un-translate-x)) translateY(var(--un-translate-y)) translateZ(var(--un-translate-z)) rotate(var(--un-rotate)) rotateX(var(--un-rotate-x)) rotateY(var(--un-rotate-y)) rotateZ(var(--un-rotate-z)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) scaleZ(var(--un-scale-z));}
.resize{resize:both;}
.items-center{align-items:center;}
.justify-center{justify-content:center;}
.justify-between{justify-content:space-between;}
.space-y-6>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1.5rem * var(--un-space-y-reverse));}
.border{border-width:1px;}
.border-0{border-width:0;}
.rounded-md{border-radius:0.375rem;}
.\!bg-indigo-600{--un-bg-opacity:1 !important;background-color:rgba(79,70,229,var(--un-bg-opacity)) !important;}
.hover\:\!bg-indigo-500:hover{--un-bg-opacity:1 !important;background-color:rgba(99,102,241,var(--un-bg-opacity)) !important;}
.px-3{padding-left:0.75rem;padding-right:0.75rem;}
.px-6{padding-left:1.5rem;padding-right:1.5rem;}
.py-1\.5{padding-top:0.375rem;padding-bottom:0.375rem;}
.py-12{padding-top:3rem;padding-bottom:3rem;}
.text-sm{font-size:0.875rem;line-height:1.25rem;}
.font-medium{font-weight:500;}
.font-semibold{font-weight:600;}
.leading-6{line-height:1.5rem;}
.text-gray-900{--un-text-opacity:1;color:rgba(17,24,39,var(--un-text-opacity));}
.text-white{--un-text-opacity:1;color:rgba(255,255,255,var(--un-text-opacity));}
.placeholder\:text-gray-400::placeholder{--un-text-opacity:1;color:rgba(156,163,175,var(--un-text-opacity));}
.underline{text-decoration-line:underline;}
.tab{-moz-tab-size:4;-o-tab-size:4;tab-size:4;}
.shadow-sm{--un-shadow:var(--un-shadow-inset) 0 1px 2px 0 var(--un-shadow-color, rgba(0,0,0,0.05));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.focus-visible\:outline-2:focus-visible{outline-width:2px;}
.focus-visible\:outline-indigo-600:focus-visible{--un-outline-color-opacity:1;outline-color:rgba(79,70,229,var(--un-outline-color-opacity));}
.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px;}
.outline{outline-style:solid;}
.focus-visible\:outline:focus-visible{outline-style:solid;}
.ring-1{--un-ring-width:1px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.focus\:ring-2:focus{--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.ring-gray-300{--un-ring-opacity:1;--un-ring-color:rgba(209,213,219,var(--un-ring-opacity));}
.focus\:ring-indigo-600:focus{--un-ring-opacity:1;--un-ring-color:rgba(79,70,229,var(--un-ring-opacity));}
.ring-inset{--un-ring-inset:inset;}
.focus\:ring-inset:focus{--un-ring-inset:inset;}
@media (min-width: 640px){
.sm\:mx-auto{margin-left:auto;margin-right:auto;}
.sm\:max-w-sm{max-width:24rem;}
.sm\:w-full{width:100%;}
.sm\:text-sm{font-size:0.875rem;line-height:1.25rem;}
.sm\:leading-6{line-height:1.5rem;}
}
@media (min-width: 1024px){
.lg\:px-8{padding-left:2rem;padding-right:2rem;}
}

View file

@ -23,7 +23,7 @@ export default defineConfig({
},
plugins: [
UnoCSS({
mode: "vue-scoped",
mode: "global",
}),
vue(),
],

View file

@ -2,16 +2,8 @@
import { getConfig } from "@config";
const args = process.argv.slice(2);
const config = getConfig();
const { stdout } = Bun.spawn(["bunx", "prisma", ...args], {
env: {
...process.env,
DATABASE_URL: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,
},
});
// Show stdout
const text = await new Response(stdout).text();
console.log(text);
process.stdout.write(
`postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`
);

View file

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "OpenIdLoginFlow" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"codeVerifier" TEXT NOT NULL,
CONSTRAINT "OpenIdLoginFlow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OpenIdAccount" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"userId" UUID,
"serverId" TEXT NOT NULL,
"issuerId" TEXT NOT NULL,
CONSTRAINT "OpenIdAccount_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "OpenIdAccount" ADD CONSTRAINT "OpenIdAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -139,6 +139,11 @@ model Token {
applicationId String? @db.Uuid
}
model OpenIdLoginFlow {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
codeVerifier String
}
model Attachment {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
url String
@ -170,36 +175,45 @@ model Notification {
}
model User {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
uri String @unique
username String @unique
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
uri String @unique
username String @unique
displayName String
password String? // Nullable
email String? @unique // Nullable
note String @default("")
isAdmin Boolean @default(false)
email String? @unique // Nullable
note String @default("")
isAdmin Boolean @default(false)
endpoints Json? // Nullable
source Json
avatar String
header String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isBot Boolean @default(false)
isLocked Boolean @default(false)
isDiscoverable Boolean @default(false)
sanctions String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isBot Boolean @default(false)
isLocked Boolean @default(false)
isDiscoverable Boolean @default(false)
sanctions String[] @default([])
publicKey String
privateKey String? // Nullable
relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship
instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance
instanceId String? @db.Uuid
pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship
instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance
instanceId String? @db.Uuid
pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
emojis Emoji[] // Many to many relation with Emoji
statuses Status[] @relation("UserStatuses") // One to many relation with Status
statuses Status[] @relation("UserStatuses") // One to many relation with Status
tokens Token[] // One to many relation with Token
likes Like[] @relation("UserLiked") // One to many relation with Like
likes Like[] @relation("UserLiked") // One to many relation with Like
statusesMentioned Status[] // Many to many relation with Status
notifications Notification[] // One to many relation with Notification
notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification
notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification
linkedOpenIdAccounts OpenIdAccount[] // One to many relation with OpenIdAccount
}
model OpenIdAccount {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
User User? @relation(fields: [userId], references: [id])
userId String? @db.Uuid
serverId String // ID on the authorization server
issuerId String
}

View file

@ -1,5 +1,4 @@
import { applyConfig } from "@api";
import { errorResponse } from "@response";
import type { MatchedRoute } from "bun";
import { randomBytes } from "crypto";
import { client } from "~database/datasource";
@ -38,11 +37,21 @@ export default async (
const email = formData.get("email")?.toString() || null;
const password = formData.get("password")?.toString() || null;
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?` +
new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString(),
302
);
if (response_type !== "code")
return errorResponse("Invalid response type (try 'code')", 400);
return redirectToLogin("Invalid response_type");
if (!email || !password)
return errorResponse("Missing username or password", 400);
return redirectToLogin("Invalid username or password");
// Get user
const user = await client.user.findFirst({
@ -53,7 +62,7 @@ export default async (
});
if (!user || !(await Bun.password.verify(password, user.password || "")))
return errorResponse("Invalid username or password", 401);
return redirectToLogin("Invalid username or password");
// Get application
const application = await client.application.findFirst({
@ -62,7 +71,7 @@ export default async (
},
});
if (!application) return errorResponse("Invalid client_id", 404);
if (!application) return redirectToLogin("Invalid client_id");
const code = randomBytes(32).toString("hex");

View file

@ -0,0 +1,84 @@
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { oauthRedirectUri } from "@constants";
import type { MatchedRoute } from "bun";
import {
calculatePKCECodeChallenge,
discoveryRequest,
processDiscoveryResponse,
} from "oauth4webapi";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/authorize-external",
});
/**
* Redirects the user to the external OAuth provider
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
// eslint-disable-next-line @typescript-eslint/require-await
): Promise<Response> => {
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?` +
new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString(),
302
);
const issuerId = matchedRoute.query.issuer;
// This is the Lysand client's client_id, not the external OAuth provider's client_id
const clientId = matchedRoute.query.clientId;
if (!clientId || clientId === "undefined") {
return redirectToLogin("Missing client_id");
}
const config = getConfig();
const issuer = config.oidc.providers.find(
provider => provider.id === issuerId
);
if (!issuer) {
return redirectToLogin("Invalid issuer");
}
const issuerUrl = new URL(issuer.url);
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then(res => processDiscoveryResponse(issuerUrl, res));
const codeVerifier = "tempString";
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
return Response.redirect(
authServer.authorization_endpoint +
"?" +
new URLSearchParams({
client_id: issuer.client_id,
redirect_uri:
oauthRedirectUri(issuerId) + `?clientId=${clientId}`,
response_type: "code",
scope: "openid profile email",
// PKCE
code_challenge_method: "S256",
code_challenge: codeChallenge,
}).toString(),
302
);
};

View file

@ -1,38 +0,0 @@
import { applyConfig } from "@api";
import type { MatchedRoute } from "bun";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/authorize",
});
/**
* Returns an HTML login form
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const html = Bun.file("./pages/login.html");
const css = Bun.file("./pages/uno.css");
return new Response(
(await html.text())
.replace(
"{{URL}}",
`/auth/login?redirect_uri=${matchedRoute.query.redirect_uri}&response_type=${matchedRoute.query.response_type}&client_id=${matchedRoute.query.client_id}&scope=${matchedRoute.query.scope}`
)
.replace("{{STYLES}}", `<style>${await css.text()}</style>`),
{
headers: {
"Content-Type": "text/html",
},
}
);
};

View file

@ -0,0 +1,187 @@
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { oauthRedirectUri } from "@constants";
import type { MatchedRoute } from "bun";
import { randomBytes } from "crypto";
import {
authorizationCodeGrantRequest,
discoveryRequest,
expectNoState,
isOAuth2Error,
processDiscoveryResponse,
validateAuthResponse,
userInfoRequest,
processAuthorizationCodeOpenIDResponse,
processUserInfoResponse,
getValidatedIdTokenClaims,
} from "oauth4webapi";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/callback/:issuer",
});
/**
* Redirects the user to the external OAuth provider
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?` +
new URLSearchParams({
client_id: matchedRoute.query.clientId,
error: encodeURIComponent(error),
}).toString(),
302
);
const currentUrl = new URL(req.url);
// Remove state query parameter from URL
currentUrl.searchParams.delete("state");
const issuerParam = matchedRoute.params.issuer;
// This is the Lysand client's client_id, not the external OAuth provider's client_id
const clientId = matchedRoute.query.clientId;
const config = getConfig();
const issuer = config.oidc.providers.find(
provider => provider.id === issuerParam
);
if (!issuer) {
return redirectToLogin("Invalid issuer");
}
const issuerUrl = new URL(issuer.url);
const authServer = await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then(res => processDiscoveryResponse(issuerUrl, res));
const parameters = validateAuthResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
currentUrl,
// Whether to expect state or not
expectNoState
);
if (isOAuth2Error(parameters)) {
return redirectToLogin(
parameters.error_description || parameters.error
);
}
const response = await authorizationCodeGrantRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
parameters,
oauthRedirectUri(issuerParam) + `?clientId=${clientId}`,
"tempString"
);
const result = await processAuthorizationCodeOpenIDResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
response
);
if (isOAuth2Error(result)) {
return redirectToLogin(result.error_description || result.error);
}
const { access_token } = result;
const claims = getValidatedIdTokenClaims(result);
const { sub } = claims;
// Validate `sub`
// Later, we'll use this to automatically set the user's data
await userInfoRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
access_token
).then(res =>
processUserInfoResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
sub,
res
)
);
const user = await client.user.findFirst({
where: {
linkedOpenIdAccounts: {
some: {
serverId: sub,
issuerId: issuer.id,
},
},
},
});
if (!user) {
return redirectToLogin("No user found with that account");
}
const application = await client.application.findFirst({
where: {
client_id: clientId,
},
});
if (!application) return redirectToLogin("Invalid client_id");
const code = randomBytes(32).toString("hex");
await client.application.update({
where: { id: application.id },
data: {
tokens: {
create: {
access_token: randomBytes(64).toString("base64url"),
code: code,
scope: application.scopes,
token_type: TokenType.BEARER,
user: {
connect: {
id: user.id,
},
},
},
},
},
});
// Redirect back to application
return Response.redirect(`${application.redirect_uris}?code=${code}`, 302);
};

View file

@ -0,0 +1,31 @@
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { jsonResponse } from "@response";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 10,
},
route: "/oauth/providers",
});
/**
* Lists available OAuth providers
*/
// eslint-disable-next-line @typescript-eslint/require-await
export default async (): Promise<Response> => {
const config = getConfig();
return jsonResponse(
config.oidc.providers.map(p => ({
name: p.name,
icon: p.icon,
id: p.id,
}))
);
};

View file

@ -0,0 +1 @@
FMFZr5UtmZkKH6vPzSZijJbpF3ySKLaocHNtHghJJdw

View file

@ -0,0 +1 @@
dOeAMvn88EbHZAYx0EJNbrw97XhzbxZXXYOt7lF8zzQ

View file

@ -0,0 +1 @@
3QEq5HYhdzRX3Rozulasmj2XAYlWiX9zwIJY2JDn4uw

View file

@ -0,0 +1 @@
OJLAurMR0_MdWhsYAnGigFjClLrAaBwEDKIr5cddeuI

View file

@ -0,0 +1 @@
S_yh9zW_PpgN5r4ngIESd5GRarZfMIBUoaGOJZhjpc8

View file

@ -0,0 +1 @@
uWPueKrIXJdtgVGEQ9n8peXmz0B6qb1ohEmOqYgCdws

View file

@ -0,0 +1 @@
HfOXX-niktBwwUJOLP6sq_IdbIWkVPt1BqWBbKKd_0A

View file

@ -0,0 +1 @@
rOUTRmSFMJMPS0tUtqklfJo-_VAxMAFKCN8uprb0e64

View file

@ -0,0 +1 @@
EsXqSioUt0FTlNfLcVIAPs9U3pS69nO1NbabgpPkyR0

View file

@ -0,0 +1 @@
DPAF37_dnsfE2YZNDkvqBkFXs745JQ4I7KDftNEK5kM

View file

@ -0,0 +1 @@
8PsIJrWP09gJqW_OSLCSygXlPqaiCPryBk-VR8mT2rA

View file

@ -0,0 +1 @@
lYOBZxX217uXRwm85z7GNKsc7TViRZ9At3yUIGzkkPM

View file

@ -0,0 +1 @@
TaOooO3g0FyrLrwTwJG45AkS7mLT0O1btt1P2Vr1dEQ

View file

@ -0,0 +1 @@
BD9We2MPmCytIKlsH7Wug8bmW9_lU-Xrx_-c-qhpsEI

View file

@ -0,0 +1 @@
OlX8iM3rrAro8J_Ncb4OvN6tLKuyfjLMEOG_xJkbeHU

View file

@ -0,0 +1 @@
VMalTqVPTIUY-ynsIQJO4jG16KyxkMhsQgoKW3doOHo

View file

@ -0,0 +1 @@
tIXKI7IDrAikA_SbBTW6XqBQfR2uuWAg9WQYpiZ5DE0

View file

@ -0,0 +1 @@
LdpsUh6F78GzSXz5zfZUW2ZaUbjoCC-zGrCjnz4gtDU

View file

@ -0,0 +1 @@
8Ch2fssgHUf4qTO4kHM-9yofisoalAufolmFkSOvo40

View file

@ -0,0 +1 @@
Yx9ZPZ11yaa64EApoZKLNNp0cIsp-GWoZmPk8Rg3yJA

View file

@ -0,0 +1 @@
0I35VFoN26g_y5ssRk0ilIjtmKD7s8oOVgqPCumt8Dw

View file

@ -0,0 +1 @@
HoddM2bqLSELN9ICAn5Wx42dr0pLHrr-a48K_Zhes2w

View file

@ -0,0 +1 @@
pdE7NC8nsUsxnywZah1akVtr324QI0zQVJOBabnyJSA

View file

@ -0,0 +1 @@
20WBny0Y2NnacGObQ91zCjRGQZdLhWzH8BaQfsQOga0

View file

@ -0,0 +1 @@
Bf8C4ChWmNROZkKLoMxVzQ9q6XMI253sje3d4IK_95Y

View file

@ -32,6 +32,17 @@ export interface ConfigType {
enabled: boolean;
};
oidc: {
providers: {
name: string;
id: string;
url: string;
client_id: string;
client_secret: string;
icon: string;
}[];
};
http: {
base_url: string;
bind: string;
@ -189,6 +200,9 @@ export const configDefaults: ConfigType = {
api_key: "",
enabled: false,
},
oidc: {
providers: [],
},
instance: {
banner: "",
description: "",

6
utils/constants.ts Normal file
View file

@ -0,0 +1,6 @@
import { getConfig } from "@config";
const config = getConfig();
export const oauthRedirectUri = (issuer: string) =>
`${config.http.base_url}/oauth/callback/${issuer}`;

View file

@ -59,3 +59,5 @@ export const checkIfOauthIsValid = (
return false;
};
export const oauthCodeVerifiers: Record<string, string> = {};

20
utils/temp.ts Normal file
View file

@ -0,0 +1,20 @@
import { join } from "path";
import { exists, mkdir, writeFile, readFile } from "fs/promises";
export const writeToTempDirectory = async (filename: string, data: string) => {
const tempDir = join(process.cwd(), "temp");
if (!(await exists(tempDir))) await mkdir(tempDir);
const tempFile = join(tempDir, filename);
await writeFile(tempFile, data);
return tempFile;
};
export const readFromTempDirectory = async (filename: string) => {
const tempDir = join(process.cwd(), "temp");
if (!(await exists(tempDir))) await mkdir(tempDir);
const tempFile = join(tempDir, filename);
return readFile(tempFile, "utf-8");
};