Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aad6a80
fix(policies): use deterministic template engine for individual polic…
tofikwest Jun 23, 2026
33d9157
fix(drata): handle non-array policy content in bulk pdf render
tofikwest Jun 23, 2026
6e27044
fix(auth): fall back to UPN/username when Microsoft omits the email c…
tofikwest Jun 23, 2026
8b9f957
Merge branch 'main' into tofik/fix-microsoft-oauth-email
tofikwest Jun 23, 2026
eed2287
chore: merge release v3.90.1 back to main [skip ci]
github-actions[bot] Jun 23, 2026
29ddd1a
fix(cloud-security): combine GCP direct-API checks with SCC, skip SCC…
tofikwest Jun 23, 2026
520665e
Merge branch 'main' into tofik/fix-gcp-cloud-tests-scc-fallback
tofikwest Jun 23, 2026
0182676
fix(cloud-security): honor disabled-service toggle in GCP direct-API …
tofikwest Jun 23, 2026
5684b97
feat(cloud-security): make GCP auto-fix first-class for direct-API fi…
tofikwest Jun 23, 2026
1d61b20
Merge pull request #3264 from trycompai/tofik/fix-gcp-cloud-tests-scc…
tofikwest Jun 23, 2026
97190c8
Merge branch 'main' into tofik/fix-microsoft-oauth-email
tofikwest Jun 23, 2026
d033218
Merge pull request #3262 from trycompai/tofik/fix-microsoft-oauth-email
tofikwest Jun 23, 2026
98021e1
Merge branch 'main' into tofik/policy-regeneration-produces-generic-out
tofikwest Jun 23, 2026
39af577
Merge branch 'main' into tofik/error-downloading-policy-pack-during
tofikwest Jun 23, 2026
61a2024
Merge pull request #3249 from trycompai/tofik/policy-regeneration-pro…
tofikwest Jun 23, 2026
eec673f
Merge branch 'main' into tofik/error-downloading-policy-pack-during
tofikwest Jun 23, 2026
a54483a
Merge pull request #3250 from trycompai/tofik/error-downloading-polic…
tofikwest Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { ac, allRoles } from '@trycompai/auth';
import { createAuthMiddleware } from 'better-auth/api';
import { Redis } from '@upstash/redis';
import type { AccessControl } from 'better-auth/plugins/access';
import {
resolveMicrosoftEmail,
type MicrosoftEmailClaims,
} from './microsoft-email';

const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour

Expand Down Expand Up @@ -184,6 +188,13 @@ if (
clientSecret: process.env.AUTH_MICROSOFT_CLIENT_SECRET,
tenantId: process.env.AUTH_MICROSOFT_TENANT_ID || 'common',
prompt: 'select_account',
// Microsoft Entra often omits the `email` claim for work/school accounts,
// which makes better-auth abort sign-in with `email_not_found`. Fall back to
// the username/UPN claims so these users can sign in. Accounts that DO return
// an `email` claim are unaffected. See ./microsoft-email.ts.
mapProfileToUser: (profile: MicrosoftEmailClaims) => ({
email: resolveMicrosoftEmail(profile),
}),
};
}

Expand Down
84 changes: 84 additions & 0 deletions apps/api/src/auth/microsoft-email.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { resolveMicrosoftEmail } from './microsoft-email';

