mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-14 00:31:25 +10:00
Various bug fixes (#102)
* feat: set lang in html head * fix: add # in front of git ref * fix: remove unused vars from example env * fix: package name and license field * fix: enable sourcemap for client and server * fix: emojis not showing in prod this is extremely cursed, but it works * chore: refactor auth manager * feat: disable invitations if simple auth disabled * feat: add drop version to footer * feat: translate auth endpoints * chore: move oidc module * feat: add weekly tasks enabled object cleanup as weekly task * feat: add timestamp to task log msgs * feat: add guard to prevent invalid progress % * fix: add missing global scope to i18n components * feat: set base url for i18n * feat: switch task log to json format * ci: run ci on develop branch only * fix: UserWidget text not updating #109 * fix: EXTERNAL_URL being computed at build * feat: add basic language outlines for translation * feat: add more english dialects
This commit is contained in:
307
server/internal/auth/oidc/index.ts
Normal file
307
server/internal/auth/oidc/index.ts
Normal file
@ -0,0 +1,307 @@
|
||||
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";
|
||||
|
||||
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) {
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user