mirror of
https://github.com/BillyOutlast/drop.git
synced 2026-02-04 00:31:17 +01:00
* prevent returning expired sessions * add issuer to ODIC creds * get id token in ODIC * make session signin return session * working backchannel logout? * require https for ODIC provider * handle wellknown not being https * find session api progress * fix windows build * return session token on session * switch OIDC to #searchSessions * update pnpm * switch to using message on error obj * move odic callback * fix type errors * redirect old oidc callback * make redirect url a URL * remove scheduled task downloadCleanup * fix session search for oidc * fix signin result * cleanup code * ignore data dir * fix lint error
556 lines
16 KiB
TypeScript
556 lines
16 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";
|
|
import { type } from "arktype";
|
|
import * as jose from "jose";
|
|
// import { inspect } from "util";
|
|
import sessionHandler from "../../session";
|
|
import type { SessionSearchTerms } from "../../session/types";
|
|
|
|
// TODO: monitor https://github.com/goauthentik/authentik/issues/8751 for easier?? OIDC setup by end users
|
|
|
|
// Schema for OIDC well-known configuration
|
|
const OIDCWellKnownV1 = type({
|
|
issuer: "string.url.parse",
|
|
authorization_endpoint: "string.url.parse",
|
|
token_endpoint: "string.url.parse",
|
|
userinfo_endpoint: "string.url.parse?",
|
|
jwks_uri: "string.url.parse",
|
|
scopes_supported: "string[]?",
|
|
});
|
|
|
|
// Represents required OIDC configuration
|
|
interface OIDCConfiguration {
|
|
issuer: URL;
|
|
authorization_endpoint: URL;
|
|
token_endpoint: URL;
|
|
userinfo_endpoint: URL;
|
|
jwks_uri: URL;
|
|
scopes_supported: string[];
|
|
}
|
|
|
|
interface OIDCAuthSessionOptions {
|
|
redirect: string | undefined;
|
|
}
|
|
|
|
interface OIDCAuthSessionClaims {
|
|
iss: string;
|
|
sub?: string;
|
|
sid?: string;
|
|
}
|
|
|
|
interface OIDCAuthSession {
|
|
redirectUrl: string;
|
|
callbackUrl: string;
|
|
state: string;
|
|
options: OIDCAuthSessionOptions;
|
|
claims: OIDCAuthSessionClaims;
|
|
}
|
|
|
|
interface OIDCUserInfo {
|
|
sub: string;
|
|
name?: string;
|
|
preferred_username?: string;
|
|
picture?: string;
|
|
email?: string;
|
|
groups?: Array<string>;
|
|
}
|
|
|
|
type OIDCUrlKey = Exclude<keyof OIDCConfiguration, "scopes_supported">;
|
|
|
|
/**
|
|
* @see https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
|
|
* @see https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1
|
|
*/
|
|
const OIDCTokenResponseV1 = type({
|
|
access_token: "string",
|
|
token_type: "string",
|
|
expires_in: "number?",
|
|
refresh_token: "string?",
|
|
scope: "string?",
|
|
id_token: "string",
|
|
});
|
|
|
|
/**
|
|
* @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
|
*/
|
|
const OIDCIDTokenV1 = type({
|
|
iss: "string",
|
|
sub: "string",
|
|
aud: "string | string[]",
|
|
exp: "number",
|
|
iat: "number",
|
|
|
|
auth_time: "number?",
|
|
nonce: "string?",
|
|
acr: "string?",
|
|
amr: "string[]?",
|
|
azp: "string?",
|
|
|
|
// see: https://openid.net/specs/openid-connect-backchannel-1_0.html#BCSupport
|
|
// and: https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
|
|
sid: "string?", // session ID
|
|
});
|
|
|
|
/**
|
|
* @see https://openid.net/specs/openid-connect-backchannel-1_0-final.html#LogoutToken
|
|
*/
|
|
const OIDCLogoutTokenV1 = type({
|
|
iss: "string",
|
|
sub: "string?",
|
|
aud: "string | string[]",
|
|
iat: "number",
|
|
jti: "string",
|
|
events: type({
|
|
"http://schemas.openid.net/event/backchannel-logout": "object",
|
|
}),
|
|
sid: "string?", // session ID
|
|
});
|
|
|
|
export interface OIDCAuthMekCredentialsV1 {
|
|
// only optional for compatibility with older versions
|
|
iss?: string;
|
|
sub: string;
|
|
}
|
|
|
|
export class OIDCManager {
|
|
private oidcConfiguration: OIDCConfiguration;
|
|
private clientId: string;
|
|
private clientSecret: string;
|
|
private externalUrl: URL;
|
|
private redirectUrl: URL;
|
|
|
|
private userGroup?: string = process.env.OIDC_USER_GROUP;
|
|
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 } = {};
|
|
|
|
/**
|
|
* Util to fetch JWKS for verifying tokens
|
|
* @see https://github.com/panva/jose/blob/main/docs/jwks/remote/functions/createRemoteJWKSet.md
|
|
*/
|
|
private JWKS: ReturnType<typeof jose.createRemoteJWKSet>;
|
|
|
|
private constructor(
|
|
oidcConfiguration: OIDCConfiguration,
|
|
clientId: string,
|
|
clientSecret: string,
|
|
externalUrl: URL,
|
|
) {
|
|
this.oidcConfiguration = oidcConfiguration;
|
|
this.clientId = clientId;
|
|
this.clientSecret = clientSecret;
|
|
this.externalUrl = externalUrl;
|
|
|
|
this.JWKS = jose.createRemoteJWKSet(this.oidcConfiguration.jwks_uri);
|
|
this.redirectUrl = new URL(
|
|
`${this.externalUrl.toString()}api/v1/auth/odic/callback`,
|
|
);
|
|
}
|
|
|
|
static async create() {
|
|
if (!systemConfig.shouldOidcRequireHttps()) {
|
|
console.warn(
|
|
"Disabling HTTPS requirement for ODIC provider, not recommened in production enviroments",
|
|
);
|
|
}
|
|
|
|
const wellKnownUrlString = process.env.OIDC_WELLKNOWN as string | undefined;
|
|
const scopes = process.env.OIDC_SCOPES as string | undefined;
|
|
let configuration: OIDCConfiguration;
|
|
if (wellKnownUrlString) {
|
|
const wellKnownUrl = new URL(wellKnownUrlString);
|
|
if (systemConfig.shouldOidcRequireHttps() && !isHttps(wellKnownUrl)) {
|
|
throw new Error("OIDC_WELLKNOWN URL must use HTTPS");
|
|
}
|
|
|
|
const response = await $fetch<unknown>(wellKnownUrl.toString());
|
|
const wellKnown = OIDCWellKnownV1(response);
|
|
if (wellKnown instanceof type.errors) {
|
|
throw new Error(
|
|
`Failed to parse OIDC well-known configuration: ${wellKnown.summary}`,
|
|
);
|
|
}
|
|
|
|
if (scopes) {
|
|
wellKnown.scopes_supported = scopes.split(",");
|
|
} else if (!wellKnown.scopes_supported) {
|
|
throw new Error(
|
|
"OIDC_SCOPES environment variable required if not provided by well-known configuration",
|
|
);
|
|
}
|
|
|
|
if (!wellKnown.userinfo_endpoint) {
|
|
throw new Error(
|
|
"OIDC_USERINFO environment variable required if not provided by well-known configuration",
|
|
);
|
|
}
|
|
|
|
configuration = {
|
|
authorization_endpoint: wellKnown.authorization_endpoint,
|
|
token_endpoint: wellKnown.token_endpoint,
|
|
userinfo_endpoint: wellKnown.userinfo_endpoint,
|
|
scopes_supported: wellKnown.scopes_supported,
|
|
issuer: wellKnown.issuer,
|
|
jwks_uri: wellKnown.jwks_uri,
|
|
};
|
|
} 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 issuer = process.env.OIDC_ISSUER as string | undefined;
|
|
const jwksEndpoint = process.env.OIDC_JWKS as string | undefined;
|
|
|
|
if (
|
|
!authorizationEndpoint ||
|
|
!tokenEndpoint ||
|
|
!userinfoEndpoint ||
|
|
!scopes ||
|
|
!issuer ||
|
|
!jwksEndpoint
|
|
) {
|
|
const debugObject = {
|
|
OIDC_AUTHORIZATION: authorizationEndpoint,
|
|
OIDC_TOKEN: tokenEndpoint,
|
|
OIDC_USERINFO: userinfoEndpoint,
|
|
OIDC_SCOPES: scopes,
|
|
OIDC_ISSUER: issuer,
|
|
OIDC_JWKS: jwksEndpoint,
|
|
};
|
|
throw new Error(
|
|
"Missing all necessary OIDC configuration: \n" +
|
|
Object.entries(debugObject)
|
|
.map(([k, v]) => ` ${k}: ${v}`)
|
|
.join("\n"),
|
|
);
|
|
}
|
|
|
|
configuration = {
|
|
authorization_endpoint: new URL(authorizationEndpoint),
|
|
token_endpoint: new URL(tokenEndpoint),
|
|
userinfo_endpoint: new URL(userinfoEndpoint),
|
|
scopes_supported: scopes.split(","),
|
|
issuer: new URL(issuer),
|
|
jwks_uri: new URL(jwksEndpoint),
|
|
};
|
|
}
|
|
|
|
if (!configuration)
|
|
throw new Error("OIDC try to init without configuration");
|
|
|
|
if (systemConfig.shouldOidcRequireHttps()) {
|
|
const endpoints: OIDCUrlKey[] = [
|
|
"authorization_endpoint",
|
|
"token_endpoint",
|
|
"userinfo_endpoint",
|
|
"issuer",
|
|
"jwks_uri",
|
|
];
|
|
|
|
for (const endpoint of endpoints) {
|
|
if (!isHttps(configuration[endpoint])) {
|
|
throw new Error(`OIDC ${endpoint} is not using HTTPS`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const clientId = process.env.OIDC_CLIENT_ID as string | undefined;
|
|
const clientSecret = process.env.OIDC_CLIENT_SECRET as string | undefined;
|
|
const externalUrl = new URL(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 finalUrl = `${normalisedUrl}?client_id=${this.clientId}&redirect_uri=${encodeURIComponent(this.redirectUrl.toString())}&state=${stateKey}&response_type=code&scope=${encodeURIComponent(this.oidcConfiguration.scopes_supported.join(" "))}`;
|
|
|
|
const session: OIDCAuthSession = {
|
|
redirectUrl: finalUrl,
|
|
callbackUrl: this.redirectUrl.toString(),
|
|
state: stateKey,
|
|
options: options ?? { redirect: undefined },
|
|
claims: {
|
|
iss: this.oidcConfiguration.issuer.toString(),
|
|
},
|
|
};
|
|
this.signinStateTable[stateKey] = session;
|
|
return session;
|
|
}
|
|
|
|
async authorize(
|
|
code: string,
|
|
state: string,
|
|
): Promise<
|
|
| {
|
|
user: UserModel;
|
|
options: OIDCAuthSessionOptions;
|
|
claims: OIDCAuthSessionClaims;
|
|
}
|
|
| string
|
|
> {
|
|
const session = this.signinStateTable[state];
|
|
if (!session) return "Invalid state parameter";
|
|
|
|
const tokenEndpoint = this.oidcConfiguration.token_endpoint.toString();
|
|
const userinfoEndpoint =
|
|
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 rawTokenResponse = await $fetch<unknown>(tokenEndpoint, {
|
|
body: requestBody,
|
|
method: "POST",
|
|
});
|
|
const tokenResponse = OIDCTokenResponseV1(rawTokenResponse);
|
|
if (tokenResponse instanceof type.errors) {
|
|
logger.error(`Invalid OIDC token response: ${tokenResponse.summary}`);
|
|
return "Invalid token response from identity provider.";
|
|
}
|
|
|
|
// TODO: handle refresh tokens?
|
|
|
|
const idTokenRaw = await jose.jwtVerify(
|
|
tokenResponse.id_token,
|
|
this.JWKS,
|
|
{
|
|
audience: this.clientId,
|
|
issuer: this.oidcConfiguration.issuer.toString(),
|
|
},
|
|
);
|
|
const idToken = OIDCIDTokenV1(idTokenRaw.payload);
|
|
if (idToken instanceof type.errors) {
|
|
logger.error(`Invalid OIDC ID token: ${idToken.summary}`);
|
|
return "Invalid ID token from identity provider.";
|
|
}
|
|
|
|
const userinfo = await $fetch<OIDCUserInfo>(userinfoEndpoint, {
|
|
headers: {
|
|
Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`,
|
|
},
|
|
});
|
|
|
|
const userOrError = await this.fetchOrCreateUser(userinfo);
|
|
|
|
if (typeof userOrError === "string") return userOrError;
|
|
|
|
const claims: OIDCAuthSessionClaims = {
|
|
iss: idToken.iss,
|
|
};
|
|
if (idToken.sub) claims.sub = idToken.sub;
|
|
if (idToken.sid) claims.sid = idToken.sid;
|
|
|
|
return {
|
|
user: userOrError,
|
|
options: session.options,
|
|
claims,
|
|
};
|
|
} 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 isAdmin =
|
|
userinfo.groups !== undefined &&
|
|
this.adminGroup !== undefined &&
|
|
userinfo.groups.includes(this.adminGroup);
|
|
|
|
const isUser = this.userGroup
|
|
? userinfo.groups !== undefined &&
|
|
userinfo.groups.includes(this.userGroup)
|
|
: true;
|
|
|
|
if (!(isAdmin || isUser))
|
|
return "Not authorized to access this application.";
|
|
|
|
/*
|
|
const takenUsername = await prisma.user.count({
|
|
where: {
|
|
username,
|
|
},
|
|
});
|
|
|
|
if (takenUsername > 0)
|
|
return "Username already taken. Please contact your server admin.";
|
|
*/
|
|
|
|
const creds: OIDCAuthMekCredentialsV1 = {
|
|
iss: this.oidcConfiguration.issuer.toString(),
|
|
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 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;
|
|
}
|
|
|
|
/**
|
|
* Handle OIDC backchannel logout token
|
|
* @param logout_token
|
|
* @returns
|
|
*
|
|
* @see https://openid.net/specs/openid-connect-backchannel-1_0-final.html#Validation
|
|
*/
|
|
async handleLogout(logout_token: string): Promise<boolean> {
|
|
let jwt: jose.JWTVerifyResult<jose.JWTPayload> & jose.ResolvedKey;
|
|
try {
|
|
jwt = await jose.jwtVerify(logout_token, this.JWKS, {
|
|
audience: this.clientId,
|
|
issuer: this.oidcConfiguration.issuer.toString(),
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to verify OIDC logout token:", e);
|
|
return false;
|
|
}
|
|
|
|
const token = OIDCLogoutTokenV1(jwt.payload);
|
|
if (token instanceof type.errors) {
|
|
console.error("Invalid OIDC logout token structure:", token.summary);
|
|
return false;
|
|
} else if (!token.sid && !token.sub) {
|
|
console.error(
|
|
"Invalid OIDC logout token: missing both 'sid' and 'sub' claims",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const searchTerm: SessionSearchTerms = {
|
|
oidc: {
|
|
iss: token.iss,
|
|
},
|
|
};
|
|
if (searchTerm.oidc) {
|
|
if (token.sub) {
|
|
searchTerm.oidc.sub = token.sub;
|
|
}
|
|
if (token.sid) {
|
|
searchTerm.oidc.sid = token.sid;
|
|
}
|
|
}
|
|
|
|
const sessions = await sessionHandler.searchSessions(searchTerm);
|
|
|
|
const taskQueue = [];
|
|
for (const session of sessions) {
|
|
taskQueue.push(sessionHandler.signoutByToken(session.token));
|
|
}
|
|
await Promise.all(taskQueue);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function isHttps(url: URL): boolean {
|
|
if (url.protocol === "https:") return true;
|
|
else return false;
|
|
}
|