describe('resolveMicrosoftEmail', () => {
it('returns the email claim unchanged when present (no regression for working accounts)', () => {
expect(
resolveMicrosoftEmail({
email: 'real@corp.com',
preferred_username: 'login@corp.onmicrosoft.com',
upn: 'upn@corp.com',
}),
).toBe('real@corp.com');
});

it('falls back to preferred_username when the email claim is missing', () => {
expect(
resolveMicrosoftEmail({
preferred_username: 'login@corp.com',
upn: 'upn@corp.com',
}),
).toBe('login@corp.com');
});

it('falls back to upn when email and preferred_username are missing', () => {
expect(resolveMicrosoftEmail({ upn: 'upn@corp.com' })).toBe('upn@corp.com');
});

it('returns undefined when no identifier is present (still fails loudly)', () => {
expect(resolveMicrosoftEmail({})).toBeUndefined();
});

it('treats empty / whitespace-only claims as missing and falls back', () => {
expect(
resolveMicrosoftEmail({ email: ' ', preferred_username: 'login@corp.com' }),
).toBe('login@corp.com');
});

it('handles null claims (Entra may send null) and falls back', () => {
expect(
resolveMicrosoftEmail({
email: null,
preferred_username: null,
upn: 'upn@corp.com',
}),
).toBe('upn@corp.com');
});

it('trims surrounding whitespace from the chosen value', () => {
expect(resolveMicrosoftEmail({ email: ' real@corp.com ' })).toBe(
'real@corp.com',
);
});

it('returns undefined when every claim is empty/whitespace', () => {
expect(
resolveMicrosoftEmail({ email: '', preferred_username: ' ', upn: '' }),
).toBeUndefined();
});

it('prefers preferred_username over upn when both are present (and email absent)', () => {
expect(
resolveMicrosoftEmail({ preferred_username: 'pref@corp.com', upn: 'upn@corp.com' }),
).toBe('pref@corp.com');
});

it('does not crash on a non-string claim (untrusted JWT) and falls back', () => {
// The decoded ID token is attacker-influenced; a non-string claim must not
// throw (e.g. .trim() on a number). Cast through unknown to simulate it.
const malformed = {
email: 12345,
preferred_username: { spoofed: true },
upn: 'upn@corp.com',
} as unknown as Parameters<typeof resolveMicrosoftEmail>[0];
expect(resolveMicrosoftEmail(malformed)).toBe('upn@corp.com');
});

it('returns undefined when all claims are present but none are usable strings', () => {
const malformed = {
email: 0,
preferred_username: null,
upn: false,
} as unknown as Parameters<typeof resolveMicrosoftEmail>[0];
expect(resolveMicrosoftEmail(malformed)).toBeUndefined();
});
});
51 changes: 51 additions & 0 deletions apps/api/src/auth/microsoft-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Resolve a usable email address for a Microsoft (Entra ID) sign-in.
*
* better-auth's built-in `microsoft` provider derives the user's email solely
* from the ID token's `email` claim (see `@better-auth/core`
* `social-providers/microsoft-entra-id`: `email: user.email`). Microsoft Entra
* only emits that claim when the account has a `mail` attribute set, or when
* `email` is configured as an optional claim in the app registration. Many
* work/school accounts therefore arrive with NO `email` claim — and better-auth
* then aborts the sign-in with `error=email_not_found`, bouncing the user to the
* API root (Swagger) instead of the app.
*
* We fall back to the username claims (`preferred_username`, then `upn`), which
* are the email-form login for Entra accounts. This mirrors better-auth's own
* `microsoftEntraId` generic-oauth helper (`profile.email ?? profile.preferred_username`).
*
* Safety:
* - When Microsoft DOES return an `email` claim, this returns it unchanged —
* accounts that already work are unaffected.
* - The input is a decoded, attacker-influenced JWT, so each claim is validated
* to actually be a non-empty string at runtime (a malformed token with a
* non-string claim must not crash sign-in).
* - Returns `undefined` only when no usable identifier is present, so a
* genuinely identifier-less account still fails loudly rather than signing in
* with an empty email.
*/
export interface MicrosoftEmailClaims {
email?: string | null;
preferred_username?: string | null;
upn?: string | null;
}

function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) return trimmed;
}
}
return undefined;
}

export function resolveMicrosoftEmail(
profile: MicrosoftEmailClaims,
): string | undefined {
return firstNonEmptyString(
profile.email,
profile.preferred_username,
profile.upn,
);
}
Loading
Loading