mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-09 00:31:53 +00:00
fix: disallow unverified outlook emails (#3256)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user