Files
drop/server/internal/auth/oidc/index.ts
Husky 2b70cea4e0 Logging (#131)
* ci: pull version from package.json on build

* fix: implicit any type

* feat: inital support for logger

* style: fix lint

* feat: move more logging over to pino

* fix: logging around company importing
2025-07-09 12:01:23 +10:00

309 lines
8.6 KiB
TypeScript

import { randomUUID } from "crypto";
import prisma from "../../db/database";
import type { User } from "~/prisma/client";
import { AuthMec } from "~/prisma/client";
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: User; 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;
}
}