mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
Merge branch 'develop' of https://github.com/Huskydog9988/drop into more-stuff
This commit is contained in:
10
components/Auth/OpenID.vue
Normal file
10
components/Auth/OpenID.vue
Normal 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 →
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
124
components/Auth/Simple.vue
Normal file
124
components/Auth/Simple.vue
Normal 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>
|
||||
@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
|
||||
<Notification
|
||||
<NotificationItem
|
||||
v-for="notification in props.notifications"
|
||||
:key="notification.id"
|
||||
:notification="notification"
|
||||
|
||||
@ -58,6 +58,18 @@
|
||||
{{ nav.label }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active, close }" hydrate-on-visible as="div">
|
||||
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
|
||||
<a
|
||||
:class="[
|
||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
||||
'w-full text-left transition block px-4 py-2 text-sm',
|
||||
]"
|
||||
href="/auth/signout"
|
||||
>
|
||||
Signout
|
||||
</a>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</PanelWidget>
|
||||
</MenuItems>
|
||||
@ -86,10 +98,5 @@ const navigation: NavigationItem[] = [
|
||||
route: "/account",
|
||||
prefix: "",
|
||||
},
|
||||
{
|
||||
label: "Sign out",
|
||||
route: "/auth/signout",
|
||||
prefix: "",
|
||||
},
|
||||
].filter((e) => e !== undefined);
|
||||
</script>
|
||||
|
||||
Submodule drop-base updated: e32cc36f33...a14d1b7081
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -18,92 +18,13 @@
|
||||
|
||||
<div class="mt-10">
|
||||
<div>
|
||||
<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>
|
||||
<AuthSimple v-if="enabledAuths.includes('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">
|
||||
<span class="h-[1px] grow bg-zinc-600" />
|
||||
OR
|
||||
<span class="h-[1px] grow bg-zinc-600" />
|
||||
</div>
|
||||
<AuthOpenID v-if="enabledAuths.includes('oidc')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -119,47 +40,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import type { User } from "@prisma/client";
|
||||
import DropLogo from "~/components/DropLogo.vue";
|
||||
|
||||
const username = ref("");
|
||||
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");
|
||||
}
|
||||
const enabledAuths = await $dropFetch("/api/v1/auth");
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-1 bg-zinc-900">
|
||||
<div
|
||||
class="flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-sm lg:w-96">
|
||||
<div>
|
||||
<DropLogo class="h-10 w-auto" />
|
||||
<h2
|
||||
class="mt-8 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
|
||||
>
|
||||
Signing out...
|
||||
</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-400">
|
||||
You are being signed out of Drop.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative hidden w-0 flex-1 lg:block">
|
||||
<img
|
||||
src="/wallpapers/signin.jpg"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DropLogo from "~/components/DropLogo.vue";
|
||||
const router = useRouter();
|
||||
|
||||
// Clear the user state
|
||||
const user = useUser();
|
||||
user.value = null;
|
||||
|
||||
// Redirect to signin page after signout
|
||||
await $dropFetch("/api/v1/auth/signout"); //TODO: add signout api route
|
||||
router.push("/auth/signin");
|
||||
</script>
|
||||
@ -1,5 +1,6 @@
|
||||
enum AuthMec {
|
||||
Simple
|
||||
OpenID
|
||||
}
|
||||
|
||||
model LinkedAuthMec {
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AuthMec" ADD VALUE 'OpenID';
|
||||
@ -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"
|
||||
provider = "postgresql"
|
||||
|
||||
9
server/api/v1/auth/index.get.ts
Normal file
9
server/api/v1/auth/index.get.ts
Normal 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;
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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<boolean> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
281
server/internal/oidc/index.ts
Normal file
281
server/internal/oidc/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
server/plugins/04.auth-init.ts
Normal file
37
server/plugins/04.auth-init.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@ -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;
|
||||
|
||||
36
server/routes/auth/callback/oidc.get.ts
Normal file
36
server/routes/auth/callback/oidc.get.ts
Normal 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, "/");
|
||||
});
|
||||
10
server/routes/auth/oidc.get.ts
Normal file
10
server/routes/auth/oidc.get.ts
Normal 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);
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import sessionHandler from "../internal/session";
|
||||
import sessionHandler from "../../internal/session";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
await sessionHandler.signout(h3);
|
||||
Reference in New Issue
Block a user