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.
This commit is contained in:
Maurice
2026-10-12 17:06:58 +02:00
parent 3d7e8be9aa
commit 9975f9f80f
5 changed files with 37 additions and 2 deletions
@@ -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<OauthTokens> {
'u.username',
'u.email',
'u.role',
'u.email_self_asserted',
])
.where({ 'ot.access_token_hash': hash })
.execute<OauthTokenWithUserRow | undefined>('get', false);
@@ -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,
});
}
+3
View File
@@ -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,
@@ -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);
@@ -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);
});
});
// ---------------------------------------------------------------------------