feat: oidc

This commit is contained in:
DecDuck
2025-05-07 22:14:04 +10:00
parent e8633ceca2
commit 19ff73cc30
16 changed files with 533 additions and 146 deletions

View File

@ -0,0 +1,10 @@
<template>
<div class="flex">
<a
href="/auth/oidc"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
>
Sign in with external provider &rarr;
</a>
</div>
</template>

124
components/Auth/Simple.vue Normal file
View File

@ -0,0 +1,124 @@
<template>
<form class="space-y-6" @submit.prevent="signin_wrapper">
<div>
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-300"
>Username</label
>
<div class="mt-2">
<input
id="username"
v-model="username"
name="username"
type="username"
autocomplete="username"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-300"
>Password</label
>
<div class="mt-2">
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="rememberMe"
name="remember-me"
type="checkbox"
class="h-4 w-4 rounded bg-zinc-800 border-zinc-700 text-blue-600 focus:ring-blue-600"
/>
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>Remember me</label
>
</div>
<div class="text-sm leading-6">
<NuxtLink to="#" class="font-semibold text-blue-600 hover:text-blue-500"
>Forgot password?</NuxtLink
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading"> Sign in</LoadingButton>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { User } from "@prisma/client";
const username = ref("");
const password = ref("");
const rememberMe = ref(false);
const loading = ref(false);
const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter();
function signin_wrapper() {
loading.value = true;
signin()
.then(() => {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.statusMessage || "An unknown error occurred";
error.value = message;
})
.finally(() => {
loading.value = false;
});
}
async function signin() {
await $dropFetch("/api/v1/auth/signin/simple", {
method: "POST",
body: {
username: username.value,
password: password.value,
rememberMe: rememberMe.value,
},
});
const user = useUser();
user.value = await $dropFetch<User | null>("/api/v1/user");
}
</script>

View File

@ -58,6 +58,7 @@
"eslint": "^9.24.0", "eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.1", "eslint-config-prettier": "^10.1.1",
"h3": "^1.15.1", "h3": "^1.15.1",
"ofetch": "^1.4.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"sass": "^1.79.4", "sass": "^1.79.4",

View File

@ -56,7 +56,7 @@
type="email" type="email"
autocomplete="email" autocomplete="email"
required required
:disabled="!!invitation.data.value?.email" :disabled="!!invitation?.email"
placeholder="me@example.com" placeholder="me@example.com"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/> />
@ -87,7 +87,7 @@
type="text" type="text"
autocomplete="username" autocomplete="username"
required required
:disabled="!!invitation.data.value?.username" :disabled="!!invitation?.username"
placeholder="myUsername" placeholder="myUsername"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/> />
@ -199,13 +199,13 @@ if (!invitationId)
statusMessage: "Invitation required to sign up.", statusMessage: "Invitation required to sign up.",
}); });
const invitation = await useFetch( const invitation = await $dropFetch(
`/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`, `/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`,
); );
const email = ref(invitation.data.value?.email); const email = ref(invitation?.email);
const displayName = ref(""); const displayName = ref("");
const username = ref(invitation.data.value?.username); const username = ref(invitation?.username);
const password = ref(""); const password = ref("");
const confirmPassword = ref(undefined); const confirmPassword = ref(undefined);

View File

@ -18,92 +18,13 @@
<div class="mt-10"> <div class="mt-10">
<div> <div>
<form class="space-y-6" @submit.prevent="signin_wrapper"> <AuthSimple v-if="enabledAuths.includes('simple')" />
<div> <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">
<label <span class="h-[1px] grow bg-zinc-600" />
for="username" OR
class="block text-sm font-medium leading-6 text-zinc-300" <span class="h-[1px] grow bg-zinc-600" />
>Username</label </div>
> <AuthOpenID v-if="enabledAuths.includes('oidc')" />
<div class="mt-2">
<input
id="username"
v-model="username"
name="username"
type="username"
autocomplete="username"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-300"
>Password</label
>
<div class="mt-2">
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="rememberMe"
name="remember-me"
type="checkbox"
class="h-4 w-4 rounded bg-zinc-800 border-zinc-700 text-blue-600 focus:ring-blue-600"
/>
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>Remember me</label
>
</div>
<div class="text-sm leading-6">
<NuxtLink
to="#"
class="font-semibold text-blue-600 hover:text-blue-500"
>Forgot password?</NuxtLink
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading">
Sign in</LoadingButton
>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
@ -119,47 +40,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { User } from "@prisma/client";
import DropLogo from "~/components/DropLogo.vue"; import DropLogo from "~/components/DropLogo.vue";
const username = ref(""); const enabledAuths = await $dropFetch("/api/v1/auth");
const password = ref("");
const rememberMe = ref(false);
const loading = ref(false);
const route = useRoute();
const router = useRouter();
const error = ref<string | undefined>();
function signin_wrapper() {
loading.value = true;
signin()
.then(() => {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.statusMessage || "An unknown error occurred";
error.value = message;
})
.finally(() => {
loading.value = false;
});
}
async function signin() {
await $dropFetch("/api/v1/auth/signin/simple", {
method: "POST",
body: {
username: username.value,
password: password.value,
rememberMe: rememberMe.value,
},
});
const user = useUser();
user.value = await $dropFetch<User | null>("/api/v1/user");
}
definePageMeta({ definePageMeta({
layout: false, layout: false,

View File

@ -1,5 +1,6 @@
enum AuthMec { enum AuthMec {
Simple Simple
OpenID
} }
model LinkedAuthMec { model LinkedAuthMec {

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AuthMec" ADD VALUE 'OpenID';

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (e.g., Git) # It should be added in your version-control system (e.g., Git)
provider = "postgresql" provider = "postgresql"

View File

@ -0,0 +1,9 @@
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
export default defineEventHandler((h3) => {
const authManagers = Object.entries(enabledAuthManagers)
.filter((e) => !!e[1])
.map((e) => e[0]);
return authManagers;
});

View File

@ -7,6 +7,7 @@ import {
checkHashBcrypt, checkHashBcrypt,
} from "~/server/internal/security/simple"; } from "~/server/internal/security/simple";
import sessionHandler from "~/server/internal/session"; import sessionHandler from "~/server/internal/session";
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
const signinValidator = type({ const signinValidator = type({
username: "string", username: "string",
@ -15,6 +16,12 @@ const signinValidator = type({
}); });
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
if (!enabledAuthManagers.simple)
throw createError({
statusCode: 403,
statusMessage: "Sign in method not enabled",
});
const body = signinValidator(await readBody(h3)); const body = signinValidator(await readBody(h3));
if (body instanceof type.errors) { if (body instanceof type.errors) {
// hover out.summary to see validation errors // hover out.summary to see validation errors

View File

@ -1,15 +0,0 @@
# Drop P2P System
Drop clients have a variety of P2P or P2P-like methods of data transfer available
## Public (not quite) HTTPS downloads endpoints
These use public HTTPS certificate, and while are authenticated, are 'public' in the sense that they aren't P2P; anyone can connect to them
## Private mTLS P2P endpoints
Drop clients use P2P mTLS aided by the P2P co-ordinator to transfer chunks between themselves. This happens over HTTP.
## Private mTLS Wireguard tunnels
Drop clients can establish P2P Wireguard

View File

@ -0,0 +1,281 @@
import { randomUUID } from "crypto";
import prisma from "../db/database";
import { AuthMec, Prisma } from "@prisma/client";
import objectHandler from "../objects";
import { Readable } from "stream";
import * as jdenticon from "jdenticon";
interface OIDCWellKnown {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
scopes_supported: string[];
}
interface OIDCAuthSession {
redirectUrl: string;
callbackUrl: string;
state: string;
}
interface OIDCUserInfo {
sub: string;
name?: string;
preferred_username?: string;
picture?: string;
email?: string;
groups?: Array<string>;
}
export interface OIDCAuthMekCredentialsV1 {
sub: string;
}
export class OIDCManager {
private oidcConfiguration: OIDCWellKnown;
private clientId: string;
private clientSecret: string;
private externalUrl: string;
private adminGroup?: string = process.env.OIDC_ADMIN_GROUP;
private usernameClaim: keyof OIDCUserInfo =
(process.env.OIDC_USERNAME_CLAIM as any) ?? "preferred_username";
private signinStateTable: { [key: string]: OIDCAuthSession } = {};
constructor(
oidcConfiguration: OIDCWellKnown,
clientId: string,
clientSecret: string,
externalUrl: string,
) {
this.oidcConfiguration = oidcConfiguration;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.externalUrl = externalUrl;
}
async create() {
const wellKnownUrl = process.env.OIDC_WELLKNOWN as string | undefined;
let configuration: OIDCWellKnown;
if (wellKnownUrl) {
const response: OIDCWellKnown = await $fetch<OIDCWellKnown>(wellKnownUrl);
if (
!response.authorization_endpoint ||
!response.scopes_supported ||
!response.token_endpoint ||
!response.userinfo_endpoint
) {
throw new Error("Well known response was invalid");
}
configuration = response;
} else {
const authorizationEndpoint = process.env.OIDC_AUTHORIZATION as
| string
| undefined;
const tokenEndpoint = process.env.OIDC_TOKEN as string | undefined;
const userinfoEndpoint = process.env.OIDC_USERINFO as string | undefined;
const scopes = process.env.OIDC_SCOPES as string | undefined;
if (
!authorizationEndpoint ||
!tokenEndpoint ||
!userinfoEndpoint ||
!scopes
) {
const debugObject = {
OIDC_AUTHORIZATION: authorizationEndpoint,
OIDC_TOKEN: tokenEndpoint,
OIDC_USERINFO: userinfoEndpoint,
OIDC_SCOPES: scopes,
};
throw new Error(
"Missing all necessary OIDC configuration: \n" +
Object.entries(debugObject)
.map(([k, v]) => ` ${k}: ${v}`)
.join("\n"),
);
}
configuration = {
authorization_endpoint: authorizationEndpoint,
token_endpoint: tokenEndpoint,
userinfo_endpoint: userinfoEndpoint,
scopes_supported: scopes.split(","),
};
}
if (!configuration)
throw new Error("OIDC try to init without configuration");
const clientId = process.env.OIDC_CLIENT_ID as string | undefined;
const clientSecret = process.env.OIDC_CLIENT_SECRET as string | undefined;
const externalUrl = process.env.EXTERNAL_URL as string | undefined;
if (!clientId || !clientSecret)
throw new Error("Missing client ID or secret for OIDC");
if (!externalUrl) throw new Error("EXTERNAL_URL required for OIDC");
return new OIDCManager(configuration, clientId, clientSecret, externalUrl);
}
generateAuthSession(): OIDCAuthSession {
const stateKey = randomUUID();
const normalisedUrl = new URL(
this.oidcConfiguration.authorization_endpoint,
).toString();
const redirectNormalisedUrl = new URL(this.externalUrl).toString();
const redirectUrl = `${redirectNormalisedUrl}auth/callback/oidc`;
const finalUrl = `${normalisedUrl}?client_id=${this.clientId}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${stateKey}&response_type=code&scope=${encodeURIComponent(this.oidcConfiguration.scopes_supported.join(" "))}`;
const session: OIDCAuthSession = {
redirectUrl: finalUrl,
callbackUrl: redirectUrl,
state: stateKey,
};
this.signinStateTable[stateKey] = session;
return session;
}
async authorize(code: string, state: string) {
const session = this.signinStateTable[state];
if (!session) return "Invalid state parameter";
const tokenEndpoint = new URL(
this.oidcConfiguration.token_endpoint,
).toString();
const userinfoEndpoint = new URL(
this.oidcConfiguration.userinfo_endpoint,
).toString();
const requestBody = new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: "authorization_code",
code: code,
redirect_uri: session.callbackUrl,
scope: this.oidcConfiguration.scopes_supported.join(","),
});
try {
const { access_token, token_type } = await $fetch<{
access_token: string;
token_type: string;
id_token: string;
}>(tokenEndpoint, {
body: requestBody,
method: "POST",
});
const userinfo = await $fetch<OIDCUserInfo>(userinfoEndpoint, {
headers: {
Authorization: `${token_type} ${access_token}`,
},
});
const user = await this.fetchOrCreateUser(userinfo);
return user;
} catch (e) {
console.error(e);
return `Request to identity provider failed: ${e}`;
}
}
async fetchOrCreateUser(userinfo: OIDCUserInfo) {
const existingAuthMek = await prisma.linkedAuthMec.findFirst({
where: {
mec: AuthMec.OpenID,
version: 1,
credentials: {
path: ["sub"],
equals: userinfo.sub,
},
},
include: {
user: true,
},
});
if (existingAuthMek) return existingAuthMek.user;
const username = userinfo[this.usernameClaim]?.toString();
if (!username)
return "Invalid username claim in OIDC response: " + this.usernameClaim;
/*
const takenUsername = await prisma.user.count({
where: {
username,
},
});
if (takenUsername > 0)
return "Username already taken. Please contact your server admin.";
*/
const creds: OIDCAuthMekCredentialsV1 = {
sub: userinfo.sub,
};
const userId = randomUUID();
const profilePictureId = randomUUID();
if (userinfo.picture) {
await objectHandler.createFromSource(
profilePictureId,
async () =>
await $fetch<Readable>(userinfo.picture!!, {
responseType: "stream",
}),
{},
[`internal:read`, `${userId}:read`],
);
} else {
await objectHandler.createFromSource(
profilePictureId,
async () => jdenticon.toPng(userinfo.sub, 256),
{},
[`internal:read`, `${userId}:read`],
);
}
const isAdmin =
userinfo.groups !== undefined &&
this.adminGroup !== undefined &&
userinfo.groups.includes(this.adminGroup);
const created = await prisma.linkedAuthMec.create({
data: {
mec: AuthMec.OpenID,
version: 1,
user: {
connectOrCreate: {
where: {
username,
},
create: {
id: userId,
username,
email: userinfo.email ?? "",
displayName: userinfo.name ?? username,
profilePicture: profilePictureId,
admin: isAdmin,
},
},
},
credentials: creds as any, // Prisma converts this to the Json type for us
},
include: {
user: true,
},
});
return created.user;
}
}

View File

@ -0,0 +1,37 @@
import { OIDCManager } from "../internal/oidc";
export const enabledAuthManagers: {
simple: boolean;
oidc: OIDCManager | undefined;
} = {
simple: false,
oidc: undefined,
};
const initFunctions: {
[K in keyof typeof enabledAuthManagers]: () => Promise<any>;
} = {
oidc: OIDCManager.prototype.create,
simple: async () => {
const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined;
return !disabled;
},
};
export default defineNitroPlugin(async (nitro) => {
for (const [key, init] of Object.entries(initFunctions)) {
try {
const object = await init();
if (!object) break;
(enabledAuthManagers as any)[key] = object;
console.log(`enabled auth: ${key}`);
} catch (e) {
console.warn(e);
}
}
// Add every other auth mechanism here, and fall back to simple if none of them are enabled
if (!enabledAuthManagers.oidc) {
enabledAuthManagers.simple = true;
}
});

View File

@ -7,6 +7,7 @@ export default defineNitroPlugin((nitro) => {
// Don't handle for API routes // Don't handle for API routes
if (event.path.startsWith("/api")) return; if (event.path.startsWith("/api")) return;
if (event.path.startsWith("/auth")) return;
// Make sure it's a web error // Make sure it's a web error
if (!(error instanceof H3Error)) return; if (!(error instanceof H3Error)) return;

View File

@ -0,0 +1,36 @@
import sessionHandler from "~/server/internal/session";
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
export default defineEventHandler(async (h3) => {
if (!enabledAuthManagers.oidc) return sendRedirect(h3, "/auth/signin");
const manager = enabledAuthManagers.oidc;
const query = getQuery(h3);
const code = query.code?.toString();
if (!code)
throw createError({
statusCode: 400,
statusMessage: "No code in query params.",
});
const state = query.state?.toString();
if (!state)
throw createError({
statusCode: 400,
statusMessage: "No state in query params.",
});
const user = await manager.authorize(code, state);
if (typeof user === "string")
throw createError({
statusCode: 403,
statusMessage: `Failed to sign in: "${user}". Please try again.`,
});
await sessionHandler.signin(h3, user.id, true);
return sendRedirect(h3, "/");
});

View File

@ -0,0 +1,10 @@
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
export default defineEventHandler((h3) => {
if (!enabledAuthManagers.oidc) return sendRedirect(h3, "/auth/signin");
const manager = enabledAuthManagers.oidc;
const { redirectUrl } = manager.generateAuthSession();
return sendRedirect(h3, redirectUrl);
});