diff --git a/server/components/Auth/OpenID.vue b/server/components/Auth/OpenID.vue
new file mode 100644
index 00000000..926e1103
--- /dev/null
+++ b/server/components/Auth/OpenID.vue
@@ -0,0 +1,10 @@
+
+
+
diff --git a/server/components/Auth/Simple.vue b/server/components/Auth/Simple.vue
new file mode 100644
index 00000000..c248f2f0
--- /dev/null
+++ b/server/components/Auth/Simple.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
diff --git a/server/components/UserHeader/NotificationWidgetPanel.vue b/server/components/UserHeader/NotificationWidgetPanel.vue
index 280f50fa..78b89828 100644
--- a/server/components/UserHeader/NotificationWidgetPanel.vue
+++ b/server/components/UserHeader/NotificationWidgetPanel.vue
@@ -22,7 +22,7 @@
@@ -86,10 +98,5 @@ const navigation: NavigationItem[] = [
route: "/account",
prefix: "",
},
- {
- label: "Sign out",
- route: "/auth/signout",
- prefix: "",
- },
].filter((e) => e !== undefined);
diff --git a/server/drop-base b/server/drop-base
index e32cc36f..a14d1b70 160000
--- a/server/drop-base
+++ b/server/drop-base
@@ -1 +1 @@
-Subproject commit e32cc36f3396d2f90448742fccb33854460a893c
+Subproject commit a14d1b7081cf2e6aa5174e3cfd7b7fe6904ab7bf
diff --git a/server/package.json b/server/package.json
index 43e321f9..5489fb35 100644
--- a/server/package.json
+++ b/server/package.json
@@ -58,6 +58,7 @@
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.1",
"h3": "^1.15.1",
+ "ofetch": "^1.4.1",
"postcss": "^8.4.47",
"prettier": "^3.5.3",
"sass": "^1.79.4",
diff --git a/server/pages/auth/register.vue b/server/pages/auth/register.vue
index 1abf5ed9..3ee5bd41 100644
--- a/server/pages/auth/register.vue
+++ b/server/pages/auth/register.vue
@@ -56,7 +56,7 @@
type="email"
autocomplete="email"
required
- :disabled="!!invitation.data.value?.email"
+ :disabled="!!invitation?.email"
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"
/>
@@ -87,7 +87,7 @@
type="text"
autocomplete="username"
required
- :disabled="!!invitation.data.value?.username"
+ :disabled="!!invitation?.username"
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"
/>
@@ -199,13 +199,13 @@ if (!invitationId)
statusMessage: "Invitation required to sign up.",
});
-const invitation = await useFetch(
+const invitation = await $dropFetch(
`/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`,
);
-const email = ref(invitation.data.value?.email);
+const email = ref(invitation?.email);
const displayName = ref("");
-const username = ref(invitation.data.value?.username);
+const username = ref(invitation?.username);
const password = ref("");
const confirmPassword = ref(undefined);
diff --git a/server/pages/auth/signin.vue b/server/pages/auth/signin.vue
index 9d9dd4f8..d833a4c4 100644
--- a/server/pages/auth/signin.vue
+++ b/server/pages/auth/signin.vue
@@ -18,92 +18,13 @@
@@ -119,47 +40,9 @@
diff --git a/server/prisma/auth.prisma b/server/prisma/auth.prisma
index d1253cba..2ff00de0 100644
--- a/server/prisma/auth.prisma
+++ b/server/prisma/auth.prisma
@@ -1,5 +1,6 @@
enum AuthMec {
Simple
+ OpenID
}
model LinkedAuthMec {
diff --git a/server/prisma/migrations/20250507120031_add_openid_authmek/migration.sql b/server/prisma/migrations/20250507120031_add_openid_authmek/migration.sql
new file mode 100644
index 00000000..c48e6a00
--- /dev/null
+++ b/server/prisma/migrations/20250507120031_add_openid_authmek/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "AuthMec" ADD VALUE 'OpenID';
diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml
index 648c57fd..044d57cd 100644
--- a/server/prisma/migrations/migration_lock.toml
+++ b/server/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
-provider = "postgresql"
\ No newline at end of file
+provider = "postgresql"
diff --git a/server/server/api/v1/auth/index.get.ts b/server/server/api/v1/auth/index.get.ts
new file mode 100644
index 00000000..34e71cae
--- /dev/null
+++ b/server/server/api/v1/auth/index.get.ts
@@ -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;
+});
diff --git a/server/server/api/v1/auth/signin/simple.post.ts b/server/server/api/v1/auth/signin/simple.post.ts
index 08e0efc9..04f3f7f8 100644
--- a/server/server/api/v1/auth/signin/simple.post.ts
+++ b/server/server/api/v1/auth/signin/simple.post.ts
@@ -7,6 +7,7 @@ import {
checkHashBcrypt,
} from "~/server/internal/security/simple";
import sessionHandler from "~/server/internal/session";
+import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
const signinValidator = type({
username: "string",
@@ -15,6 +16,12 @@ const signinValidator = type({
});
export default defineEventHandler(async (h3) => {
+ if (!enabledAuthManagers.simple)
+ throw createError({
+ statusCode: 403,
+ statusMessage: "Sign in method not enabled",
+ });
+
const body = signinValidator(await readBody(h3));
if (body instanceof type.errors) {
// hover out.summary to see validation errors
diff --git a/server/server/internal/P2P.md b/server/server/internal/P2P.md
deleted file mode 100644
index 9da9a822..00000000
--- a/server/server/internal/P2P.md
+++ /dev/null
@@ -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
diff --git a/server/server/internal/clients/ca-store.ts b/server/server/internal/clients/ca-store.ts
index 15fe49ec..fc7f1c65 100644
--- a/server/server/internal/clients/ca-store.ts
+++ b/server/server/internal/clients/ca-store.ts
@@ -70,14 +70,17 @@ export const dbCertificateStore = () => {
};
},
async blacklistCertificate(name: string) {
- await prisma.certificate.update({
- where: {
- id: name,
- },
- data: {
- blacklisted: true,
- },
- });
+ try {
+ await prisma.certificate.update({
+ where: {
+ id: name,
+ },
+ data: {
+ blacklisted: true,
+ },
+ });
+ } finally {
+ }
},
async checkBlacklistCertificate(name: string): Promise {
const result = await prisma.certificate.findUnique({
@@ -88,7 +91,7 @@ export const dbCertificateStore = () => {
blacklisted: true,
},
});
- if (result === null) return false;
+ if (result === null) return true;
return result.blacklisted;
},
};
diff --git a/server/server/internal/oidc/index.ts b/server/server/internal/oidc/index.ts
new file mode 100644
index 00000000..dbcf7a84
--- /dev/null
+++ b/server/server/internal/oidc/index.ts
@@ -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;
+}
+
+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(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(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(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;
+ }
+}
diff --git a/server/server/plugins/04.auth-init.ts b/server/server/plugins/04.auth-init.ts
new file mode 100644
index 00000000..6b561eb1
--- /dev/null
+++ b/server/server/plugins/04.auth-init.ts
@@ -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;
+} = {
+ 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;
+ }
+});
diff --git a/server/server/plugins/redirect.ts b/server/server/plugins/redirect.ts
index b892dc83..8218a0cb 100644
--- a/server/server/plugins/redirect.ts
+++ b/server/server/plugins/redirect.ts
@@ -7,6 +7,7 @@ export default defineNitroPlugin((nitro) => {
// Don't handle for API routes
if (event.path.startsWith("/api")) return;
+ if (event.path.startsWith("/auth")) return;
// Make sure it's a web error
if (!(error instanceof H3Error)) return;
diff --git a/server/server/routes/auth/callback/oidc.get.ts b/server/server/routes/auth/callback/oidc.get.ts
new file mode 100644
index 00000000..8d2d5ad3
--- /dev/null
+++ b/server/server/routes/auth/callback/oidc.get.ts
@@ -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, "/");
+});
diff --git a/server/server/routes/auth/oidc.get.ts b/server/server/routes/auth/oidc.get.ts
new file mode 100644
index 00000000..dda26d68
--- /dev/null
+++ b/server/server/routes/auth/oidc.get.ts
@@ -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);
+});
diff --git a/server/server/routes/signout.get.ts b/server/server/routes/auth/signout.get.ts
similarity index 71%
rename from server/server/routes/signout.get.ts
rename to server/server/routes/auth/signout.get.ts
index 08e7673e..ebeb6f74 100644
--- a/server/server/routes/signout.get.ts
+++ b/server/server/routes/auth/signout.get.ts
@@ -1,4 +1,4 @@
-import sessionHandler from "../internal/session";
+import sessionHandler from "../../internal/session";
export default defineEventHandler(async (h3) => {
await sessionHandler.signout(h3);