mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
Add full OpenID connect provider support
This commit is contained in:
parent
14d96ac9e6
commit
947c1f4991
69
cli.ts
69
cli.ts
|
|
@ -73,6 +73,19 @@ ${chalk.bold("Commands:")}
|
||||||
${chalk.bold("Example:")} ${chalk.bgGray(
|
${chalk.bold("Example:")} ${chalk.bgGray(
|
||||||
`bun cli user search admin`
|
`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("note"), 24)} Manage notes
|
||||||
${alignDots(chalk.blue("delete"))} Delete a note
|
${alignDots(chalk.blue("delete"))} Delete a note
|
||||||
${alignDotsSmall(chalk.green("id"))} ID of the note
|
${alignDotsSmall(chalk.green("id"))} ID of the note
|
||||||
|
|
@ -403,6 +416,62 @@ switch (command) {
|
||||||
|
|
||||||
break;
|
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:
|
default:
|
||||||
console.log(`Unknown command ${chalk.blue(command)}`);
|
console.log(`Unknown command ${chalk.blue(command)}`);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,18 @@ port = 40007
|
||||||
api_key = ""
|
api_key = ""
|
||||||
enabled = true
|
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]
|
[http]
|
||||||
base_url = "https://lysand.social"
|
base_url = "https://lysand.social"
|
||||||
bind = "http://localhost"
|
bind = "http://localhost"
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
"migrate-dev": "bun prisma migrate dev",
|
"migrate-dev": "bun prisma migrate dev",
|
||||||
"migrate": "bun prisma migrate deploy",
|
"migrate": "bun prisma migrate deploy",
|
||||||
"lint": "eslint --config .eslintrc.cjs --ext .ts .",
|
"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",
|
"generate": "bun prisma generate",
|
||||||
"benchmark:timeline": "bun run benchmarks/timelines.ts",
|
"benchmark:timeline": "bun run benchmarks/timelines.ts",
|
||||||
"cloc": "cloc . --exclude-dir node_modules",
|
"cloc": "cloc . --exclude-dir node_modules",
|
||||||
|
|
@ -94,11 +94,13 @@
|
||||||
"linkifyjs": "^4.1.3",
|
"linkifyjs": "^4.1.3",
|
||||||
"marked": "^9.1.2",
|
"marked": "^9.1.2",
|
||||||
"meilisearch": "^0.36.0",
|
"meilisearch": "^0.36.0",
|
||||||
|
"oauth4webapi": "^2.4.0",
|
||||||
"prisma": "^5.6.0",
|
"prisma": "^5.6.0",
|
||||||
"prisma-redis-middleware": "^4.8.0",
|
"prisma-redis-middleware": "^4.8.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"sharp": "^0.33.0-rc.2",
|
"sharp": "^0.33.0-rc.2",
|
||||||
"vite": "^5.0.4",
|
"vite": "^5.0.4",
|
||||||
|
"vite-ssr": "^0.17.1",
|
||||||
"vue": "^3.3.9",
|
"vue": "^3.3.9",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
"vue-tsc": "^1.8.24"
|
"vue-tsc": "^1.8.24"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
// Import Tailwind style reset
|
// Import Tailwind style reset
|
||||||
import '@unocss/reset/tailwind.css'
|
import '@unocss/reset/tailwind-compat.css'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
29
pages/components/LoginInput.vue
Normal file
29
pages/components/LoginInput.vue
Normal 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>
|
||||||
|
|
@ -1,28 +1,43 @@
|
||||||
<template>
|
<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">
|
<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>
|
<div>
|
||||||
<label for="email" class="block text-sm font-medium leading-6 text-gray-900">Email address</label>
|
<h1 class="font-bold text-2xl text-center tracking-tight">Login to your account</h1>
|
||||||
<div class="mt-2">
|
</div>
|
||||||
<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 v-if="error && error !== 'undefined'" class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
|
||||||
</div>
|
<h3 class="font-bold">An error occured:</h3>
|
||||||
|
<p>{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<LoginInput label="Email" id="email" name="email" type="email" autocomplete="email" required />
|
||||||
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
<div>
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required
|
<LoginInput label="Password" id="password" name="password" type="password"
|
||||||
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">
|
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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button type="submit"
|
<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>
|
in</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -32,6 +47,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import LoginInput from "./components/LoginInput.vue"
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const query = useRoute().query;
|
const query = useRoute().query;
|
||||||
|
|
||||||
|
|
@ -39,4 +56,20 @@ const redirect_uri = query.redirect_uri;
|
||||||
const response_type = query.response_type;
|
const response_type = query.response_type;
|
||||||
const client_id = query.client_id;
|
const client_id = query.client_id;
|
||||||
const scope = query.scope;
|
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>
|
</script>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
import "virtual:uno.css";
|
||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import Login from "./login.vue";
|
import Login from "./login.vue";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
|
@ -8,7 +9,7 @@ const Home = { template: "<div>Home</div>" };
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", component: Home },
|
{ path: "/", component: Home },
|
||||||
{ path: "/oauth/login", component: Login },
|
{ path: "/oauth/authorize", component: Login },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
|
||||||
155
pages/uno.css
155
pages/uno.css
|
|
@ -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;}
|
|
||||||
}
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
UnoCSS({
|
UnoCSS({
|
||||||
mode: "vue-scoped",
|
mode: "global",
|
||||||
}),
|
}),
|
||||||
vue(),
|
vue(),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
14
prisma.ts
14
prisma.ts
|
|
@ -2,16 +2,8 @@
|
||||||
|
|
||||||
import { getConfig } from "@config";
|
import { getConfig } from "@config";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
const { stdout } = Bun.spawn(["bunx", "prisma", ...args], {
|
process.stdout.write(
|
||||||
env: {
|
`postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`
|
||||||
...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);
|
|
||||||
|
|
|
||||||
20
prisma/migrations/20231206215508_add_openid/migration.sql
Normal file
20
prisma/migrations/20231206215508_add_openid/migration.sql
Normal 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;
|
||||||
|
|
@ -139,6 +139,11 @@ model Token {
|
||||||
applicationId String? @db.Uuid
|
applicationId String? @db.Uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model OpenIdLoginFlow {
|
||||||
|
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
|
||||||
|
codeVerifier String
|
||||||
|
}
|
||||||
|
|
||||||
model Attachment {
|
model Attachment {
|
||||||
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
|
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
|
||||||
url String
|
url String
|
||||||
|
|
@ -170,36 +175,45 @@ model Notification {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
|
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
|
||||||
uri String @unique
|
uri String @unique
|
||||||
username String @unique
|
username String @unique
|
||||||
displayName String
|
displayName String
|
||||||
password String? // Nullable
|
password String? // Nullable
|
||||||
email String? @unique // Nullable
|
email String? @unique // Nullable
|
||||||
note String @default("")
|
note String @default("")
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
endpoints Json? // Nullable
|
endpoints Json? // Nullable
|
||||||
source Json
|
source Json
|
||||||
avatar String
|
avatar String
|
||||||
header String
|
header String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
isBot Boolean @default(false)
|
isBot Boolean @default(false)
|
||||||
isLocked Boolean @default(false)
|
isLocked Boolean @default(false)
|
||||||
isDiscoverable Boolean @default(false)
|
isDiscoverable Boolean @default(false)
|
||||||
sanctions String[] @default([])
|
sanctions String[] @default([])
|
||||||
publicKey String
|
publicKey String
|
||||||
privateKey String? // Nullable
|
privateKey String? // Nullable
|
||||||
relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
|
relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
|
||||||
relationshipSubjects Relationship[] @relation("SubjectToRelationship") // 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
|
instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance
|
||||||
instanceId String? @db.Uuid
|
instanceId String? @db.Uuid
|
||||||
pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
|
pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
|
||||||
emojis Emoji[] // Many to many relation with Emoji
|
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
|
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
|
statusesMentioned Status[] // Many to many relation with Status
|
||||||
notifications Notification[] // One to many relation with Notification
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
import { errorResponse } from "@response";
|
|
||||||
import type { MatchedRoute } from "bun";
|
import type { MatchedRoute } from "bun";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
|
|
@ -38,11 +37,21 @@ export default async (
|
||||||
const email = formData.get("email")?.toString() || null;
|
const email = formData.get("email")?.toString() || null;
|
||||||
const password = formData.get("password")?.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")
|
if (response_type !== "code")
|
||||||
return errorResponse("Invalid response type (try 'code')", 400);
|
return redirectToLogin("Invalid response_type");
|
||||||
|
|
||||||
if (!email || !password)
|
if (!email || !password)
|
||||||
return errorResponse("Missing username or password", 400);
|
return redirectToLogin("Invalid username or password");
|
||||||
|
|
||||||
// Get user
|
// Get user
|
||||||
const user = await client.user.findFirst({
|
const user = await client.user.findFirst({
|
||||||
|
|
@ -53,7 +62,7 @@ export default async (
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !(await Bun.password.verify(password, user.password || "")))
|
if (!user || !(await Bun.password.verify(password, user.password || "")))
|
||||||
return errorResponse("Invalid username or password", 401);
|
return redirectToLogin("Invalid username or password");
|
||||||
|
|
||||||
// Get application
|
// Get application
|
||||||
const application = await client.application.findFirst({
|
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");
|
const code = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
|
|
||||||
84
server/api/oauth/authorize-external/index.ts
Normal file
84
server/api/oauth/authorize-external/index.ts
Normal 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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
187
server/api/oauth/callback/[issuer]/index.ts
Normal file
187
server/api/oauth/callback/[issuer]/index.ts
Normal 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);
|
||||||
|
};
|
||||||
31
server/api/oauth/providers/index.ts
Normal file
31
server/api/oauth/providers/index.ts
Normal 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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
1
temp/flow_0exywktgkcq6wjwa691mat
Normal file
1
temp/flow_0exywktgkcq6wjwa691mat
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
FMFZr5UtmZkKH6vPzSZijJbpF3ySKLaocHNtHghJJdw
|
||||||
1
temp/flow_2c0ojqmxbidvqt6g964fnn
Normal file
1
temp/flow_2c0ojqmxbidvqt6g964fnn
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
dOeAMvn88EbHZAYx0EJNbrw97XhzbxZXXYOt7lF8zzQ
|
||||||
1
temp/flow_366pnjfy46z7a6ijrv2s2e
Normal file
1
temp/flow_366pnjfy46z7a6ijrv2s2e
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3QEq5HYhdzRX3Rozulasmj2XAYlWiX9zwIJY2JDn4uw
|
||||||
1
temp/flow_9gk2f91f7rpr4zz1lza4n8
Normal file
1
temp/flow_9gk2f91f7rpr4zz1lza4n8
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
OJLAurMR0_MdWhsYAnGigFjClLrAaBwEDKIr5cddeuI
|
||||||
1
temp/flow_9xab9zbynrche1s5oqpko
Normal file
1
temp/flow_9xab9zbynrche1s5oqpko
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
S_yh9zW_PpgN5r4ngIESd5GRarZfMIBUoaGOJZhjpc8
|
||||||
1
temp/flow_adnvd9wrsykciwdbfz9da8
Normal file
1
temp/flow_adnvd9wrsykciwdbfz9da8
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uWPueKrIXJdtgVGEQ9n8peXmz0B6qb1ohEmOqYgCdws
|
||||||
1
temp/flow_b7w5jcwll3tee0l4tq9ejw
Normal file
1
temp/flow_b7w5jcwll3tee0l4tq9ejw
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
HfOXX-niktBwwUJOLP6sq_IdbIWkVPt1BqWBbKKd_0A
|
||||||
1
temp/flow_bcw2mcd2l3pbfsvwp4t1tp
Normal file
1
temp/flow_bcw2mcd2l3pbfsvwp4t1tp
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
rOUTRmSFMJMPS0tUtqklfJo-_VAxMAFKCN8uprb0e64
|
||||||
1
temp/flow_be9y4se653i4ks1qdvhiu5
Normal file
1
temp/flow_be9y4se653i4ks1qdvhiu5
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
EsXqSioUt0FTlNfLcVIAPs9U3pS69nO1NbabgpPkyR0
|
||||||
1
temp/flow_bwluoig4sco7i5jrau0u
Normal file
1
temp/flow_bwluoig4sco7i5jrau0u
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DPAF37_dnsfE2YZNDkvqBkFXs745JQ4I7KDftNEK5kM
|
||||||
1
temp/flow_ckkbeibg93nohnk87ljuk
Normal file
1
temp/flow_ckkbeibg93nohnk87ljuk
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
8PsIJrWP09gJqW_OSLCSygXlPqaiCPryBk-VR8mT2rA
|
||||||
1
temp/flow_ggo4fp8ui1ejwinui77ycf
Normal file
1
temp/flow_ggo4fp8ui1ejwinui77ycf
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
lYOBZxX217uXRwm85z7GNKsc7TViRZ9At3yUIGzkkPM
|
||||||
1
temp/flow_jp0vw18g9qe63tucqixezt
Normal file
1
temp/flow_jp0vw18g9qe63tucqixezt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
TaOooO3g0FyrLrwTwJG45AkS7mLT0O1btt1P2Vr1dEQ
|
||||||
1
temp/flow_llg9kgdkbdmy8m7e023lm
Normal file
1
temp/flow_llg9kgdkbdmy8m7e023lm
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
BD9We2MPmCytIKlsH7Wug8bmW9_lU-Xrx_-c-qhpsEI
|
||||||
1
temp/flow_lp0v28msillw76xmk83sx
Normal file
1
temp/flow_lp0v28msillw76xmk83sx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
OlX8iM3rrAro8J_Ncb4OvN6tLKuyfjLMEOG_xJkbeHU
|
||||||
1
temp/flow_lpmjxmln4o2axlfu9kb0n
Normal file
1
temp/flow_lpmjxmln4o2axlfu9kb0n
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VMalTqVPTIUY-ynsIQJO4jG16KyxkMhsQgoKW3doOHo
|
||||||
1
temp/flow_nreir9hwdpjnylbl79ydl
Normal file
1
temp/flow_nreir9hwdpjnylbl79ydl
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
tIXKI7IDrAikA_SbBTW6XqBQfR2uuWAg9WQYpiZ5DE0
|
||||||
1
temp/flow_rx8dih8r80gzhlosl1glw
Normal file
1
temp/flow_rx8dih8r80gzhlosl1glw
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
LdpsUh6F78GzSXz5zfZUW2ZaUbjoCC-zGrCjnz4gtDU
|
||||||
1
temp/flow_thi2f1athskd4cculln0n
Normal file
1
temp/flow_thi2f1athskd4cculln0n
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
8Ch2fssgHUf4qTO4kHM-9yofisoalAufolmFkSOvo40
|
||||||
1
temp/flow_uiahlqg3uolo9x007xwhf
Normal file
1
temp/flow_uiahlqg3uolo9x007xwhf
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Yx9ZPZ11yaa64EApoZKLNNp0cIsp-GWoZmPk8Rg3yJA
|
||||||
1
temp/flow_vf6gxl8c4s6583pxpelsc
Normal file
1
temp/flow_vf6gxl8c4s6583pxpelsc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0I35VFoN26g_y5ssRk0ilIjtmKD7s8oOVgqPCumt8Dw
|
||||||
1
temp/flow_vwgyv1bz05d69quclfqclo
Normal file
1
temp/flow_vwgyv1bz05d69quclfqclo
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
HoddM2bqLSELN9ICAn5Wx42dr0pLHrr-a48K_Zhes2w
|
||||||
1
temp/flow_w39k8p9uvnvcmj61wuzh
Normal file
1
temp/flow_w39k8p9uvnvcmj61wuzh
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pdE7NC8nsUsxnywZah1akVtr324QI0zQVJOBabnyJSA
|
||||||
1
temp/flow_yjmm00zgy1ainhm9i6ggp
Normal file
1
temp/flow_yjmm00zgy1ainhm9i6ggp
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
20WBny0Y2NnacGObQ91zCjRGQZdLhWzH8BaQfsQOga0
|
||||||
1
temp/flow_z26sq4gy8aiyfr835akz2d
Normal file
1
temp/flow_z26sq4gy8aiyfr835akz2d
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Bf8C4ChWmNROZkKLoMxVzQ9q6XMI253sje3d4IK_95Y
|
||||||
|
|
@ -32,6 +32,17 @@ export interface ConfigType {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
oidc: {
|
||||||
|
providers: {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
icon: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
http: {
|
http: {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
bind: string;
|
bind: string;
|
||||||
|
|
@ -189,6 +200,9 @@ export const configDefaults: ConfigType = {
|
||||||
api_key: "",
|
api_key: "",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
oidc: {
|
||||||
|
providers: [],
|
||||||
|
},
|
||||||
instance: {
|
instance: {
|
||||||
banner: "",
|
banner: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
|
||||||
6
utils/constants.ts
Normal file
6
utils/constants.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { getConfig } from "@config";
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
export const oauthRedirectUri = (issuer: string) =>
|
||||||
|
`${config.http.base_url}/oauth/callback/${issuer}`;
|
||||||
|
|
@ -59,3 +59,5 @@ export const checkIfOauthIsValid = (
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const oauthCodeVerifiers: Record<string, string> = {};
|
||||||
|
|
|
||||||
20
utils/temp.ts
Normal file
20
utils/temp.ts
Normal 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");
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue