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);