mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
* chore: update prisma to 6.11 more prisma future proofing due to experimental features * chore: update dependencies twemoji - new unicode update argon2 - bux fixes vue3-carousel - improve mobile experiance vue-tsc - more stable * fix: incorrect prisma version in docker Also remove default value for BUILD_DROP_VERSION, that is now handled in nuxt config * fix: no logging in prod * chore: optimize docker builds even more * fix: revert adoption of prisma driverAdapters see: https://github.com/prisma/prisma/issues/27486 * chore: optimize dockerignore some more * Fix `pino-pretty` not being included in build (#135) * Remove `pino` from frontend * Fix for downloads and removing of library source (#136) * fix: downloads and removing library source * fix: linting * Fix max file size of 4GB (update droplet) (#137) * Fix manual metadata import (#138) * chore(deps): bump vue-i18n from 10.0.7 to 10.0.8 (#140) Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 10.0.7 to 10.0.8. - [Release notes](https://github.com/intlify/vue-i18n/releases) - [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md) - [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.8/packages/vue-i18n) --- updated-dependencies: - dependency-name: vue-i18n dependency-version: 10.0.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @intlify/core from 10.0.7 to 10.0.8 (#139) --- updated-dependencies: - dependency-name: "@intlify/core" dependency-version: 10.0.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Small fixes (#141) * fix: save task as Json rather than string * fix: pull objects before creating game in database * fix: strips relative dirs from version information * fix: #132 * fix: lint * fix: news object ids and small tweaks * fix: notification styling errors * fix: lint * fix: build issues by regenerating lockfile --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DecDuck <declanahofmeyr@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
309 lines
8.6 KiB
TypeScript
309 lines
8.6 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import prisma from "../../db/database";
|
|
import type { UserModel } from "~/prisma/client/models";
|
|
import { AuthMec } from "~/prisma/client/enums";
|
|
import objectHandler from "../../objects";
|
|
import type { Readable } from "stream";
|
|
import * as jdenticon from "jdenticon";
|
|
import { systemConfig } from "../../config/sys-conf";
|
|
import { logger } from "~/server/internal/logging";
|
|
|
|
interface OIDCWellKnown {
|
|
authorization_endpoint: string;
|
|
token_endpoint: string;
|
|
userinfo_endpoint: string;
|
|
scopes_supported: string[];
|
|
}
|
|
|
|
interface OIDCAuthSessionOptions {
|
|
redirect: string | undefined;
|
|
}
|
|
|
|
interface OIDCAuthSession {
|
|
redirectUrl: string;
|
|
callbackUrl: string;
|
|
state: string;
|
|
options: OIDCAuthSessionOptions;
|
|
}
|
|
|
|
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 keyof OIDCUserInfo) ??
|
|
"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 = systemConfig.getExternalUrl();
|
|
|
|
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);
|
|
}
|
|
|
|
generateConfiguration() {
|
|
return {
|
|
authorizationUrl: this.oidcConfiguration.authorization_endpoint,
|
|
scopes: this.oidcConfiguration.scopes_supported.join(", "),
|
|
adminGroup: this.adminGroup,
|
|
usernameClaim: this.usernameClaim,
|
|
externalUrl: this.externalUrl,
|
|
};
|
|
}
|
|
|
|
generateAuthSession(options?: OIDCAuthSessionOptions): 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,
|
|
options: options ?? { redirect: undefined },
|
|
};
|
|
this.signinStateTable[stateKey] = session;
|
|
return session;
|
|
}
|
|
|
|
async authorize(
|
|
code: string,
|
|
state: string,
|
|
): Promise<{ user: UserModel; options: OIDCAuthSessionOptions } | 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);
|
|
|
|
if (typeof user === "string") return user;
|
|
|
|
return { user, options: session.options };
|
|
} catch (e) {
|
|
logger.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();
|
|
|
|
const picture = userinfo.picture;
|
|
if (picture) {
|
|
await objectHandler.createFromSource(
|
|
profilePictureId,
|
|
async () =>
|
|
await $fetch<Readable>(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,
|
|
profilePictureObjectId: profilePictureId,
|
|
admin: isAdmin,
|
|
},
|
|
},
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
credentials: creds as any, // Prisma converts this to the Json type for us
|
|
},
|
|
include: {
|
|
user: true,
|
|
},
|
|
});
|
|
|
|
return created.user;
|
|
}
|
|
}
|