diff --git a/src/backend/services/auth/OIDCService.test.ts b/src/backend/services/auth/OIDCService.test.ts new file mode 100644 index 000000000..41569c175 --- /dev/null +++ b/src/backend/services/auth/OIDCService.test.ts @@ -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 . + */ + +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; +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 = {}, + 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); + }); +}); diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index ccc635090..59603ff39 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -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 { 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, }; } } diff --git a/src/backend/services/auth/oidcIdToken.test.ts b/src/backend/services/auth/oidcIdToken.test.ts index 6752013c6..55c0b0b28 100644 --- a/src/backend/services/auth/oidcIdToken.test.ts +++ b/src/backend/services/auth/oidcIdToken.test.ts @@ -54,9 +54,10 @@ const signToken = ( audience?: string; issuer?: string; key?: crypto.KeyObject; + payload?: Record; } = {}, ): 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); + }); +}); diff --git a/src/backend/services/auth/oidcIdToken.ts b/src/backend/services/auth/oidcIdToken.ts index 69a47ae36..154228e81 100644 --- a/src/backend/services/auth/oidcIdToken.ts +++ b/src/backend/services/auth/oidcIdToken.ts @@ -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) + : 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; 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);