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:
Maurice
2026-09-16 13:00:39 +02:00
parent 5d04f9c400
commit 4b3fb7dec9
6 changed files with 456 additions and 52 deletions
+1
View File
@@ -24,6 +24,7 @@ const ACTION_LABELS: Record<string, string> = {
'admin.storage_backfill_cancel': 'cancelled a storage sync',
'admin.storage_stats_refresh': 'refreshed storage usage stats',
'immich.private_ip_configured': 'configured Immich with private IP',
'oidc.role_change': 'role changed by OIDC claim mapping',
};
/**
+17 -1
View File
@@ -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')
@Controller('api/auth/oidc')
export class OidcController {
constructor(private readonly oidc: OidcService) {}
constructor(
private readonly oidc: OidcService,
private readonly audit: AuditService,
) {}
@Get('login')
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);
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);
// Pass the flag through untouched: `undefined` must reach the token as
+94 -5
View File
@@ -56,6 +56,26 @@ export interface OidcConfig {
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
// ---------------------------------------------------------------------------
@@ -197,6 +217,10 @@ export class OidcService implements OnModuleDestroy {
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 codeSweeper: NodeJS.Timeout;
@@ -353,6 +377,62 @@ export class OidcService implements OnModuleDestroy {
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
// -------------------------------------------------------------------------
@@ -531,7 +611,7 @@ export class OidcService implements OnModuleDestroy {
userInfo: OidcUserInfo,
config: OidcConfig,
inviteToken?: string,
): { user: User } | { error: string } {
): { user: User; roleChange?: OidcRoleChange } | { error: string } {
// Defense-in-depth for direct callers — the controller redirects on a
// missing email before it ever calls this; the same code flows through its
// `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;
}
// Update role based on OIDC claims on every login (if claim mapping is configured)
let roleChange: OidcRoleChange | undefined;
if (readEnv().oidc.adminValue) {
const newRole = this.resolveOidcRole(userInfo, false);
if (user.role !== newRole) {
const resolution = this.resolveOidcRoleDetailed(userInfo, false);
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
// 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
@@ -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.`);
} else {
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;
}
}
@@ -601,7 +686,7 @@ export class OidcService implements OnModuleDestroy {
this.db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(picture, user.id);
user = { ...user, avatar: picture } as User;
}
return { user };
return { user, roleChange };
}
// --- 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 hash = bcrypt.hashSync(randomPass, 10);
+84 -44
View File
@@ -4,6 +4,13 @@ import type { Request, Response } from 'express';
import { OidcController } from '../../../src/nest/oidc/oidc.controller';
import { OIDC_STATE_TTL_MS } 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 {
return {
@@ -54,21 +61,21 @@ afterEach(() => { delete process.env.NODE_ENV; });
describe('OidcController /login', () => {
it('403 when SSO is disabled', async () => {
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.body).toEqual({ error: 'SSO login is disabled.' });
});
it('400 when not configured', async () => {
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.body).toEqual({ error: 'OIDC not configured' });
});
it('redirects to the provider authorize endpoint with PKCE params', async () => {
const res = makeRes();
await new OidcController(svc()).login(req, res);
await ctl(svc()).login(req, res);
expect(res.redirect).toHaveBeenCalled();
expect(res.redirectedTo).toContain('https://idp/auth?');
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 () => {
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 }));
});
it('400 when a non-HTTPS issuer is used in production', async () => {
process.env.NODE_ENV = 'production';
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.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 () => {
process.env.NODE_ENV = 'development';
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();
});
it('500 when APP_URL is not configured', async () => {
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.body).toEqual({ error: 'APP_URL is not configured. OIDC cannot be used.' });
});
@@ -107,7 +114,7 @@ describe('OidcController /login', () => {
const res = makeRes();
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
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);
});
@@ -119,7 +126,7 @@ describe('OidcController /login', () => {
] as const) {
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
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);
}
});
@@ -128,7 +135,7 @@ describe('OidcController /login', () => {
const createState = vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' });
const res = makeRes();
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(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 () => {
const res = makeRes();
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);
});
it('500 when discovery throws', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
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.body).toEqual({ error: 'OIDC login failed' });
});
@@ -151,7 +158,7 @@ describe('OidcController /login', () => {
it('500 logs a non-Error rejection without crashing', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
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(spy).toHaveBeenCalledWith('[OIDC] Login error:', 'plain string');
});
@@ -160,33 +167,33 @@ describe('OidcController /login', () => {
describe('OidcController /callback', () => {
it('redirects with sso_disabled when SSO is off', async () => {
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');
});
it('redirects with the provider error', async () => {
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');
});
it('redirects missing_params / invalid_state', async () => {
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');
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');
});
it('rejects a missing id_token, then completes with an auth code on success', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
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');
const ok = makeRes();
const c = new OidcController(svc({
const c = 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' }),
@@ -199,18 +206,51 @@ describe('OidcController /callback', () => {
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 () => {
const res = makeRes();
// Browser presents a different (or no) state cookie than the callback URL —
// 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');
});
it('rejects a userinfo subject mismatch', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const res = makeRes();
const c = new OidcController(svc({
const c = 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: 'OTHER' }),
@@ -222,48 +262,48 @@ describe('OidcController /callback', () => {
it('redirects invalid_state when there is no bound state cookie at all', async () => {
const res = makeRes();
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');
});
it('tolerates a request with no cookies object', async () => {
const res = makeRes();
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');
});
it('redirects not_configured when the config disappears mid-flow', async () => {
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');
});
it('redirects issuer_not_https when a non-HTTPS issuer is used in production', async () => {
process.env.NODE_ENV = 'production';
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');
});
it('redirects token_failed when the token exchange is not ok', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
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');
});
it('redirects token_failed when the access token is missing', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
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');
});
it('redirects id_token_invalid when verification fails with a reason', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const res = makeRes();
await new OidcController(svc({
await ctl(svc({
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
verifyIdToken: vi.fn().mockResolvedValue({ ok: false, error: 'bad_signature' }),
})).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 () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const res = makeRes();
await new OidcController(svc({
await ctl(svc({
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
verifyIdToken: vi.fn().mockResolvedValue({ ok: false }),
})).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 () => {
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
const res = makeRes();
await new OidcController(svc({
await ctl(svc({
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' }),
verifyIdToken,
@@ -298,7 +338,7 @@ describe('OidcController /callback', () => {
it('strips trailing slashes off the discovery doc issuer before verifying', async () => {
const verifyIdToken = vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
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/' }),
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
verifyIdToken,
@@ -310,7 +350,7 @@ describe('OidcController /callback', () => {
it('redirects no_email when the userinfo has no email', async () => {
const res = makeRes();
await new OidcController(svc({
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({ sub: 'u1' }),
@@ -320,7 +360,7 @@ describe('OidcController /callback', () => {
it('accepts when userinfo omits sub (no cross-check to run)', async () => {
const res = makeRes();
await new OidcController(svc({
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' }),
@@ -331,7 +371,7 @@ describe('OidcController /callback', () => {
it('accepts when the id_token claims have a non-string sub (cross-check skipped)', async () => {
const res = makeRes();
await new OidcController(svc({
await ctl(svc({
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 12345 } }),
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 () => {
const res = makeRes();
await new OidcController(svc({
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' }),
@@ -354,7 +394,7 @@ describe('OidcController /callback', () => {
it('redirects server_error when the flow throws', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
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');
});
@@ -366,7 +406,7 @@ describe('OidcController /callback', () => {
const generateToken = vi.fn().mockReturnValue('jwt');
const createAuthCode = vi.fn().mockReturnValue({ code: 'ac', binding: 'bnd' });
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 }),
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
@@ -384,19 +424,19 @@ describe('OidcController /callback', () => {
describe('OidcController /exchange', () => {
it('400 without a code, 400 on an invalid code, else sets the cookie + returns the token', () => {
const r1 = makeRes();
new OidcController(svc()).exchange(undefined, reqEx(), r1);
ctl(svc()).exchange(undefined, reqEx(), r1);
expect(r1.statusCode).toBe(400);
expect(r1.body).toEqual({ error: 'Code required' });
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.body).toEqual({ error: 'invalid_code' });
const r3 = makeRes();
const setAuthCookie = vi.fn();
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(r3.body).toEqual({ token: 'jwt' });
});
@@ -406,7 +446,7 @@ describe('OidcController /exchange', () => {
const res = makeRes();
const setAuthCookie = vi.fn();
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(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', () => {
const consumeAuthCode = vi.fn().mockReturnValue({ token: 'jwt' });
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(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 res = makeRes();
// 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(res.statusCode).toBe(400);
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', () => {
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());
});
});
+230
View File
@@ -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 ──────────────────────────────────────────────────────
describe('exchangeCodeForToken', () => {
+30 -2
View File
@@ -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_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_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. |
## 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
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: