fix: disallow unverified outlook emails (#3256)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-06-12 18:52:24 -07:00
committed by GitHub
parent 4c03080efa
commit 00d6f9c230
4 changed files with 411 additions and 39 deletions
@@ -0,0 +1,214 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { OIDCService } from './OIDCService.js';
const TEST_ORIGIN = 'http://test.local';
const MS_CLIENT_ID = 'ms-client';
// Home tenant of personal Microsoft accounts — mirrors the constant in
// OIDCService.
const MSA_TENANT = '9188040d-6c67-4c5b-b112-36a304b66dad';
const ENTRA_TENANT = '3a8757eb-bf01-4b5d-83b2-90e0eaf21d10';
const KID = 'ms-key-1';
const MS_DISCOVERY_URL =
'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration';
const MS_JWKS_URI =
'https://login.microsoftonline.com/common/discovery/v2.0/keys';
const MS_USERINFO = 'https://graph.microsoft.com/oidc/userinfo';
let server: PuterServer;
let privateKey: crypto.KeyObject;
let jwk: Record<string, unknown>;
const fetchedUrls: string[] = [];
beforeAll(async () => {
server = await setupTestServer({
origin: TEST_ORIGIN,
oidc: {
providers: {
microsoft: {
client_id: MS_CLIENT_ID,
client_secret: 'ms-secret',
},
},
},
} as never);
const pair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
privateKey = pair.privateKey;
jwk = {
...(pair.publicKey.export({ format: 'jwk' }) as Record<
string,
unknown
>),
kid: KID,
};
// Serve Microsoft discovery + JWKS over a fake fetch. The Graph
// userinfo endpoint is deliberately NOT handled — Microsoft claims
// must come from the verified id_token, never from userinfo.
vi.stubGlobal('fetch', (async (input: unknown) => {
const url = String(input);
fetchedUrls.push(url);
if (url === MS_DISCOVERY_URL) {
return {
ok: true,
json: async () => ({
issuer: 'https://login.microsoftonline.com/{tenantid}/v2.0',
authorization_endpoint:
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
token_endpoint:
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userinfo_endpoint: MS_USERINFO,
jwks_uri: MS_JWKS_URI,
}),
} as Response;
}
if (url === MS_JWKS_URI) {
return {
ok: true,
json: async () => ({ keys: [jwk] }),
} as Response;
}
throw new Error(`unexpected fetch in test: ${url}`);
}) as typeof fetch);
});
afterAll(async () => {
vi.unstubAllGlobals();
await server?.shutdown();
});
const oidc = (): OIDCService =>
server.services.oidc as unknown as OIDCService;
const signMsIdToken = (
tid: string,
payload: Record<string, unknown> = {},
key: crypto.KeyObject = privateKey,
): string =>
jwt.sign({ tid, ...payload }, key, {
algorithm: 'RS256',
keyid: KID,
subject: 'ms-sub-1',
audience: MS_CLIENT_ID,
issuer: `https://login.microsoftonline.com/${tid}/v2.0`,
expiresIn: '5m',
});
describe('OIDCService.getUserInfo (microsoft)', () => {
it('reads claims from the verified id_token, never Graph userinfo', async () => {
const info = await oidc().getUserInfo(
'microsoft',
'access-token',
signMsIdToken(MSA_TENANT, { email: 'someone@outlook.com' }),
);
expect(info).toEqual({
sub: 'ms-sub-1',
email: 'someone@outlook.com',
email_verified: true,
});
expect(fetchedUrls).not.toContain(MS_USERINFO);
});
it('marks Entra emails verified only when xms_edov attests them', async () => {
const withEdov = await oidc().getUserInfo(
'microsoft',
'access-token',
signMsIdToken(ENTRA_TENANT, {
email: 'user@corp.example',
xms_edov: true,
}),
);
expect(withEdov?.email_verified).toBe(true);
const withoutEdov = await oidc().getUserInfo(
'microsoft',
'access-token',
signMsIdToken(ENTRA_TENANT, { email: 'user@corp.example' }),
);
expect(withoutEdov?.email_verified).toBe(false);
});
it('omits email (rather than inventing one) when the token has none', async () => {
const info = await oidc().getUserInfo(
'microsoft',
'access-token',
signMsIdToken(ENTRA_TENANT),
);
expect(info?.sub).toBe('ms-sub-1');
expect(info?.email).toBeUndefined();
});
it('returns null when no id_token is supplied', async () => {
const info = await oidc().getUserInfo('microsoft', 'access-token');
expect(info).toBeNull();
});
it('returns null for an id_token signed by an unknown key', async () => {
const otherKey = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
}).privateKey;
const forged = signMsIdToken(
MSA_TENANT,
{ email: 'victim@outlook.com' },
otherKey,
);
const info = await oidc().getUserInfo(
'microsoft',
'access-token',
forged,
);
expect(info).toBeNull();
});
});
describe('OIDCService.createUserFromOIDC', () => {
const req = { headers: {}, ip: '127.0.0.1', socket: {} } as never;
it('refuses to create an account when the provider returned no email', async () => {
const result = await runWithContext({ req }, () =>
oidc().createUserFromOIDC('microsoft', {
sub: 'no-email-sub',
email_verified: true,
}),
);
expect(result.success).toBe(false);
expect(result.error).toMatch(/email/i);
});
it('refuses when the provider explicitly reports the email unverified', async () => {
const result = await runWithContext({ req }, () =>
oidc().createUserFromOIDC('microsoft', {
sub: 'unverified-sub',
email: 'someone@corp.example',
email_verified: false,
}),
);
expect(result.success).toBe(false);
expect(result.error).toMatch(/verify/i);
});
});
+71 -37
View File
@@ -37,6 +37,10 @@ const MICROSOFT_DISCOVERY_URL_TEMPLATE =
const GOOGLE_SCOPES = 'openid email profile';
const APPLE_SCOPES = 'openid email';
const MICROSOFT_SCOPES = 'openid email profile';
// Home tenant of personal Microsoft accounts (outlook.com, hotmail, …).
// Microsoft verifies those emails itself; work/school (Entra) emails are
// admin-editable and only attested via the opt-in `xms_edov` claim.
const MICROSOFT_CONSUMER_TENANT = '9188040d-6c67-4c5b-b112-36a304b66dad';
const STATE_EXPIRY_SEC = 600; // 10 minutes
const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate'] as const;
const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes
@@ -300,8 +304,29 @@ export class OIDCService extends PuterService {
idToken?: string,
): Promise<OIDCUserInfo | null> {
const config = await this.getProviderConfig(providerId);
if (!config) return null;
if (config?.userinfo_endpoint) {
// Microsoft: Graph's userinfo endpoint omits `email` for many Entra
// accounts and never returns `email_verified`, so read claims from
// the verified id_token instead. The email only counts as verified
// for personal accounts (Microsoft verifies those itself) or when
// the `xms_edov` claim attests the email's domain belongs to the
// issuing tenant — an Entra admin can put any address in `mail`
// (nOAuth), so everything else is unverified.
if (providerId === 'microsoft') {
if (!idToken) return null;
const claims = await this.#verifyIdToken(idToken, config);
if (!claims) return null;
return {
sub: claims.sub,
email: claims.email,
email_verified:
claims.tid === MICROSOFT_CONSUMER_TENANT ||
claims.xms_edov === true,
};
}
if (config.userinfo_endpoint) {
const res = await fetch(config.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
@@ -311,7 +336,7 @@ export class OIDCService extends PuterService {
// No userinfo endpoint — verify the id_token signature against the
// provider's JWKS and read claims from it (e.g. Apple).
if (idToken && config) {
if (idToken) {
return this.#verifyIdToken(idToken, config);
}
@@ -407,6 +432,17 @@ export class OIDCService extends PuterService {
};
}
// No email, no account. Providers can legitimately omit the claim
// (e.g. Entra accounts with an empty `mail` attribute); refuse
// rather than mint an account we can never contact or recover.
const email = claims.email;
if (!email) {
return {
success: false,
error: 'Provider did not supply an email address.',
};
}
// Generate a unique username
let username: string;
let attempts = 0;
@@ -434,7 +470,7 @@ export class OIDCService extends PuterService {
// IdP already authenticated the user, so captcha listeners
// (e.g. Turnstile) should skip — abuse/IP/email checks still run.
source: 'oidc' as const,
data: { username, email: claims.email ?? '' },
data: { username, email },
ip:
(req?.headers?.['x-forwarded-for'] as string | undefined) ||
req?.connection?.remoteAddress ||
@@ -442,7 +478,7 @@ export class OIDCService extends PuterService {
req?.socket?.remoteAddress ||
null,
user_agent: req?.headers?.['user-agent'] ?? null,
email: claims.email ?? '',
email,
allow: true,
no_temp_user: false,
requires_email_confirmation: false,
@@ -466,46 +502,42 @@ export class OIDCService extends PuterService {
};
}
// Email validation — mirrors AuthController#validateEmail. Skipped
// when the IdP didn't return an email (createUserFromOIDC is
// reachable with `email` undefined).
if (claims.email) {
if (isBlockedEmail(claims.email, this.config.blockedEmailDomains)) {
return {
success: false,
error: 'This email is not allowed.',
};
}
const emailEvent = {
email: cleanEmail(claims.email),
allow: true,
message: null as string | null,
// Email validation — mirrors AuthController#validateEmail.
if (isBlockedEmail(email, this.config.blockedEmailDomains)) {
return {
success: false,
error: 'This email is not allowed.',
};
}
const emailEvent = {
email: cleanEmail(email),
allow: true,
message: null as string | null,
};
try {
await this.clients.event?.emitAndWait(
'email.validate',
emailEvent,
{},
);
} catch (e) {
console.warn('[oidc] email validate hook failed:', e);
}
if (!emailEvent.allow) {
return {
success: false,
error:
emailEvent.message ??
'This email cannot be used. Please try a different email address.',
};
try {
await this.clients.event?.emitAndWait(
'email.validate',
emailEvent,
{},
);
} catch (e) {
console.warn('[oidc] email validate hook failed:', e);
}
if (!emailEvent.allow) {
return {
success: false,
error:
emailEvent.message ??
'This email cannot be used. Please try a different email address.',
};
}
}
const created = await this.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: claims.email ?? null,
clean_email: claims.email ? cleanEmail(claims.email) : null,
email,
clean_email: cleanEmail(email),
free_storage: this.config.storage_capacity ?? null,
requires_email_confirmation: false,
audit_metadata: {
@@ -680,6 +712,8 @@ export class OIDCService extends PuterService {
sub: claims.sub,
email: claims.email,
email_verified: claims.email_verified,
tid: claims.tid,
xms_edov: claims.xms_edov,
};
}
}
+86 -1
View File
@@ -54,9 +54,10 @@ const signToken = (
audience?: string;
issuer?: string;
key?: crypto.KeyObject;
payload?: Record<string, unknown>;
} = {},
): string =>
jwt.sign({ email: 'a@b.com', email_verified: true }, overrides.key ?? privateKey, {
jwt.sign({ email: 'a@b.com', email_verified: true, ...overrides.payload }, overrides.key ?? privateKey, {
algorithm: 'RS256',
keyid: overrides.kid ?? KID,
subject: 'user-123',
@@ -210,3 +211,87 @@ describe('verifyOidcIdToken', () => {
expect(calls()).toBe(1);
});
});
// Multi-tenant Microsoft discovery returns the issuer as a template with a
// literal '{tenantid}' placeholder; the verifier substitutes the token's own
// `tid` claim so that `iss` and `tid` must agree.
describe('verifyOidcIdToken with a {tenantid} issuer template', () => {
const TEMPLATE_ISSUER = 'https://login.microsoftonline.com/{tenantid}/v2.0';
const TID = '3a8757eb-bf01-4b5d-83b2-90e0eaf21d10';
const tenantIssuer = `https://login.microsoftonline.com/${TID}/v2.0`;
const templateOpts = {
jwksUri: JWKS_URI,
issuer: TEMPLATE_ISSUER,
audience: AUDIENCE,
};
it('accepts a token whose iss matches its own tid, and passes tid/xms_edov through', async () => {
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({
issuer: tenantIssuer,
payload: { tid: TID, xms_edov: true },
}),
templateOpts,
deps(fetchImpl),
);
expect(claims).toEqual({
sub: 'user-123',
email: 'a@b.com',
email_verified: true,
tid: TID,
xms_edov: true,
});
});
it('rejects a token whose iss names a different tenant than its tid', async () => {
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({
issuer:
'https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0',
payload: { tid: TID },
}),
templateOpts,
deps(fetchImpl),
);
expect(claims).toBeNull();
});
it('rejects a token whose tid is not a UUID (no substitution into the issuer)', async () => {
const { fetchImpl, calls } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({
issuer: 'https://login.microsoftonline.com/evil/v2.0',
payload: { tid: 'evil' },
}),
templateOpts,
deps(fetchImpl),
);
expect(claims).toBeNull();
expect(calls()).toBe(0);
});
it('rejects a token with no tid claim at all', async () => {
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({ issuer: tenantIssuer }),
templateOpts,
deps(fetchImpl),
);
expect(claims).toBeNull();
});
it('normalizes a string-encoded xms_edov to boolean', async () => {
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({
issuer: tenantIssuer,
payload: { tid: TID, xms_edov: 'true' },
}),
templateOpts,
deps(fetchImpl),
);
expect(claims?.xms_edov).toBe(true);
});
});
+40 -1
View File
@@ -37,6 +37,14 @@ export interface IdTokenClaims {
sub: string;
email?: string;
email_verified?: boolean;
/** Microsoft: tenant id of the account's home tenant. */
tid?: string;
/**
* Microsoft: "email domain owner verified" true when the email's
* domain is a verified domain of the issuing tenant. Opt-in claim,
* configured on the Azure app registration.
*/
xms_edov?: boolean;
}
export interface VerifyIdTokenOptions {
@@ -149,6 +157,27 @@ export const verifyOidcIdToken = async (
: null;
if (!kid) return null;
// Multi-tenant Microsoft discovery returns the issuer as a template
// ('https://login.microsoftonline.com/{tenantid}/v2.0'). Substitute the
// token's own `tid` before verification — `jwt.verify` then enforces
// that `iss` agrees with `tid`, and the signature check pins both to
// the IdP.
let issuer = opts.issuer;
if (issuer?.includes('{tenantid}')) {
const unverified =
decoded && typeof decoded.payload === 'object'
? (decoded.payload as Record<string, unknown>)
: null;
const tid = unverified?.tid;
if (
typeof tid !== 'string' ||
!/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(tid)
) {
return null;
}
issuer = issuer.replace('{tenantid}', tid);
}
const pem = await getSigningKey(opts.jwksUri, kid, deps);
if (!pem) return null;
@@ -156,12 +185,22 @@ export const verifyOidcIdToken = async (
const payload = jwt.verify(idToken, pem, {
algorithms: ['RS256', 'ES256'],
audience: opts.audience,
issuer: opts.issuer,
issuer,
}) as Record<string, unknown>;
return {
sub: payload.sub as string,
email: payload.email as string | undefined,
email_verified: payload.email_verified as boolean | undefined,
tid: payload.tid as string | undefined,
// Documented as boolean; normalize string encodings defensively
// so a representation change can't silently flip accounts to
// unverified.
xms_edov:
payload.xms_edov === undefined
? undefined
: payload.xms_edov === true ||
payload.xms_edov === 'true' ||
payload.xms_edov === '1',
};
} catch (e) {
console.warn('[oidc] id_token verification failed', e);