Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
# ┌─── Logto Setup Guide ───────────────────────────────────────────────────────┐
# │ │
# │ 1. Create a "Traditional Web" application in Logto Console │
# │ 2. Set the redirect URI to: <your-domain>/auth/callback
# │ 2. Set the redirect URI to: <your-domain>/auth/login-callback │
# │ 3. Copy the App ID → PUBLIC_OIDC_CLIENT_ID │
# │ 4. Copy the App Secret → OIDC_CLIENT_SECRET │
# │ │
Expand Down Expand Up @@ -246,4 +246,4 @@ SMTP_FROM_NAME=MUNIFY Delegator
# PUBLIC_VERSION=1.0.0

# [AUTO] Git commit SHA
# PUBLIC_SHA=abc123
# PUBLIC_SHA=abc123
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ bunx lefthook install

## Deployment

The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example docker compose file in the [example](./example/) directoy. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [Logto](https://logto.io/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file.
The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example Docker Compose file in the [example](./example/) directory. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [Logto](https://logto.io/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file.

## FAQ

Expand Down
128 changes: 84 additions & 44 deletions src/api/context/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { z } from 'zod';
import { oidcRoles, refresh, validateTokens, type OIDCUser } from '$api/services/OIDC';
import {
oidcRoles,
refresh,
validateTokens,
getJwks,
getConfig,
type OIDCUser
} from '$api/services/OIDC';
import { configPrivate } from '$config/private';
import type { RequestEvent } from '@sveltejs/kit';
import { GraphQLError } from 'graphql';
import { decodeJwt } from 'jose';
import { jwtVerify } from 'jose';
import { db } from '$db/db';

const TokenCookieSchema = z
Expand Down Expand Up @@ -31,6 +38,43 @@ export type ImpersonationContext = {
export const tokensCookieName = 'token_set';
export const impersonationTokenCookieName = 'impersonation_token_set';

/**
* Parse and validate OIDC roles from raw claim data.
* Handles multiple provider formats: plain string arrays, objects with `.name` property, or key-value objects.
*
* @param rolesRaw - The raw roles claim value from the OIDC token
* @param allowedRoles - Set or array of allowed role values
* @returns Array of validated role names
*/
function parseOidcRoles(
rolesRaw: unknown,
allowedRoles: readonly string[]
): (typeof oidcRoles)[number][] {
const result: string[] = [];

if (Array.isArray(rolesRaw)) {
for (const role of rolesRaw) {
if (typeof role === 'string') {
// Simple string array (e.g. ["admin"])
result.push(role);
} else if (role && typeof role === 'object' && 'name' in role) {
// Logto returns role objects (e.g. [{name: "admin", ...}])
const roleName = role.name;
if (typeof roleName === 'string') {
result.push(roleName);
}
}
}
} else if (rolesRaw && typeof rolesRaw === 'object') {
// Zitadel returned roles as an object with role names as keys
const roleNames = Object.keys(rolesRaw);
result.push(...roleNames);
}

// Filter to only allowed roles
return result.filter((role) => allowedRoles.includes(role)) as (typeof oidcRoles)[number][];
}

/**
* Builds an OIDC context from request cookies: validates or refreshes tokens, extracts roles, and handles optional impersonation.
*
Expand Down Expand Up @@ -116,25 +160,11 @@ export async function oidc(cookies: RequestEvent['cookies']) {
}
}

const OIDCRoleNames: (typeof oidcRoles)[number][] = [];
let OIDCRoleNames: (typeof oidcRoles)[number][] = [];

if (user && configPrivate.OIDC_ROLE_CLAIM) {
const rolesRaw = user[configPrivate.OIDC_ROLE_CLAIM]!;
if (Array.isArray(rolesRaw)) {
for (const role of rolesRaw) {
if (typeof role === 'string') {
// Simple string array (e.g. ["admin"])
OIDCRoleNames.push(role as any);
} else if (role && typeof role === 'object' && 'name' in role) {
// Logto returns role objects (e.g. [{name: "admin", ...}])
OIDCRoleNames.push(role.name as any);
}
}
} else if (rolesRaw && typeof rolesRaw === 'object') {
// Zitadel returned roles as an object with role names as keys
const roleNames = Object.keys(rolesRaw);
OIDCRoleNames.push(...(roleNames as any));
}
const rolesRaw = user[configPrivate.OIDC_ROLE_CLAIM];
OIDCRoleNames = parseOidcRoles(rolesRaw, oidcRoles);
}

const hasRole = (role: (typeof OIDCRoleNames)[number]) => {
Expand All @@ -151,34 +181,55 @@ export async function oidc(cookies: RequestEvent['cookies']) {
const impersonationTokenSet = TokenCookieSchema.safeParse(JSON.parse(impersonationCookie));

if (impersonationTokenSet.success && impersonationTokenSet.data.access_token) {
// Decode the impersonation JWT directly — it's a resource-scoped token
// that cannot be used with the userinfo endpoint.
// The token only contains `sub` and custom JWT claims (roles, mfa, etc.)
// but no profile claims, so we look up the user from the database.
const decoded = decodeJwt(impersonationTokenSet.data.access_token);
if (!decoded.sub) {
// Verify the impersonation JWT cryptographically against the OIDC issuer JWKS
const jwks = getJwks();
if (!jwks) {
throw new Error('JWKS not available for impersonation token verification');
}

let verifiedPayload;
try {
const verification = await jwtVerify(impersonationTokenSet.data.access_token, jwks, {
issuer: getConfig().serverMetadata().issuer,
audience: configPrivate.OIDC_RESOURCE ?? undefined
});
verifiedPayload = verification.payload;
} catch (verificationError) {
console.warn('Impersonation token verification failed:', verificationError);
cookies.delete(impersonationTokenCookieName, { path: '/' });
return {
nextTokenRefreshDue: tokenSet.expires_in
? new Date(Date.now() + tokenSet.expires_in * 1000)
: undefined,
tokenSet,
user: user ? { ...user, hasRole, OIDCRoleNames } : undefined,
impersonation: impersonationContext
};
}

if (!verifiedPayload.sub) {
throw new Error('Impersonation token missing sub claim');
}

const dbUser = await db.user.findUnique({ where: { id: decoded.sub } });
const dbUser = await db.user.findUnique({ where: { id: verifiedPayload.sub } });
if (!dbUser) {
throw new Error(`Impersonated user ${decoded.sub} not found in database`);
throw new Error(`Impersonated user ${verifiedPayload.sub} not found in database`);
}

const impersonatedUser: OIDCUser = {
sub: decoded.sub,
sub: verifiedPayload.sub,
email: dbUser.email ?? '',
preferred_username: dbUser.preferred_username ?? undefined,
family_name: dbUser.family_name ?? undefined,
given_name: dbUser.given_name ?? undefined,
locale: dbUser.locale ?? undefined,
phone: dbUser.phone ?? undefined,
// Spread custom JWT claims (roles, mfa, password, etc.)
...decoded
...verifiedPayload
};

// Extract actor information from the JWT token (if present)
const actorInfo = decoded.act as Record<string, unknown> | undefined;
const actorInfo = verifiedPayload.act as Record<string, unknown> | undefined;

// If actor claim is present, verify it matches the current user
if (actorInfo && typeof actorInfo === 'object') {
Expand Down Expand Up @@ -212,21 +263,10 @@ export async function oidc(cookies: RequestEvent['cookies']) {
user = impersonatedUser;

// Update role information for impersonated user
const impersonatedOIDCRoleNames: (typeof oidcRoles)[number][] = [];
let impersonatedOIDCRoleNames: (typeof oidcRoles)[number][] = [];
if (impersonatedUser && configPrivate.OIDC_ROLE_CLAIM) {
const impersonatedRolesRaw = impersonatedUser[configPrivate.OIDC_ROLE_CLAIM]!;
if (Array.isArray(impersonatedRolesRaw)) {
for (const role of impersonatedRolesRaw) {
if (typeof role === 'string') {
impersonatedOIDCRoleNames.push(role as any);
} else if (role && typeof role === 'object' && 'name' in role) {
impersonatedOIDCRoleNames.push(role.name as any);
}
}
} else if (impersonatedRolesRaw && typeof impersonatedRolesRaw === 'object') {
const impersonatedRoleNames = Object.keys(impersonatedRolesRaw);
impersonatedOIDCRoleNames.push(...(impersonatedRoleNames as any));
}
const impersonatedRolesRaw = impersonatedUser[configPrivate.OIDC_ROLE_CLAIM];
impersonatedOIDCRoleNames = parseOidcRoles(impersonatedRolesRaw, oidcRoles);
}

// Override role functions for impersonated user
Expand Down
52 changes: 41 additions & 11 deletions src/api/resolvers/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,51 @@ builder.queryFields((t) => {
}),
resolve: (root, args, ctx) => {
const user = ctx.oidc.user;
// TYPE-SAFETY-EXCEPTION: `password` and `mfa` are custom JWT claims
// injected by Logto's Custom JWT feature. They exist on the OIDCUser
// index signature but TypeScript loses it after the spread in oidc context.
const claims = user as Record<string, unknown> | undefined;

// Type guards for custom JWT claims
const isBooleanClaim = (value: unknown): value is boolean => {
return typeof value === 'boolean';
};

const isStringArrayClaim = (value: unknown): value is string[] => {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
};

const isSsoIdentitiesArrayClaim = (
value: unknown
): value is { issuer: string; identityId: string }[] => {
return (
Array.isArray(value) &&
value.every(
(item) =>
item &&
typeof item === 'object' &&
'issuer' in item &&
'identityId' in item &&
typeof item.issuer === 'string' &&
typeof item.identityId === 'string'
)
);
};

// Extract custom JWT claims with runtime validation
const passwordClaim = user?.['password'];
const mfaClaim = user?.['mfa'];
const ssoIdentitiesClaim = user?.['sso_identities'];
const socialIdentitiesClaim = user?.['social_identities'];

return {
user: user
? {
...user,
hasPassword: (claims?.['password'] as boolean) ?? null,
mfaVerificationFactors: (claims?.['mfa'] as string[]) ?? null,
ssoIdentities:
(claims?.['sso_identities'] as
| { issuer: string; identityId: string }[]
| undefined) ?? null,
socialIdentities: (claims?.['social_identities'] as string[] | undefined) ?? null
hasPassword: isBooleanClaim(passwordClaim) ? passwordClaim : null,
mfaVerificationFactors: isStringArrayClaim(mfaClaim) ? mfaClaim : null,
ssoIdentities: isSsoIdentitiesArrayClaim(ssoIdentitiesClaim)
? ssoIdentitiesClaim
: null,
socialIdentities: isStringArrayClaim(socialIdentitiesClaim)
? socialIdentitiesClaim
: null
}
: null,
nextTokenRefreshDue: ctx.oidc.nextTokenRefreshDue
Expand Down
3 changes: 2 additions & 1 deletion src/api/resolvers/modules/impersonation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,8 @@ builder.mutationFields((t) => ({
// Perform token exchange (no scope needed — Logto uses the resource indicator)
const impersonationTokens = await performTokenExchange(
ctx.oidc.tokenSet.access_token,
args.targetUserId
args.targetUserId,
args.scope ?? undefined
);

// Store impersonation tokens in cookie
Expand Down
14 changes: 9 additions & 5 deletions src/api/resolvers/modules/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,11 +429,15 @@ builder.mutationFields((t) => {
},
update: {
email: issuerUserData.email,
...(issuerUserData.family_name && { family_name: issuerUserData.family_name }),
...(issuerUserData.given_name && { given_name: issuerUserData.given_name }),
...(issuerUserData.preferred_username && {
preferred_username: issuerUserData.preferred_username
}),
...(issuerUserData.family_name != null
? { family_name: issuerUserData.family_name }
: {}),
...(issuerUserData.given_name != null
? { given_name: issuerUserData.given_name }
: {}),
...(issuerUserData.preferred_username != null
? { preferred_username: issuerUserData.preferred_username }
: {}),
locale: issuerUserData.locale ?? configPublic.PUBLIC_DEFAULT_LOCALE,
phone: issuerUserData.phone ?? user.phone
}
Expand Down
21 changes: 21 additions & 0 deletions src/api/services/OIDC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,22 @@ const { config, cryptr, jwks } = await (async () => {
return { config, cryptr, jwks };
})();

/**
* Get the JWKS for token verification.
* @returns The JWKS remote set or undefined if not available.
*/
export function getJwks() {
return jwks;
}

/**
* Get the OIDC configuration.
* @returns The OIDC configuration object.
*/
export function getConfig() {
return config;
}

export async function startSignin(visitedUrl: URL) {
//TODO https://github.com/gornostay25/svelte-adapter-bun/issues/62
if (configPrivate.NODE_ENV === 'production') {
Expand Down Expand Up @@ -368,6 +384,11 @@ export async function performTokenExchange(
tokenExchangeParams.resource = configPrivate.OIDC_RESOURCE;
}

// Optional scope parameter
if (scope) {
tokenExchangeParams.scope = scope;
}

const response = await fetch(config.serverMetadata().token_endpoint!, {
method: 'POST',
headers: {
Expand Down
4 changes: 2 additions & 2 deletions src/config/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const schema = z.object({
OIDC_M2M_CLIENT_ID: z.string().optional(),
OIDC_M2M_CLIENT_SECRET: z.string().optional(),
// Logto Management API resource indicator (e.g. https://default.logto.app/api)
OIDC_M2M_RESOURCE: z.string().url().optional(),
OIDC_M2M_RESOURCE: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()),
SECRET: z.string(),
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
OTEL_SERVICE_NAME: z.string().default('MUNIFY-DELEGATOR'),
Expand All @@ -39,7 +39,7 @@ const schema = z.object({
SMTP_FROM_ADDRESS: z.string().email().default('[email protected]'),
SMTP_FROM_NAME: z.string().default('MUNIFY Delegator'),
// Sentry/Bugsink error tracking
SENTRY_DSN: z.string().url().optional(),
SENTRY_DSN: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()),
SENTRY_SEND_DEFAULT_PII: z.stringbool().optional()
});

Expand Down
14 changes: 10 additions & 4 deletions src/config/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const schema = z.object({
PUBLIC_OIDC_AUTHORITY: z.string(),
PUBLIC_OIDC_CLIENT_ID: z.string(),
PUBLIC_DEFAULT_LOCALE: z.string().default('de'),
PUBLIC_OIDC_ACCOUNT_URL: z.string().url().optional(),
PUBLIC_OIDC_ACCOUNT_URL: z.preprocess(
(v) => (v === '' ? undefined : v),
z.string().url().optional()
),
PUBLIC_FEEDBACK_URL: z.optional(z.string()),
PUBLIC_GLOBAL_USER_NOTES_ACTIVE: z.coerce.boolean().default(false),

Expand All @@ -18,14 +21,17 @@ const schema = z.object({
PUBLIC_MAINTENANCE_WINDOW_START: z.iso.datetime({ offset: true }).optional(),
PUBLIC_MAINTENANCE_WINDOW_END: z.iso.datetime({ offset: true }).optional(),
// Sentry/Bugsink error tracking
PUBLIC_SENTRY_DSN: z.string().url().optional(),
PUBLIC_SENTRY_DSN: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()),
PUBLIC_SENTRY_SEND_DEFAULT_PII: z.stringbool().optional(),

// Badge generator URL (optional)
PUBLIC_BADGE_GENERATOR_URL: z.string().url().optional(),
PUBLIC_BADGE_GENERATOR_URL: z.preprocess(
(v) => (v === '' ? undefined : v),
z.string().url().optional()
),

// Documentation URL (optional) - global link to DELEGATOR app documentation
PUBLIC_DOCS_URL: z.string().url().optional(),
PUBLIC_DOCS_URL: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()),

// Support email for error pages and help requests
PUBLIC_SUPPORT_EMAIL: z.string().email().default('[email protected]'),
Expand Down
Loading
Loading