mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
feat: oidc
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>
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
enum AuthMec {
|
enum AuthMec {
|
||||||
Simple
|
Simple
|
||||||
|
OpenID
|
||||||
}
|
}
|
||||||
|
|
||||||
model LinkedAuthMec {
|
model LinkedAuthMec {
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AuthMec" ADD VALUE 'OpenID';
|
||||||
@ -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"
|
||||||
|
|||||||
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,
|
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
|
||||||
|
|||||||
@ -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
|
|
||||||
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
|
// 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;
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user