Files
drop/server/internal/auth/oidc/index.ts
Husky 1ae051f066 Update Prisma to 6.11 (#133)
* 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>
2025-07-25 21:28:00 +10:00

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;
}
}