feat: add oidc to admin panel

This commit is contained in:
DecDuck
2025-05-08 15:29:50 +10:00
parent 19ff73cc30
commit bfa2c0a641
17 changed files with 91 additions and 44 deletions
+11
View File
@@ -0,0 +1,11 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M9.41 20H6.5c-1.5 0-2.82-.5-3.89-1.57C1.54 17.38 1 16.09 1 14.58q0-1.95 1.17-3.48a5.25 5.25 0 0 1 3.08-1.95c.42-1.53 1.25-2.77 2.5-3.72C9 4.5 10.42 4 12 4c1.95 0 3.61.68 4.96 2.04C18.32 7.39 19 9.05 19 11c1.15.13 2.11.63 2.86 1.5c.64.73 1 1.56 1.1 2.5H18a5.01 5.01 0 0 0-4-2c-2.8 0-5 2.2-5 5c0 .72.15 1.39.41 2M23 17v2h-2v2h-2v-2h-2.2c-.4 1.2-1.5 2-2.8 2c-1.7 0-3-1.3-3-3s1.3-3 3-3c1.3 0 2.4.8 2.8 2zm-8 1c0-.5-.4-1-1-1s-1 .5-1 1s.4 1 1 1s1-.5 1-1"
/>
</svg>
</template>
+1 -1
View File
@@ -58,7 +58,7 @@
{{ nav.label }} {{ nav.label }}
</button> </button>
</MenuItem> </MenuItem>
<MenuItem v-slot="{ active, close }" hydrate-on-visible as="div"> <MenuItem v-slot="{ active }" hydrate-on-visible as="div">
<!-- TODO: think this would work better as a NuxtLink instead of a button --> <!-- TODO: think this would work better as a NuxtLink instead of a button -->
<a <a
:class="[ :class="[
+17 -7
View File
@@ -32,7 +32,7 @@
<div class="text-sm/6 font-medium text-zinc-100"> <div class="text-sm/6 font-medium text-zinc-100">
{{ authMech.name }} {{ authMech.name }}
</div> </div>
<Menu as="div" class="relative ml-auto"> <Menu v-if="authMech.route" as="div" class="relative ml-auto">
<MenuButton <MenuButton
class="-m-2.5 block p-2.5 text-gray-400 hover:text-gray-500" class="-m-2.5 block p-2.5 text-gray-400 hover:text-gray-500"
> >
@@ -81,10 +81,10 @@
<div <div
v-for="[key, value] in Object.entries(authMech.settings)" v-for="[key, value] in Object.entries(authMech.settings)"
:key="key" :key="key"
class="flex justify-between gap-x-4 py-2" class="flex flex-nowrap justify-between gap-x-4 py-2"
> >
<dt class="text-zinc-400">{{ key }}</dt> <dt class="text-zinc-400">{{ key }}</dt>
<dd class="text-gray-500"> <dd class="text-gray-500 truncate">
{{ value }} {{ value }}
</dd> </dd>
</div> </div>
@@ -96,7 +96,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { IconsSimpleAuthenticationLogo } from "#components"; import { IconsSimpleAuthenticationLogo, IconsSSOLogo } from "#components";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid"; import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid";
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid"; import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid";
@@ -117,9 +117,9 @@ const authenticationMechanisms: Array<{
name: string; name: string;
mec: AuthMec; mec: AuthMec;
icon: Component; icon: Component;
route: string; route?: string;
enabled: boolean; enabled: boolean;
settings?: { [key: string]: string }; settings?: { [key: string]: string | undefined } | undefined | boolean;
}> = [ }> = [
{ {
name: "Simple (username/password)", name: "Simple (username/password)",
@@ -127,5 +127,15 @@ const authenticationMechanisms: Array<{
icon: IconsSimpleAuthenticationLogo, icon: IconsSimpleAuthenticationLogo,
route: "/admin/users/auth/simple", route: "/admin/users/auth/simple",
}, },
].map((e) => ({ ...e, enabled: enabledMechanisms.includes(e.mec) })); {
name: "OpenID Connect",
mec: "OpenID" as AuthMec,
icon: IconsSSOLogo,
},
].map((e) => ({
...e,
enabled: !!enabledMechanisms[e.mec],
settings:
typeof enabledMechanisms[e.mec] === "object" && enabledMechanisms[e.mec],
}));
</script> </script>
+7 -3
View File
@@ -18,13 +18,16 @@
<div class="mt-10"> <div class="mt-10">
<div> <div>
<AuthSimple v-if="enabledAuths.includes('simple')" /> <AuthSimple v-if="enabledAuths.includes(AuthMec.Simple)" />
<div v-if="enabledAuths.length > 1" class="py-4 flex flex-row items-center justify-center gap-x-4 font-bold text-sm text-zinc-600"> <div
v-if="enabledAuths.length > 1"
class="py-4 flex flex-row items-center justify-center gap-x-4 font-bold text-sm text-zinc-600"
>
<span class="h-[1px] grow bg-zinc-600" /> <span class="h-[1px] grow bg-zinc-600" />
OR OR
<span class="h-[1px] grow bg-zinc-600" /> <span class="h-[1px] grow bg-zinc-600" />
</div> </div>
<AuthOpenID v-if="enabledAuths.includes('oidc')" /> <AuthOpenID v-if="enabledAuths.includes(AuthMec.OpenID)" />
</div> </div>
</div> </div>
</div> </div>
@@ -40,6 +43,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AuthMec } from "@prisma/client";
import DropLogo from "~/components/DropLogo.vue"; import DropLogo from "~/components/DropLogo.vue";
const enabledAuths = await $dropFetch("/api/v1/auth"); const enabledAuths = await $dropFetch("/api/v1/auth");
-1
View File
@@ -1,7 +1,6 @@
model ApplicationSettings { model ApplicationSettings {
timestamp DateTime @id @default(now()) timestamp DateTime @id @default(now())
enabledAuthencationMechanisms AuthMec[]
metadataProviders String[] metadataProviders String[]
saveSlotCountLimit Int @default(5) saveSlotCountLimit Int @default(5)
@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `enabledAuthencationMechanisms` on the `ApplicationSettings` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "ApplicationSettings" DROP COLUMN "enabledAuthencationMechanisms";
+7 -6
View File
@@ -1,14 +1,15 @@
import type { AuthMec } from "@prisma/client"; import { AuthMec } from "@prisma/client";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import { applicationSettings } from "~/server/internal/config/application-configuration"; import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["auth:read"]); const allowed = await aclManager.allowSystemACL(h3, ["auth:read"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const enabledMechanisms: AuthMec[] = await applicationSettings.get( const authData = {
"enabledAuthencationMechanisms", [AuthMec.Simple]: enabledAuthManagers.Simple,
); [AuthMec.OpenID]: enabledAuthManagers.OpenID && enabledAuthManagers.OpenID.generateConfiguration(),
}
return enabledMechanisms; return authData;
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
export default defineEventHandler((h3) => { export default defineEventHandler(() => {
const authManagers = Object.entries(enabledAuthManagers) const authManagers = Object.entries(enabledAuthManagers)
.filter((e) => !!e[1]) .filter((e) => !!e[1])
.map((e) => e[0]); .map((e) => e[0]);
+1 -1
View File
@@ -16,7 +16,7 @@ const signinValidator = type({
}); });
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
if (!enabledAuthManagers.simple) if (!enabledAuthManagers.Simple)
throw createError({ throw createError({
statusCode: 403, statusCode: 403,
statusMessage: "Sign in method not enabled", statusMessage: "Sign in method not enabled",
+1
View File
@@ -80,6 +80,7 @@ export const dbCertificateStore = () => {
}, },
}); });
} finally { } finally {
/* empty */
} }
}, },
async checkBlacklistCertificate(name: string): Promise<boolean> { async checkBlacklistCertificate(name: string): Promise<boolean> {
@@ -1,5 +1,4 @@
import type { ApplicationSettings } from "@prisma/client"; import type { ApplicationSettings } from "@prisma/client";
import { AuthMec } from "@prisma/client";
import prisma from "../db/database"; import prisma from "../db/database";
class ApplicationConfiguration { class ApplicationConfiguration {
@@ -38,7 +37,6 @@ class ApplicationConfiguration {
async initialiseConfiguration() { async initialiseConfiguration() {
const initialState = await prisma.applicationSettings.create({ const initialState = await prisma.applicationSettings.create({
data: { data: {
enabledAuthencationMechanisms: [AuthMec.Simple],
metadataProviders: [], metadataProviders: [],
}, },
}); });
+3 -1
View File
@@ -57,9 +57,11 @@ class NotificationSystem {
} }
async push(userId: string, notificationCreateArgs: NotificationCreateArgs) { async push(userId: string, notificationCreateArgs: NotificationCreateArgs) {
if (!notificationCreateArgs.nonce)
throw new Error("No nonce in notificationCreateArgs");
const notification = await prisma.notification.upsert({ const notification = await prisma.notification.upsert({
where: { where: {
nonce: notificationCreateArgs.nonce!! nonce: notificationCreateArgs.nonce,
}, },
update: { update: {
userId: userId, userId: userId,
+18 -5
View File
@@ -1,8 +1,8 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import prisma from "../db/database"; import prisma from "../db/database";
import { AuthMec, Prisma } from "@prisma/client"; import { AuthMec } from "@prisma/client";
import objectHandler from "../objects"; import objectHandler from "../objects";
import { Readable } from "stream"; import type { Readable } from "stream";
import * as jdenticon from "jdenticon"; import * as jdenticon from "jdenticon";
interface OIDCWellKnown { interface OIDCWellKnown {
@@ -39,7 +39,8 @@ export class OIDCManager {
private adminGroup?: string = process.env.OIDC_ADMIN_GROUP; private adminGroup?: string = process.env.OIDC_ADMIN_GROUP;
private usernameClaim: keyof OIDCUserInfo = private usernameClaim: keyof OIDCUserInfo =
(process.env.OIDC_USERNAME_CLAIM as any) ?? "preferred_username"; (process.env.OIDC_USERNAME_CLAIM as keyof OIDCUserInfo) ??
"preferred_username";
private signinStateTable: { [key: string]: OIDCAuthSession } = {}; private signinStateTable: { [key: string]: OIDCAuthSession } = {};
@@ -121,6 +122,16 @@ export class OIDCManager {
return new OIDCManager(configuration, clientId, clientSecret, externalUrl); return new OIDCManager(configuration, clientId, clientSecret, externalUrl);
} }
generateConfiguration() {
return {
authorizationUrl: this.oidcConfiguration.authorization_endpoint,
scopes: this.oidcConfiguration.scopes_supported.join(", "),
adminGroup: this.adminGroup,
usernameClaim: this.usernameClaim,
externalUrl: this.externalUrl,
};
}
generateAuthSession(): OIDCAuthSession { generateAuthSession(): OIDCAuthSession {
const stateKey = randomUUID(); const stateKey = randomUUID();
@@ -226,11 +237,12 @@ export class OIDCManager {
const userId = randomUUID(); const userId = randomUUID();
const profilePictureId = randomUUID(); const profilePictureId = randomUUID();
if (userinfo.picture) { const picture = userinfo.picture;
if (picture) {
await objectHandler.createFromSource( await objectHandler.createFromSource(
profilePictureId, profilePictureId,
async () => async () =>
await $fetch<Readable>(userinfo.picture!!, { await $fetch<Readable>(picture, {
responseType: "stream", responseType: "stream",
}), }),
{}, {},
@@ -269,6 +281,7 @@ export class OIDCManager {
}, },
}, },
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
credentials: creds as any, // Prisma converts this to the Json type for us credentials: creds as any, // Prisma converts this to the Json type for us
}, },
include: { include: {
-1
View File
@@ -27,7 +27,6 @@ export default defineNitroPlugin(async (_nitro) => {
} }
} }
// Add providers based on their position in the application settings // Add providers based on their position in the application settings
const configuredProviderList = const configuredProviderList =
await applicationSettings.get("metadataProviders"); await applicationSettings.get("metadataProviders");
+12 -10
View File
@@ -1,28 +1,30 @@
import { AuthMec } from "@prisma/client";
import { OIDCManager } from "../internal/oidc"; import { OIDCManager } from "../internal/oidc";
export const enabledAuthManagers: { export const enabledAuthManagers: {
simple: boolean; [AuthMec.Simple]: boolean;
oidc: OIDCManager | undefined; [AuthMec.OpenID]: OIDCManager | undefined;
} = { } = {
simple: false, [AuthMec.Simple]: false,
oidc: undefined, [AuthMec.OpenID]: undefined,
}; };
const initFunctions: { const initFunctions: {
[K in keyof typeof enabledAuthManagers]: () => Promise<any>; [K in keyof typeof enabledAuthManagers]: () => Promise<unknown>;
} = { } = {
oidc: OIDCManager.prototype.create, [AuthMec.OpenID]: OIDCManager.prototype.create,
simple: async () => { [AuthMec.Simple]: async () => {
const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined; const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined;
return !disabled; return !disabled;
}, },
}; };
export default defineNitroPlugin(async (nitro) => { export default defineNitroPlugin(async () => {
for (const [key, init] of Object.entries(initFunctions)) { for (const [key, init] of Object.entries(initFunctions)) {
try { try {
const object = await init(); const object = await init();
if (!object) break; if (!object) break;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(enabledAuthManagers as any)[key] = object; (enabledAuthManagers as any)[key] = object;
console.log(`enabled auth: ${key}`); console.log(`enabled auth: ${key}`);
} catch (e) { } catch (e) {
@@ -31,7 +33,7 @@ export default defineNitroPlugin(async (nitro) => {
} }
// Add every other auth mechanism here, and fall back to simple if none of them are enabled // Add every other auth mechanism here, and fall back to simple if none of them are enabled
if (!enabledAuthManagers.oidc) { if (!enabledAuthManagers[AuthMec.OpenID]) {
enabledAuthManagers.simple = true; enabledAuthManagers[AuthMec.Simple] = true;
} }
}); });
+2 -3
View File
@@ -2,9 +2,9 @@ import sessionHandler from "~/server/internal/session";
import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
if (!enabledAuthManagers.oidc) return sendRedirect(h3, "/auth/signin"); if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin");
const manager = enabledAuthManagers.oidc; const manager = enabledAuthManagers.OpenID;
const query = getQuery(h3); const query = getQuery(h3);
const code = query.code?.toString(); const code = query.code?.toString();
@@ -29,7 +29,6 @@ export default defineEventHandler(async (h3) => {
statusMessage: `Failed to sign in: "${user}". Please try again.`, statusMessage: `Failed to sign in: "${user}". Please try again.`,
}); });
await sessionHandler.signin(h3, user.id, true); await sessionHandler.signin(h3, user.id, true);
return sendRedirect(h3, "/"); return sendRedirect(h3, "/");
+2 -2
View File
@@ -1,9 +1,9 @@
import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
export default defineEventHandler((h3) => { export default defineEventHandler((h3) => {
if (!enabledAuthManagers.oidc) return sendRedirect(h3, "/auth/signin"); if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin");
const manager = enabledAuthManagers.oidc; const manager = enabledAuthManagers.OpenID;
const { redirectUrl } = manager.generateAuthSession(); const { redirectUrl } = manager.generateAuthSession();
return sendRedirect(h3, redirectUrl); return sendRedirect(h3, redirectUrl);