mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(oidc): never take a role away over a claim the provider did not send
Role mapping read the configured OIDC_ADMIN_CLAIM out of the userinfo payload and collapsed two very different answers into one: the claim arrived and did not name an admin, and the claim was not there at all. The second is not a verdict about the user — it is what an operator sees when the scope carrying that claim was never requested, or the claim name has a typo. TREK wrote role='user' for it anyway, with nothing in the log, nothing in the audit trail and nothing in the UI, so a misconfiguration read as an unexplained loss of admin. The only guard was the last-admin rule, which protects an instance with exactly one admin row and therefore almost nobody. An absent claim now leaves the stored role untouched. A claim that does arrive still promotes and demotes exactly as before. To keep the silence from moving somewhere worse, an absent claim on an account that is already an admin warns on every login, naming the account: providers that omit a claim rather than send it empty — Okta's filtered groups, Entra ID — cannot take admin away through the mapping at all, and that has to be done in TREK's own admin panel. The ordinary case still warns once per claim name, listing the claim names that did arrive (never their values) and pointing at OIDC_SCOPE. A real transition writes an audit row from the controller, with the client IP. Closes #2364
This commit is contained in:
@@ -24,6 +24,7 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
'admin.storage_backfill_cancel': 'cancelled a storage sync',
|
'admin.storage_backfill_cancel': 'cancelled a storage sync',
|
||||||
'admin.storage_stats_refresh': 'refreshed storage usage stats',
|
'admin.storage_stats_refresh': 'refreshed storage usage stats',
|
||||||
'immich.private_ip_configured': 'configured Immich with private IP',
|
'immich.private_ip_configured': 'configured Immich with private IP',
|
||||||
|
'oidc.role_change': 'role changed by OIDC claim mapping',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ const OIDC_EXCHANGE_COOKIE = 'trek_oidc_exchange';
|
|||||||
@Public('the OIDC handshake happens before a TREK session exists; the provider state is the credential')
|
@Public('the OIDC handshake happens before a TREK session exists; the provider state is the credential')
|
||||||
@Controller('api/auth/oidc')
|
@Controller('api/auth/oidc')
|
||||||
export class OidcController {
|
export class OidcController {
|
||||||
constructor(private readonly oidc: OidcService) {}
|
constructor(
|
||||||
|
private readonly oidc: OidcService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('login')
|
@Get('login')
|
||||||
async login(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async login(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
@@ -164,6 +167,19 @@ export class OidcController {
|
|||||||
|
|
||||||
const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
|
const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||||
if ('error' in result) return f('/login?oidc_error=' + result.error);
|
if ('error' in result) return f('/login?oidc_error=' + result.error);
|
||||||
|
if (result.roleChange) {
|
||||||
|
// The claim mapping changing someone's privileges is a security event, and
|
||||||
|
// the row is written here because this is where the client IP is. The claim
|
||||||
|
// NAME goes in the details, never its value: that column is readable by
|
||||||
|
// every admin and a claim can carry group memberships and worse.
|
||||||
|
this.audit.writeAudit({
|
||||||
|
userId: result.user.id,
|
||||||
|
action: 'oidc.role_change',
|
||||||
|
resource: String(result.user.id),
|
||||||
|
ip: getClientIp(req),
|
||||||
|
details: { from: result.roleChange.from, to: result.roleChange.to, claim: result.roleChange.claim },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.oidc.touchLastLogin(result.user.id);
|
this.oidc.touchLastLogin(result.user.id);
|
||||||
// Pass the flag through untouched: `undefined` must reach the token as
|
// Pass the flag through untouched: `undefined` must reach the token as
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ export interface OidcConfig {
|
|||||||
discoveryUrl: string | null;
|
discoveryUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OidcRoleResolution {
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
/**
|
||||||
|
* The mapping is configured but the claim it names is not in the payload, so
|
||||||
|
* there is no verdict to act on — which is not the same as a verdict of
|
||||||
|
* "not an admin".
|
||||||
|
*/
|
||||||
|
claimMissing: boolean;
|
||||||
|
claimKey: string;
|
||||||
|
/** Claim NAMES only — the values can carry personal data and must not be logged. */
|
||||||
|
seenKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What findOrCreateUser hands back so the controller can audit it with the client IP. */
|
||||||
|
export interface OidcRoleChange {
|
||||||
|
from: 'admin' | 'user';
|
||||||
|
to: 'admin' | 'user';
|
||||||
|
claim: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants / TTLs
|
// Constants / TTLs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -197,6 +217,10 @@ export class OidcService implements OnModuleDestroy {
|
|||||||
|
|
||||||
private readonly jwksCache = new Map<string, JwksEntry>();
|
private readonly jwksCache = new Map<string, JwksEntry>();
|
||||||
|
|
||||||
|
// Claim names already reported as missing, so the warning is a diagnosis and
|
||||||
|
// not a line on every login.
|
||||||
|
private readonly warnedMissingAdminClaims = new Set<string>();
|
||||||
|
|
||||||
private readonly stateSweeper: NodeJS.Timeout;
|
private readonly stateSweeper: NodeJS.Timeout;
|
||||||
private readonly codeSweeper: NodeJS.Timeout;
|
private readonly codeSweeper: NodeJS.Timeout;
|
||||||
|
|
||||||
@@ -353,6 +377,62 @@ export class OidcService implements OnModuleDestroy {
|
|||||||
return 'user';
|
return 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same mapping, plus the third outcome its callers need: an IdP that never sent
|
||||||
|
// the configured claim — because the scope carrying it was never requested, or
|
||||||
|
// because the name is a typo — says nothing about this user, and must not cost
|
||||||
|
// them a role they already have. #2364
|
||||||
|
resolveOidcRoleDetailed(userInfo: OidcUserInfo, isFirstUser: boolean): OidcRoleResolution {
|
||||||
|
const claimKey = readEnv().oidc.adminClaim;
|
||||||
|
const claimMissing =
|
||||||
|
!isFirstUser &&
|
||||||
|
!!readEnv().oidc.adminValue &&
|
||||||
|
!Object.prototype.hasOwnProperty.call(userInfo, claimKey);
|
||||||
|
return {
|
||||||
|
role: this.resolveOidcRole(userInfo, isFirstUser),
|
||||||
|
claimMissing,
|
||||||
|
claimKey,
|
||||||
|
seenKeys: Object.keys(userInfo),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once per claim name per process for the ordinary case: an operator who has not
|
||||||
|
// noticed after the first login will not notice after the thousandth, and this
|
||||||
|
// runs on a path that is hit on every single SSO login.
|
||||||
|
//
|
||||||
|
// A user who is stored as an admin is the exception and is never deduped. Okta
|
||||||
|
// sends a filtered `groups` claim only when the filter matches something and
|
||||||
|
// Entra ID drops `groups` for a user in no group at all, so on those providers
|
||||||
|
// taking somebody out of the admin group makes the claim vanish rather than
|
||||||
|
// arrive empty — which is exactly the login where keeping the role quiet would
|
||||||
|
// hide a revocation that never happened. #2364
|
||||||
|
private warnMissingAdminClaim(
|
||||||
|
resolution: OidcRoleResolution,
|
||||||
|
user: { id: number; username: string; role: 'admin' | 'user' } | null,
|
||||||
|
): void {
|
||||||
|
const received = `Claims received: ${resolution.seenKeys.join(', ')}.`;
|
||||||
|
const scopeHint =
|
||||||
|
`A provider only sends a claim when one of the requested scopes carries it — add that scope to OIDC_SCOPE ` +
|
||||||
|
`(Authentik's "entitlements", for one, rides a scope of its own).`;
|
||||||
|
if (user?.role === 'admin') {
|
||||||
|
console.warn(
|
||||||
|
`[OIDC] User ${user.id} (${user.username}) is stored as an admin and the configured OIDC_ADMIN_CLAIM ` +
|
||||||
|
`"${resolution.claimKey}" was not in their userinfo response, so the admin role is kept. Providers that omit a ` +
|
||||||
|
`claim instead of sending it empty (Okta filtered groups, Entra ID) cannot take admin away this way — remove it ` +
|
||||||
|
`in TREK's admin panel. ${received} ${scopeHint}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.warnedMissingAdminClaims.has(resolution.claimKey)) return;
|
||||||
|
this.warnedMissingAdminClaims.add(resolution.claimKey);
|
||||||
|
const consequence = user === null
|
||||||
|
? 'during registration, so the new account keeps its default role'
|
||||||
|
: `for user ${user.id}, so their stored role is left unchanged`;
|
||||||
|
console.warn(
|
||||||
|
`[OIDC] The configured OIDC_ADMIN_CLAIM "${resolution.claimKey}" was not in the userinfo response ${consequence}. ` +
|
||||||
|
`${received} ${scopeHint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -531,7 +611,7 @@ export class OidcService implements OnModuleDestroy {
|
|||||||
userInfo: OidcUserInfo,
|
userInfo: OidcUserInfo,
|
||||||
config: OidcConfig,
|
config: OidcConfig,
|
||||||
inviteToken?: string,
|
inviteToken?: string,
|
||||||
): { user: User } | { error: string } {
|
): { user: User; roleChange?: OidcRoleChange } | { error: string } {
|
||||||
// Defense-in-depth for direct callers — the controller redirects on a
|
// Defense-in-depth for direct callers — the controller redirects on a
|
||||||
// missing email before it ever calls this; the same code flows through its
|
// missing email before it ever calls this; the same code flows through its
|
||||||
// `oidc_error=' + result.error` pass-through if reached here.
|
// `oidc_error=' + result.error` pass-through if reached here.
|
||||||
@@ -568,9 +648,13 @@ export class OidcService implements OnModuleDestroy {
|
|||||||
user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer } as User;
|
user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer } as User;
|
||||||
}
|
}
|
||||||
// Update role based on OIDC claims on every login (if claim mapping is configured)
|
// Update role based on OIDC claims on every login (if claim mapping is configured)
|
||||||
|
let roleChange: OidcRoleChange | undefined;
|
||||||
if (readEnv().oidc.adminValue) {
|
if (readEnv().oidc.adminValue) {
|
||||||
const newRole = this.resolveOidcRole(userInfo, false);
|
const resolution = this.resolveOidcRoleDetailed(userInfo, false);
|
||||||
if (user.role !== newRole) {
|
const newRole = resolution.role;
|
||||||
|
if (resolution.claimMissing) {
|
||||||
|
this.warnMissingAdminClaim(resolution, user);
|
||||||
|
} else if (user.role !== newRole) {
|
||||||
// Never let the claim-based downgrade strip the last admin. The bootstrap
|
// Never let the claim-based downgrade strip the last admin. The bootstrap
|
||||||
// admin (first SSO user) usually doesn't carry the admin claim, so a forced
|
// admin (first SSO user) usually doesn't carry the admin claim, so a forced
|
||||||
// re-login — e.g. after a JWT-secret rotation — would otherwise demote it and
|
// re-login — e.g. after a JWT-secret rotation — would otherwise demote it and
|
||||||
@@ -583,6 +667,7 @@ export class OidcService implements OnModuleDestroy {
|
|||||||
console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`);
|
console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`);
|
||||||
} else {
|
} else {
|
||||||
this.db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
this.db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
||||||
|
roleChange = { from: user.role, to: newRole, claim: resolution.claimKey };
|
||||||
user = { ...user, role: newRole } as User;
|
user = { ...user, role: newRole } as User;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,7 +686,7 @@ export class OidcService implements OnModuleDestroy {
|
|||||||
this.db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(picture, user.id);
|
this.db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(picture, user.id);
|
||||||
user = { ...user, avatar: picture } as User;
|
user = { ...user, avatar: picture } as User;
|
||||||
}
|
}
|
||||||
return { user };
|
return { user, roleChange };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- New user registration ---
|
// --- New user registration ---
|
||||||
@@ -624,7 +709,11 @@ export class OidcService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = this.resolveOidcRole(userInfo, isFirstUser);
|
// Same three outcomes as above; a missing claim here means the account is
|
||||||
|
// created with the role it would have had without any mapping at all.
|
||||||
|
const resolution = this.resolveOidcRoleDetailed(userInfo, isFirstUser);
|
||||||
|
if (resolution.claimMissing) this.warnMissingAdminClaim(resolution, null);
|
||||||
|
const role = resolution.role;
|
||||||
const randomPass = crypto.randomBytes(32).toString('hex');
|
const randomPass = crypto.randomBytes(32).toString('hex');
|
||||||
const hash = bcrypt.hashSync(randomPass, 10);
|
const hash = bcrypt.hashSync(randomPass, 10);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import type { Request, Response } from 'express';
|
|||||||
import { OidcController } from '../../../src/nest/oidc/oidc.controller';
|
import { OidcController } from '../../../src/nest/oidc/oidc.controller';
|
||||||
import { OIDC_STATE_TTL_MS } from '../../../src/nest/oidc/oidc.service';
|
import { OIDC_STATE_TTL_MS } from '../../../src/nest/oidc/oidc.service';
|
||||||
import type { OidcService } from '../../../src/nest/oidc/oidc.service';
|
import type { OidcService } from '../../../src/nest/oidc/oidc.service';
|
||||||
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
||||||
|
|
||||||
|
// AuditService is constructor-injected since the OIDC role change started
|
||||||
|
// auditing; the wrapper keeps the historical construction sites positional.
|
||||||
|
const writeAudit = vi.fn();
|
||||||
|
const audit = { writeAudit } as unknown as AuditService;
|
||||||
|
const ctl = (o: OidcService) => new OidcController(o, audit);
|
||||||
|
|
||||||
function svc(o: Partial<OidcService> = {}): OidcService {
|
function svc(o: Partial<OidcService> = {}): OidcService {
|
||||||
return {
|
return {
|
||||||
@@ -54,21 +61,21 @@ afterEach(() => { delete process.env.NODE_ENV; });
|
|||||||
describe('OidcController /login', () => {
|
describe('OidcController /login', () => {
|
||||||
it('403 when SSO is disabled', async () => {
|
it('403 when SSO is disabled', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).login(req, res);
|
await ctl(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).login(req, res);
|
||||||
expect(res.statusCode).toBe(403);
|
expect(res.statusCode).toBe(403);
|
||||||
expect(res.body).toEqual({ error: 'SSO login is disabled.' });
|
expect(res.body).toEqual({ error: 'SSO login is disabled.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('400 when not configured', async () => {
|
it('400 when not configured', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).login(req, res);
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).login(req, res);
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(res.body).toEqual({ error: 'OIDC not configured' });
|
expect(res.body).toEqual({ error: 'OIDC not configured' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to the provider authorize endpoint with PKCE params', async () => {
|
it('redirects to the provider authorize endpoint with PKCE params', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc()).login(req, res);
|
await ctl(svc()).login(req, res);
|
||||||
expect(res.redirect).toHaveBeenCalled();
|
expect(res.redirect).toHaveBeenCalled();
|
||||||
expect(res.redirectedTo).toContain('https://idp/auth?');
|
expect(res.redirectedTo).toContain('https://idp/auth?');
|
||||||
expect(res.redirectedTo).toContain('code_challenge=cc');
|
expect(res.redirectedTo).toContain('code_challenge=cc');
|
||||||
@@ -77,14 +84,14 @@ describe('OidcController /login', () => {
|
|||||||
|
|
||||||
it('binds the state cookie with a maxAge matching the server-side state TTL', async () => {
|
it('binds the state cookie with a maxAge matching the server-side state TTL', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc()).login(req, res);
|
await ctl(svc()).login(req, res);
|
||||||
expect(res.cookie).toHaveBeenCalledWith('trek_oidc_state', 'st', expect.objectContaining({ maxAge: OIDC_STATE_TTL_MS }));
|
expect(res.cookie).toHaveBeenCalledWith('trek_oidc_state', 'st', expect.objectContaining({ maxAge: OIDC_STATE_TTL_MS }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('400 when a non-HTTPS issuer is used in production', async () => {
|
it('400 when a non-HTTPS issuer is used in production', async () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(res.body).toEqual({ error: 'OIDC issuer must use HTTPS in production' });
|
expect(res.body).toEqual({ error: 'OIDC issuer must use HTTPS in production' });
|
||||||
});
|
});
|
||||||
@@ -92,13 +99,13 @@ describe('OidcController /login', () => {
|
|||||||
it('allows a non-HTTPS issuer outside production', async () => {
|
it('allows a non-HTTPS issuer outside production', async () => {
|
||||||
process.env.NODE_ENV = 'development';
|
process.env.NODE_ENV = 'development';
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).login(req, res);
|
||||||
expect(res.redirect).toHaveBeenCalled();
|
expect(res.redirect).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('500 when APP_URL is not configured', async () => {
|
it('500 when APP_URL is not configured', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ getAppUrl: vi.fn().mockReturnValue('') })).login(req, res);
|
await ctl(svc({ getAppUrl: vi.fn().mockReturnValue('') })).login(req, res);
|
||||||
expect(res.statusCode).toBe(500);
|
expect(res.statusCode).toBe(500);
|
||||||
expect(res.body).toEqual({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
expect(res.body).toEqual({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
||||||
});
|
});
|
||||||
@@ -107,7 +114,7 @@ describe('OidcController /login', () => {
|
|||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||||
const reqInvite = { query: { invite: 'tok123' }, headers: {} } as unknown as Request;
|
const reqInvite = { query: { invite: 'tok123' }, headers: {} } as unknown as Request;
|
||||||
await new OidcController(svc({ createState })).login(reqInvite, res);
|
await ctl(svc({ createState })).login(reqInvite, res);
|
||||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123', undefined);
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +126,7 @@ describe('OidcController /login', () => {
|
|||||||
] as const) {
|
] as const) {
|
||||||
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||||
const r = makeRes();
|
const r = makeRes();
|
||||||
await new OidcController(svc({ createState })).login({ query, headers: {} } as unknown as Request, r);
|
await ctl(svc({ createState })).login({ query, headers: {} } as unknown as Request, r);
|
||||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined, expected);
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined, expected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -128,7 +135,7 @@ describe('OidcController /login', () => {
|
|||||||
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const reqBad = { query: { invite: 'tok123', remember: 'yes' }, headers: {} } as unknown as Request;
|
const reqBad = { query: { invite: 'tok123', remember: 'yes' }, headers: {} } as unknown as Request;
|
||||||
await new OidcController(svc({ createState })).login(reqBad, res);
|
await ctl(svc({ createState })).login(reqBad, res);
|
||||||
expect(res.redirect).toHaveBeenCalled();
|
expect(res.redirect).toHaveBeenCalled();
|
||||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123', undefined);
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', 'tok123', undefined);
|
||||||
});
|
});
|
||||||
@@ -136,14 +143,14 @@ describe('OidcController /login', () => {
|
|||||||
it('trims a trailing slash off APP_URL when building the redirect uri', async () => {
|
it('trims a trailing slash off APP_URL when building the redirect uri', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||||
await new OidcController(svc({ getAppUrl: vi.fn().mockReturnValue('https://app///'), createState })).login(req, res);
|
await ctl(svc({ getAppUrl: vi.fn().mockReturnValue('https://app///'), createState })).login(req, res);
|
||||||
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined, undefined);
|
expect(createState).toHaveBeenCalledWith('https://app/api/auth/oidc/callback', undefined, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('500 when discovery throws', async () => {
|
it('500 when discovery throws', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ discover: vi.fn().mockRejectedValue(new Error('boom')) })).login(req, res);
|
await ctl(svc({ discover: vi.fn().mockRejectedValue(new Error('boom')) })).login(req, res);
|
||||||
expect(res.statusCode).toBe(500);
|
expect(res.statusCode).toBe(500);
|
||||||
expect(res.body).toEqual({ error: 'OIDC login failed' });
|
expect(res.body).toEqual({ error: 'OIDC login failed' });
|
||||||
});
|
});
|
||||||
@@ -151,7 +158,7 @@ describe('OidcController /login', () => {
|
|||||||
it('500 logs a non-Error rejection without crashing', async () => {
|
it('500 logs a non-Error rejection without crashing', async () => {
|
||||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ discover: vi.fn().mockRejectedValue('plain string') })).login(req, res);
|
await ctl(svc({ discover: vi.fn().mockRejectedValue('plain string') })).login(req, res);
|
||||||
expect(res.statusCode).toBe(500);
|
expect(res.statusCode).toBe(500);
|
||||||
expect(spy).toHaveBeenCalledWith('[OIDC] Login error:', 'plain string');
|
expect(spy).toHaveBeenCalledWith('[OIDC] Login error:', 'plain string');
|
||||||
});
|
});
|
||||||
@@ -160,33 +167,33 @@ describe('OidcController /login', () => {
|
|||||||
describe('OidcController /callback', () => {
|
describe('OidcController /callback', () => {
|
||||||
it('redirects with sso_disabled when SSO is off', async () => {
|
it('redirects with sso_disabled when SSO is off', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).callback('c', 's', undefined, reqCb('s'), res);
|
await ctl(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=sso_disabled');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=sso_disabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects with the provider error', async () => {
|
it('redirects with the provider error', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc()).callback(undefined, undefined, 'access_denied', reqCb('s'), res);
|
await ctl(svc()).callback(undefined, undefined, 'access_denied', reqCb('s'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=access_denied');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=access_denied');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects missing_params / invalid_state', async () => {
|
it('redirects missing_params / invalid_state', async () => {
|
||||||
const r1 = makeRes();
|
const r1 = makeRes();
|
||||||
await new OidcController(svc()).callback(undefined, 's', undefined, reqCb('s'), r1);
|
await ctl(svc()).callback(undefined, 's', undefined, reqCb('s'), r1);
|
||||||
expect(r1.redirectedTo).toBe('https://app/login?oidc_error=missing_params');
|
expect(r1.redirectedTo).toBe('https://app/login?oidc_error=missing_params');
|
||||||
const r2 = makeRes();
|
const r2 = makeRes();
|
||||||
await new OidcController(svc({ consumeState: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), r2);
|
await ctl(svc({ consumeState: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), r2);
|
||||||
expect(r2.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
expect(r2.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a missing id_token, then completes with an auth code on success', async () => {
|
it('rejects a missing id_token, then completes with an auth code on success', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const noId = makeRes();
|
const noId = makeRes();
|
||||||
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at' }) })).callback('c', 's', undefined, reqCb('s'), noId);
|
await ctl(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at' }) })).callback('c', 's', undefined, reqCb('s'), noId);
|
||||||
expect(noId.redirectedTo).toBe('https://app/login?oidc_error=no_id_token');
|
expect(noId.redirectedTo).toBe('https://app/login?oidc_error=no_id_token');
|
||||||
|
|
||||||
const ok = makeRes();
|
const ok = makeRes();
|
||||||
const c = new OidcController(svc({
|
const c = ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||||
@@ -199,18 +206,51 @@ describe('OidcController /callback', () => {
|
|||||||
expect(ok.cookie).toHaveBeenCalledWith('trek_oidc_exchange', 'bnd', expect.objectContaining({ httpOnly: true, maxAge: 60000, sameSite: 'lax' }));
|
expect(ok.cookie).toHaveBeenCalledWith('trek_oidc_exchange', 'bnd', expect.objectContaining({ httpOnly: true, maxAge: 60000, sameSite: 'lax' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('audits a role change the claim mapping made, with the client IP and the claim name only', async () => {
|
||||||
|
const res = makeRes();
|
||||||
|
const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request;
|
||||||
|
await ctl(svc({
|
||||||
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||||
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7 }, roleChange: { from: 'admin', to: 'user', claim: 'entitlements' } }),
|
||||||
|
})).callback('c', 's', undefined, reqIp, res);
|
||||||
|
|
||||||
|
expect(writeAudit).toHaveBeenCalledWith({
|
||||||
|
userId: 7,
|
||||||
|
action: 'oidc.role_change',
|
||||||
|
resource: '7',
|
||||||
|
ip: '203.0.113.9',
|
||||||
|
details: { from: 'admin', to: 'user', claim: 'entitlements' },
|
||||||
|
});
|
||||||
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes no audit row when the login left the role alone', async () => {
|
||||||
|
const res = makeRes();
|
||||||
|
await ctl(svc({
|
||||||
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||||
|
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7 } }),
|
||||||
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
|
|
||||||
|
expect(writeAudit).not.toHaveBeenCalled();
|
||||||
|
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a callback whose state cookie does not match the query state', async () => {
|
it('rejects a callback whose state cookie does not match the query state', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
// Browser presents a different (or no) state cookie than the callback URL —
|
// Browser presents a different (or no) state cookie than the callback URL —
|
||||||
// an attacker-initiated flow replayed in the victim's browser.
|
// an attacker-initiated flow replayed in the victim's browser.
|
||||||
await new OidcController(svc()).callback('c', 's', undefined, reqCb('attacker-state'), res);
|
await ctl(svc()).callback('c', 's', undefined, reqCb('attacker-state'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a userinfo subject mismatch', async () => {
|
it('rejects a userinfo subject mismatch', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const c = new OidcController(svc({
|
const c = ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'OTHER' }),
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'OTHER' }),
|
||||||
@@ -222,48 +262,48 @@ describe('OidcController /callback', () => {
|
|||||||
it('redirects invalid_state when there is no bound state cookie at all', async () => {
|
it('redirects invalid_state when there is no bound state cookie at all', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const reqNoCookie = { query: {}, headers: {}, cookies: {} } as unknown as Request;
|
const reqNoCookie = { query: {}, headers: {}, cookies: {} } as unknown as Request;
|
||||||
await new OidcController(svc()).callback('c', 's', undefined, reqNoCookie, res);
|
await ctl(svc()).callback('c', 's', undefined, reqNoCookie, res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates a request with no cookies object', async () => {
|
it('tolerates a request with no cookies object', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const reqNoCookies = { query: {}, headers: {} } as unknown as Request;
|
const reqNoCookies = { query: {}, headers: {} } as unknown as Request;
|
||||||
await new OidcController(svc()).callback('c', 's', undefined, reqNoCookies, res);
|
await ctl(svc()).callback('c', 's', undefined, reqNoCookies, res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects not_configured when the config disappears mid-flow', async () => {
|
it('redirects not_configured when the config disappears mid-flow', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), res);
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=not_configured');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=not_configured');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects issuer_not_https when a non-HTTPS issuer is used in production', async () => {
|
it('redirects issuer_not_https when a non-HTTPS issuer is used in production', async () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).callback('c', 's', undefined, reqCb('s'), res);
|
await ctl(svc({ getOidcConfig: vi.fn().mockReturnValue({ issuer: 'http://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=issuer_not_https');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=issuer_not_https');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects token_failed when the token exchange is not ok', async () => {
|
it('redirects token_failed when the token exchange is not ok', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: false, _status: 401 }) })).callback('c', 's', undefined, reqCb('s'), res);
|
await ctl(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: false, _status: 401 }) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects token_failed when the access token is missing', async () => {
|
it('redirects token_failed when the access token is missing', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true }) })).callback('c', 's', undefined, reqCb('s'), res);
|
await ctl(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true }) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=token_failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects id_token_invalid when verification fails with a reason', async () => {
|
it('redirects id_token_invalid when verification fails with a reason', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: false, error: 'bad_signature' }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: false, error: 'bad_signature' }),
|
||||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
@@ -273,7 +313,7 @@ describe('OidcController /callback', () => {
|
|||||||
it('redirects id_token_invalid when verification fails without an error field', async () => {
|
it('redirects id_token_invalid when verification fails without an error field', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: false }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: false }),
|
||||||
})).callback('c', 's', undefined, reqCb('s'), res);
|
})).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
@@ -283,7 +323,7 @@ describe('OidcController /callback', () => {
|
|||||||
it('falls back to config.issuer when the discovery doc has no issuer', async () => {
|
it('falls back to config.issuer when the discovery doc has no issuer', async () => {
|
||||||
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui' }),
|
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui' }),
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken,
|
verifyIdToken,
|
||||||
@@ -298,7 +338,7 @@ describe('OidcController /callback', () => {
|
|||||||
it('strips trailing slashes off the discovery doc issuer before verifying', async () => {
|
it('strips trailing slashes off the discovery doc issuer before verifying', async () => {
|
||||||
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp/' }),
|
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp/' }),
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken,
|
verifyIdToken,
|
||||||
@@ -310,7 +350,7 @@ describe('OidcController /callback', () => {
|
|||||||
|
|
||||||
it('redirects no_email when the userinfo has no email', async () => {
|
it('redirects no_email when the userinfo has no email', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
getUserInfo: vi.fn().mockResolvedValue({ sub: 'u1' }),
|
getUserInfo: vi.fn().mockResolvedValue({ sub: 'u1' }),
|
||||||
@@ -320,7 +360,7 @@ describe('OidcController /callback', () => {
|
|||||||
|
|
||||||
it('accepts when userinfo omits sub (no cross-check to run)', async () => {
|
it('accepts when userinfo omits sub (no cross-check to run)', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c' }),
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c' }),
|
||||||
@@ -331,7 +371,7 @@ describe('OidcController /callback', () => {
|
|||||||
|
|
||||||
it('accepts when the id_token claims have a non-string sub (cross-check skipped)', async () => {
|
it('accepts when the id_token claims have a non-string sub (cross-check skipped)', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 12345 } }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 12345 } }),
|
||||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'something-else' }),
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'something-else' }),
|
||||||
@@ -342,7 +382,7 @@ describe('OidcController /callback', () => {
|
|||||||
|
|
||||||
it('surfaces a findOrCreateUser provisioning error', async () => {
|
it('surfaces a findOrCreateUser provisioning error', async () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||||
@@ -354,7 +394,7 @@ describe('OidcController /callback', () => {
|
|||||||
it('redirects server_error when the flow throws', async () => {
|
it('redirects server_error when the flow throws', async () => {
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({ discover: vi.fn().mockRejectedValue(new Error('network down')) })).callback('c', 's', undefined, reqCb('s'), res);
|
await ctl(svc({ discover: vi.fn().mockRejectedValue(new Error('network down')) })).callback('c', 's', undefined, reqCb('s'), res);
|
||||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=server_error');
|
expect(res.redirectedTo).toBe('https://app/login?oidc_error=server_error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -366,7 +406,7 @@ describe('OidcController /callback', () => {
|
|||||||
const generateToken = vi.fn().mockReturnValue('jwt');
|
const generateToken = vi.fn().mockReturnValue('jwt');
|
||||||
const createAuthCode = vi.fn().mockReturnValue({ code: 'ac', binding: 'bnd' });
|
const createAuthCode = vi.fn().mockReturnValue({ code: 'ac', binding: 'bnd' });
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
await new OidcController(svc({
|
await ctl(svc({
|
||||||
consumeState: vi.fn().mockReturnValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv', inviteToken: undefined, remember }),
|
consumeState: vi.fn().mockReturnValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv', inviteToken: undefined, remember }),
|
||||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||||
@@ -384,19 +424,19 @@ describe('OidcController /callback', () => {
|
|||||||
describe('OidcController /exchange', () => {
|
describe('OidcController /exchange', () => {
|
||||||
it('400 without a code, 400 on an invalid code, else sets the cookie + returns the token', () => {
|
it('400 without a code, 400 on an invalid code, else sets the cookie + returns the token', () => {
|
||||||
const r1 = makeRes();
|
const r1 = makeRes();
|
||||||
new OidcController(svc()).exchange(undefined, reqEx(), r1);
|
ctl(svc()).exchange(undefined, reqEx(), r1);
|
||||||
expect(r1.statusCode).toBe(400);
|
expect(r1.statusCode).toBe(400);
|
||||||
expect(r1.body).toEqual({ error: 'Code required' });
|
expect(r1.body).toEqual({ error: 'Code required' });
|
||||||
|
|
||||||
const r2 = makeRes();
|
const r2 = makeRes();
|
||||||
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ error: 'invalid_code' }) })).exchange('x', reqEx(), r2);
|
ctl(svc({ consumeAuthCode: vi.fn().mockReturnValue({ error: 'invalid_code' }) })).exchange('x', reqEx(), r2);
|
||||||
expect(r2.statusCode).toBe(400);
|
expect(r2.statusCode).toBe(400);
|
||||||
expect(r2.body).toEqual({ error: 'invalid_code' });
|
expect(r2.body).toEqual({ error: 'invalid_code' });
|
||||||
|
|
||||||
const r3 = makeRes();
|
const r3 = makeRes();
|
||||||
const setAuthCookie = vi.fn();
|
const setAuthCookie = vi.fn();
|
||||||
const req3 = reqEx();
|
const req3 = reqEx();
|
||||||
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt' }), setAuthCookie })).exchange('x', req3, r3);
|
ctl(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt' }), setAuthCookie })).exchange('x', req3, r3);
|
||||||
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req3, undefined);
|
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req3, undefined);
|
||||||
expect(r3.body).toEqual({ token: 'jwt' });
|
expect(r3.body).toEqual({ token: 'jwt' });
|
||||||
});
|
});
|
||||||
@@ -406,7 +446,7 @@ describe('OidcController /exchange', () => {
|
|||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const setAuthCookie = vi.fn();
|
const setAuthCookie = vi.fn();
|
||||||
const r = reqEx();
|
const r = reqEx();
|
||||||
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt', remember }), setAuthCookie })).exchange('x', r, res);
|
ctl(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt', remember }), setAuthCookie })).exchange('x', r, res);
|
||||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', r, remember);
|
expect(setAuthCookie).toHaveBeenCalledWith(res, 'jwt', r, remember);
|
||||||
expect(res.body).toEqual({ token: 'jwt' });
|
expect(res.body).toEqual({ token: 'jwt' });
|
||||||
}
|
}
|
||||||
@@ -415,7 +455,7 @@ describe('OidcController /exchange', () => {
|
|||||||
it('hands the binding cookie to consumeAuthCode and clears it on the way out', () => {
|
it('hands the binding cookie to consumeAuthCode and clears it on the way out', () => {
|
||||||
const consumeAuthCode = vi.fn().mockReturnValue({ token: 'jwt' });
|
const consumeAuthCode = vi.fn().mockReturnValue({ token: 'jwt' });
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
new OidcController(svc({ consumeAuthCode })).exchange('x', reqEx('the-secret'), res);
|
ctl(svc({ consumeAuthCode })).exchange('x', reqEx('the-secret'), res);
|
||||||
expect(consumeAuthCode).toHaveBeenCalledWith('x', 'the-secret');
|
expect(consumeAuthCode).toHaveBeenCalledWith('x', 'the-secret');
|
||||||
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.objectContaining({ httpOnly: true, path: '/' }));
|
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.objectContaining({ httpOnly: true, path: '/' }));
|
||||||
});
|
});
|
||||||
@@ -424,7 +464,7 @@ describe('OidcController /exchange', () => {
|
|||||||
const consumeAuthCode = vi.fn().mockReturnValue({ error: 'Invalid or expired code' });
|
const consumeAuthCode = vi.fn().mockReturnValue({ error: 'Invalid or expired code' });
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
// A request from any other context: no cookie jar for this origin at all.
|
// A request from any other context: no cookie jar for this origin at all.
|
||||||
new OidcController(svc({ consumeAuthCode })).exchange('stolen', { query: {}, headers: {} } as Request, res);
|
ctl(svc({ consumeAuthCode })).exchange('stolen', { query: {}, headers: {} } as Request, res);
|
||||||
expect(consumeAuthCode).toHaveBeenCalledWith('stolen', undefined);
|
expect(consumeAuthCode).toHaveBeenCalledWith('stolen', undefined);
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.anything());
|
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.anything());
|
||||||
@@ -432,7 +472,7 @@ describe('OidcController /exchange', () => {
|
|||||||
|
|
||||||
it('clears the binding cookie even when no code was presented', () => {
|
it('clears the binding cookie even when no code was presented', () => {
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
new OidcController(svc()).exchange(undefined, reqEx(), res);
|
ctl(svc()).exchange(undefined, reqEx(), res);
|
||||||
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.anything());
|
expect(res.clearCookie).toHaveBeenCalledWith('trek_oidc_exchange', expect.anything());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -798,6 +798,236 @@ describe('findOrCreateUser', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── claim-based role mapping on login (#2364) ────────────────────────────────
|
||||||
|
|
||||||
|
describe('findOrCreateUser role mapping', () => {
|
||||||
|
type RoleResult = { user: any; roleChange?: { from: string; to: string; claim: string } };
|
||||||
|
|
||||||
|
function ssoUser(email: string, sub: string, role: 'admin' | 'user') {
|
||||||
|
const { user } = createUser(testDb, { email, role });
|
||||||
|
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, MOCK_CONFIG.issuer, user.id);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
const storedRole = (id: number) => (testDb.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string }).role;
|
||||||
|
|
||||||
|
it('OIDC-SVC-063: an admin keeps the role when the configured claim is absent from the payload', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
|
||||||
|
// A second admin row, so the #1274 last-admin guard cannot be what saves them.
|
||||||
|
createUser(testDb, { email: 'bootstrap@example.com', role: 'admin' });
|
||||||
|
const user = ssoUser('sso-admin@example.com', 'sub-keep-admin', 'admin');
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = svc.findOrCreateUser(
|
||||||
|
{ sub: 'sub-keep-admin', email: 'sso-admin@example.com', groups: ['authentik Admins'] },
|
||||||
|
MOCK_CONFIG
|
||||||
|
) as RoleResult;
|
||||||
|
|
||||||
|
expect(result.user.role).toBe('admin');
|
||||||
|
expect(storedRole(user.id)).toBe('admin');
|
||||||
|
expect(result.roleChange).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-064: an absent claim does not promote a plain user either', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
|
||||||
|
const user = ssoUser('plain@example.com', 'sub-keep-user', 'user');
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = svc.findOrCreateUser(
|
||||||
|
{ sub: 'sub-keep-user', email: 'plain@example.com', groups: ['authentik Admins'] },
|
||||||
|
MOCK_CONFIG
|
||||||
|
) as RoleResult;
|
||||||
|
|
||||||
|
expect(result.user.role).toBe('user');
|
||||||
|
expect(storedRole(user.id)).toBe('user');
|
||||||
|
expect(result.roleChange).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-065: a custom claim that does arrive still promotes, and reports the change', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
|
||||||
|
const user = ssoUser('promote@example.com', 'sub-promote', 'user');
|
||||||
|
|
||||||
|
const result = svc.findOrCreateUser(
|
||||||
|
{ sub: 'sub-promote', email: 'promote@example.com', entitlements: ['trek-users', 'trek-admins'] },
|
||||||
|
MOCK_CONFIG
|
||||||
|
) as RoleResult;
|
||||||
|
|
||||||
|
expect(storedRole(user.id)).toBe('admin');
|
||||||
|
expect(result.roleChange).toEqual({ from: 'user', to: 'admin', claim: 'entitlements' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-066: a claim that arrives without the admin value still demotes', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
|
||||||
|
createUser(testDb, { email: 'bootstrap2@example.com', role: 'admin' });
|
||||||
|
const user = ssoUser('demote@example.com', 'sub-demote', 'admin');
|
||||||
|
|
||||||
|
const result = svc.findOrCreateUser(
|
||||||
|
{ sub: 'sub-demote', email: 'demote@example.com', entitlements: ['trek-users'] },
|
||||||
|
MOCK_CONFIG
|
||||||
|
) as RoleResult;
|
||||||
|
|
||||||
|
expect(storedRole(user.id)).toBe('user');
|
||||||
|
expect(result.roleChange).toEqual({ from: 'admin', to: 'user', claim: 'entitlements' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-067: the only admin is still kept when the claim arrives without the admin value (#1274)', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
|
||||||
|
const user = ssoUser('lonely@example.com', 'sub-lonely', 'admin');
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = svc.findOrCreateUser(
|
||||||
|
{ sub: 'sub-lonely', email: 'lonely@example.com', entitlements: ['trek-users'] },
|
||||||
|
MOCK_CONFIG
|
||||||
|
) as RoleResult;
|
||||||
|
|
||||||
|
expect(storedRole(user.id)).toBe('admin');
|
||||||
|
expect(result.roleChange).toBeUndefined();
|
||||||
|
expect(warn.mock.calls[0][0]).toContain('only admin');
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-068: for a plain user the absent-claim warning names the claim and the keys that arrived, never their values, and fires once', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'trek_roles';
|
||||||
|
ssoUser('warned@example.com', 'sub-warned', 'user');
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
// The warning is deduped per claim name for the life of the process, so this
|
||||||
|
// case needs an instance that has not seen the claim yet.
|
||||||
|
const fresh = new OidcService(new DatabaseService(testDb), auth, membership);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = { sub: 'sub-warned', email: 'warned@example.com', groups: ['authentik Admins'] };
|
||||||
|
fresh.findOrCreateUser(info, MOCK_CONFIG);
|
||||||
|
fresh.findOrCreateUser(info, MOCK_CONFIG);
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
const line = warn.mock.calls[0][0] as string;
|
||||||
|
expect(line).toContain('"trek_roles"');
|
||||||
|
expect(line).toContain('sub, email, groups');
|
||||||
|
expect(line).toContain('OIDC_SCOPE');
|
||||||
|
expect(line).not.toContain('authentik Admins');
|
||||||
|
expect(line).not.toContain('warned@example.com');
|
||||||
|
} finally {
|
||||||
|
fresh.onModuleDestroy();
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-069: a new account is registered with the default role when the claim is absent', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'reg_entitlements';
|
||||||
|
createUser(testDb, { email: 'someone@example.com' }); // not the first user any more
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = svc.findOrCreateUser(
|
||||||
|
{ sub: 'sub-fresh', email: 'fresh@example.com', name: 'Fresh', groups: ['authentik Admins'] },
|
||||||
|
MOCK_CONFIG
|
||||||
|
) as RoleResult;
|
||||||
|
|
||||||
|
expect(storedRole(result.user.id)).toBe('user');
|
||||||
|
expect(result.roleChange).toBeUndefined();
|
||||||
|
expect(warn.mock.calls[0][0]).toContain('during registration');
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-070: a claim that already agrees with the stored role reports no change', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
|
||||||
|
const user = ssoUser('steady@example.com', 'sub-steady', 'admin');
|
||||||
|
|
||||||
|
const result = svc.findOrCreateUser(
|
||||||
|
{ sub: 'sub-steady', email: 'steady@example.com', entitlements: ['trek-admins'] },
|
||||||
|
MOCK_CONFIG
|
||||||
|
) as RoleResult;
|
||||||
|
|
||||||
|
expect(storedRole(user.id)).toBe('admin');
|
||||||
|
expect(result.roleChange).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-071: resolveOidcRoleDetailed only reports a missing claim where the mapping is actually consulted', () => {
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'entitlements';
|
||||||
|
const info = { sub: 'x', groups: ['authentik Admins'] };
|
||||||
|
|
||||||
|
delete process.env.OIDC_ADMIN_VALUE;
|
||||||
|
expect(svc.resolveOidcRoleDetailed(info, false)).toMatchObject({ role: 'user', claimMissing: false, claimKey: 'entitlements' });
|
||||||
|
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
expect(svc.resolveOidcRoleDetailed(info, true)).toMatchObject({ role: 'admin', claimMissing: false });
|
||||||
|
expect(svc.resolveOidcRoleDetailed(info, false)).toMatchObject({ role: 'user', claimMissing: true, seenKeys: ['sub', 'groups'] });
|
||||||
|
expect(svc.resolveOidcRoleDetailed({ ...info, entitlements: ['trek-admins'] }, false)).toMatchObject({ role: 'admin', claimMissing: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-072: a stored admin whose claim never arrives is warned about on every login, by id, with the admin panel as the way out', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'kept_admin_roles';
|
||||||
|
createUser(testDb, { email: 'bootstrap3@example.com', role: 'admin' });
|
||||||
|
const user = ssoUser('kept@example.com', 'sub-kept-admin', 'admin');
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = { sub: 'sub-kept-admin', email: 'kept@example.com', groups: ['authentik Admins'] };
|
||||||
|
svc.findOrCreateUser(info, MOCK_CONFIG);
|
||||||
|
svc.findOrCreateUser(info, MOCK_CONFIG);
|
||||||
|
|
||||||
|
// No dedup here: this is the login where an IdP-side revocation quietly fails.
|
||||||
|
expect(warn).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of warn.mock.calls) {
|
||||||
|
const line = call[0] as string;
|
||||||
|
expect(line).toContain(`User ${user.id} (${user.username}) is stored as an admin`);
|
||||||
|
expect(line).toContain('"kept_admin_roles"');
|
||||||
|
expect(line).toContain('admin panel');
|
||||||
|
expect(line).toContain('sub, email, groups');
|
||||||
|
expect(line).not.toContain('authentik Admins');
|
||||||
|
expect(line).not.toContain('kept@example.com');
|
||||||
|
}
|
||||||
|
expect(storedRole(user.id)).toBe('admin');
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OIDC-SVC-073: warning about a stored admin does not use up the once-per-claim warning for everybody else', () => {
|
||||||
|
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||||
|
process.env.OIDC_ADMIN_CLAIM = 'shared_roles';
|
||||||
|
createUser(testDb, { email: 'bootstrap4@example.com', role: 'admin' });
|
||||||
|
ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin');
|
||||||
|
ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user');
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const fresh = new OidcService(new DatabaseService(testDb), auth, membership);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fresh.findOrCreateUser({ sub: 'sub-shared-admin', email: 'shared-admin@example.com' }, MOCK_CONFIG);
|
||||||
|
fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG);
|
||||||
|
fresh.findOrCreateUser({ sub: 'sub-shared-plain', email: 'shared-plain@example.com' }, MOCK_CONFIG);
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(warn.mock.calls[0][0]).toContain('is stored as an admin');
|
||||||
|
expect(warn.mock.calls[1][0]).toContain('so their stored role is left unchanged');
|
||||||
|
} finally {
|
||||||
|
fresh.onModuleDestroy();
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── exchangeCodeForToken ──────────────────────────────────────────────────────
|
// ── exchangeCodeForToken ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('exchangeCodeForToken', () => {
|
describe('exchangeCodeForToken', () => {
|
||||||
|
|||||||
+30
-2
@@ -53,11 +53,39 @@ For example: `https://trek.example.com/api/auth/oidc/callback`
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO button. Defaults to `SSO`. |
|
| `OIDC_DISPLAY_NAME` | Label shown on the SSO button. Defaults to `SSO`. |
|
||||||
| `OIDC_ONLY` | Set to `true` to disable local password login and password registration. SSO login and SSO registration remain governed by their own toggles. This is an environment-variable-only setting and cannot be toggled at runtime via the admin panel. |
|
| `OIDC_ONLY` | Set to `true` to disable local password login and password registration. SSO login and SSO registration remain governed by their own toggles. This is an environment-variable-only setting and cannot be toggled at runtime via the admin panel. |
|
||||||
| `OIDC_ADMIN_CLAIM` | OIDC claim to inspect for admin role mapping. Defaults to `groups`. The claim value may be an array or a plain string. **Env var only — not configurable via the admin panel.** |
|
| `OIDC_ADMIN_CLAIM` | OIDC claim to inspect for admin role mapping. Defaults to `groups`. The claim value may be an array or a plain string. The claim only reaches TREK if one of the scopes in `OIDC_SCOPE` carries it — see *Admin role mapping* below. **Env var only — not configurable via the admin panel.** |
|
||||||
| `OIDC_ADMIN_VALUE` | Value that must be present in `OIDC_ADMIN_CLAIM` to grant the admin role. If unset, claim-based role mapping is disabled. When set, the role is re-evaluated on every login. **Env var only — not configurable via the admin panel.** |
|
| `OIDC_ADMIN_VALUE` | Value that must be present in `OIDC_ADMIN_CLAIM` to grant the admin role. If unset, claim-based role mapping is disabled. When set, the role is re-evaluated on every login. **Env var only — not configurable via the admin panel.** |
|
||||||
| `OIDC_SCOPE` | Overrides the default scope list sent to the provider. Defaults to `openid email profile`. Ensure `openid` and `email` are always included. **Env var only — not configurable via the admin panel.** |
|
| `OIDC_SCOPE` | Overrides the default scope list sent to the provider. Defaults to `openid email profile`. Ensure `openid` and `email` are always included, plus whichever scope carries your `OIDC_ADMIN_CLAIM`. **Env var only — not configurable via the admin panel.** |
|
||||||
| `OIDC_DISCOVERY_URL` | Full URL to the OIDC discovery document. Use this for providers with non-standard discovery paths (e.g. Authentik tenants). If unset, discovery is attempted at `<OIDC_ISSUER>/.well-known/openid-configuration`. The discovery document is cached for 1 hour. |
|
| `OIDC_DISCOVERY_URL` | Full URL to the OIDC discovery document. Use this for providers with non-standard discovery paths (e.g. Authentik tenants). If unset, discovery is attempted at `<OIDC_ISSUER>/.well-known/openid-configuration`. The discovery document is cached for 1 hour. |
|
||||||
|
|
||||||
|
## Admin role mapping
|
||||||
|
|
||||||
|
TREK requests exactly the scopes listed in `OIDC_SCOPE` and nothing else, and a provider only emits a claim when a requested scope carries it. So a claim that is not covered by `OIDC_SCOPE` never arrives, and a claim that never arrives can never grant the admin role — no matter how `OIDC_ADMIN_CLAIM` is spelled.
|
||||||
|
|
||||||
|
Authentik is the usual example: `groups` rides the default `profile` scope, so it works out of the box, while `entitlements` has a scope of its own. Mapping admins onto an entitlement therefore takes all three variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
OIDC_ADMIN_CLAIM=entitlements
|
||||||
|
OIDC_ADMIN_VALUE=trek-admins
|
||||||
|
OIDC_SCOPE=openid email profile entitlements
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak (group and role mappers carry their own "Add to userinfo" switch) and Entra ID (optional claims) behave the same way — whatever the claim is called, check that its scope is requested.
|
||||||
|
|
||||||
|
If the configured claim is missing from the userinfo response entirely, TREK leaves the stored role untouched — a claim that never arrived is not a statement that somebody is no longer an admin — and writes one line to the server log naming the claim it looked for and the claims it did receive. Read that line first when role mapping does not do what you expect. A claim that *does* arrive without `OIDC_ADMIN_VALUE` in it still demotes on the next login; that is how the mapping takes admin away. Every role change it makes is recorded in the admin audit log.
|
||||||
|
|
||||||
|
### Taking admin away
|
||||||
|
|
||||||
|
Some providers omit a claim instead of sending it empty. Okta emits a filtered `groups` claim only when the filter matches at least one group, and Entra ID leaves `groups` out for a user who is in no group at all. **On those providers, removing somebody from the admin group does not demote them in TREK.** Their next login carries no claim, and no claim means no verdict, so the account keeps `admin` for as long as it exists. Take the role away in TREK as well, in **Admin → Users** ([Admin-Users-and-Invites](Admin-Users-and-Invites)) — the IdP side alone is not enough.
|
||||||
|
|
||||||
|
TREK writes a warning to the server log on **every** login where this happens, naming the account, so `docker logs trek | grep OIDC` lists the users it affects:
|
||||||
|
|
||||||
|
```
|
||||||
|
[OIDC] User 7 (alex) is stored as an admin and the configured OIDC_ADMIN_CLAIM "groups" was not in their userinfo response, so the admin role is kept. …
|
||||||
|
```
|
||||||
|
|
||||||
|
A provider that sends the claim as an empty list instead of dropping it is not affected: the empty list arrives, does not contain `OIDC_ADMIN_VALUE`, and demotes the account on that same login.
|
||||||
|
|
||||||
## New-user registration via SSO
|
## New-user registration via SSO
|
||||||
|
|
||||||
When an SSO login matches an existing TREK account by OIDC subject (`sub`), that account is used directly. When it matches only by **email**, the OIDC identity is linked to that account only if the provider asserts `email_verified` for it; if the claim is missing or false the login is rejected with an `email_not_verified` error, so an unverified address can never take over a local account. Make sure your IdP includes `email_verified` in the userinfo response — it is part of the `email` scope. If no matching account exists, TREK attempts to create one. The outcome depends on the following:
|
When an SSO login matches an existing TREK account by OIDC subject (`sub`), that account is used directly. When it matches only by **email**, the OIDC identity is linked to that account only if the provider asserts `email_verified` for it; if the claim is missing or false the login is rejected with an `email_not_verified` error, so an unverified address can never take over a local account. Make sure your IdP includes `email_verified` in the userinfo response — it is part of the `email` scope. If no matching account exists, TREK attempts to create one. The outcome depends on the following:
|
||||||
|
|||||||
Reference in New Issue
Block a user