From 9975f9f80f96560fae0d89b5002cdd02c15563e0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 17:06:58 +0200 Subject: [PATCH] fix(oauth): userinfo reports email_verified only for an address the account did not set itself The userinfo endpoint answered email_verified: true for every account, although nobody had confirmed the address. It now follows users.email_self_asserted, the same mark the SSO login uses before it links an account by email. --- .../db/repositories/OauthTokens.repository.ts | 2 ++ .../src/nest/oauth/oauth-public.controller.ts | 2 +- server/src/nest/oauth/oauth.service.ts | 3 +++ .../tests/unit/nest/oauth.controller.test.ts | 19 ++++++++++++++++++- server/tests/unit/nest/oauth.service.test.ts | 13 +++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/server/src/db/repositories/OauthTokens.repository.ts b/server/src/db/repositories/OauthTokens.repository.ts index 43a297b66..eac360f45 100644 --- a/server/src/db/repositories/OauthTokens.repository.ts +++ b/server/src/db/repositories/OauthTokens.repository.ts @@ -41,6 +41,7 @@ export interface OauthTokenWithUserRow { username: string; email: string; role: string; + email_self_asserted: number; } /** OA17 (`findChainRoot`'s upward-walk read). */ @@ -268,6 +269,7 @@ export class OauthTokensRepository extends TrekRepository { 'u.username', 'u.email', 'u.role', + 'u.email_self_asserted', ]) .where({ 'ot.access_token_hash': hash }) .execute('get', false); diff --git a/server/src/nest/oauth/oauth-public.controller.ts b/server/src/nest/oauth/oauth-public.controller.ts index e10f22520..0ec1b2c1f 100644 --- a/server/src/nest/oauth/oauth-public.controller.ts +++ b/server/src/nest/oauth/oauth-public.controller.ts @@ -220,7 +220,7 @@ export class OauthPublicController { res.json({ sub: String(info.user.id), email: info.user.email, - email_verified: true, + email_verified: info.emailVerified, preferred_username: info.user.username, }); } diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index f47413ea6..ef1a9148b 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -73,6 +73,8 @@ function expiriesAsIso(row: { access_token_expires_at: string; refresh_token_exp export interface OAuthTokenInfo { user: User; + /** False when the account typed its email in itself; nobody has confirmed that address. */ + emailVerified: boolean; scopes: string[]; clientId: string; audience: string | null; @@ -450,6 +452,7 @@ export class OauthService { return { user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' }, + emailVerified: !row.email_self_asserted, scopes: decodeJson(OAUTH_TOKEN_SCOPES, row.scopes, `client ${row.client_id} user ${row.user_id}`), clientId: row.client_id, audience: row.audience ?? null, diff --git a/server/tests/unit/nest/oauth.controller.test.ts b/server/tests/unit/nest/oauth.controller.test.ts index 005fae481..6c3e88036 100644 --- a/server/tests/unit/nest/oauth.controller.test.ts +++ b/server/tests/unit/nest/oauth.controller.test.ts @@ -428,12 +428,29 @@ describe('OauthPublicController /userinfo + /revoke', () => { expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"'); const r2 = makeRes(); await opc( - osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), + osvc({ + getUserByAccessToken: vi + .fn() + .mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' }, emailVerified: true }), + }), rl(), ).userinfo('Bearer tok', r2); expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' }); }); + it('userinfo: an email the account typed in itself is not reported as verified', async () => { + const res = makeRes(); + await opc( + osvc({ + getUserByAccessToken: vi + .fn() + .mockReturnValue({ user: { id: 2, email: 'x@y.z', username: 'v' }, emailVerified: false }), + }), + rl(), + ).userinfo('Bearer tok', res); + expect(res.body).toEqual({ sub: '2', email: 'x@y.z', email_verified: false, preferred_username: 'v' }); + }); + it('userinfo: 404 empty when MCP is disabled', async () => { const res = makeRes(); await opc(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).userinfo('Bearer tok', res); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index 36c50ebed..5f48d0e14 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -954,6 +954,19 @@ describe('getUserByAccessToken — includes clientId (C2)', () => { expect(info).not.toBeNull(); expect(info!.clientId).toBe(clientId); }); + + it('reports the email as verified unless the account typed it in itself', async () => { + const { user } = createUser(testDb); + const created = await makeClient(user.id); + const clientId = created.client!.client_id as string; + + const first = await issueTokens(clientId, user.id, ['trips:read']); + expect((await getUserByAccessToken(first.access_token))!.emailVerified).toBe(true); + + await updateRows(t, Users, { id: user.id }, { email_self_asserted: 1 }); + const second = await issueTokens(clientId, user.id, ['trips:read']); + expect((await getUserByAccessToken(second.access_token))!.emailVerified).toBe(false); + }); }); // ---------------------------------------------------------------------------