Skip to content

Commit 377162e

Browse files
committed
fix(auth): keep Microsoft sign-in accounts keyed by the ID token subject
Better Auth 1.7 keys Microsoft accounts by oid where every existing row was stored under sub. An existing user's account was no longer found, and sign-in fell back to email linking, which Microsoft passes only for verified domains: the rest landed on account_not_linked. A plugin keeps the provider on sub.
1 parent 4194b5b commit 377162e

3 files changed

Lines changed: 158 additions & 7 deletions

File tree

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ import {
163163
getMicrosoftRefreshTokenExpiry,
164164
isMicrosoftProvider,
165165
mapMicrosoftProfileToUser,
166+
microsoftAccountSubjectPlugin,
166167
} from '@/lib/oauth/microsoft'
167168
import {
168169
assertMicrosoftDataverseOAuthLinkRequest,
@@ -1409,6 +1410,7 @@ export const auth = betterAuth({
14091410
}),
14101411
]
14111412
: []),
1413+
microsoftAccountSubjectPlugin(),
14121414
admin(),
14131415
oneTimeToken({
14141416
/**
@@ -1483,13 +1485,10 @@ export const auth = betterAuth({
14831485
}
14841486
},
14851487
/**
1486-
* Without this, /sign-in/email-otp auto-registers any unknown email —
1487-
* bypassing the signup gate entirely (no captcha, no /sign-up path).
1488-
* Gated by the same DISABLE_EMAIL_SIGNUP flag as the signup form (and by
1489-
* DISABLE_REGISTRATION, whose /sign-up path check has the same blind
1490-
* spot); when set, better-auth also silently skips sending OTPs to
1491-
* unknown emails (enumeration-safe) while existing users keep OTP
1492-
* sign-in.
1488+
* Keeps the plugin from creating users under the same DISABLE_EMAIL_SIGNUP and
1489+
* DISABLE_REGISTRATION flags as the signup form (the auth route also refuses
1490+
* `sign-in/email-otp`). When set, better-auth silently skips sending OTPs to
1491+
* unknown emails (enumeration-safe).
14931492
*/
14941493
disableSignUp: isEmailSignupDisabled || isRegistrationDisabled,
14951494
sendVerificationOnSignUp: false,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { db } from '@sim/db'
2+
import { account, user } from '@sim/db/schema'
3+
import { emailMailerMock } from '@sim/testing/mocks/email-mailer.mock'
4+
import { emailTemplatesMock } from '@sim/testing/mocks/email-templates.mock'
5+
import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
6+
import { generateId } from '@sim/utils/id'
7+
import { eq } from 'drizzle-orm'
8+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
vi.hoisted(() => {
11+
process.env.MICROSOFT_CLIENT_ID = 'microsoft-sign-in-fixture-client'
12+
process.env.MICROSOFT_CLIENT_SECRET = 'microsoft-sign-in-fixture-secret'
13+
})
14+
15+
vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock)
16+
vi.mock('@/components/emails', () => emailTemplatesMock)
17+
vi.mock('@/lib/posthog/server', () => posthogServerMock)
18+
19+
const MICROSOFT_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
20+
const APP_ORIGIN = 'http://localhost:3000'
21+
22+
function unsignedJwt(claims: Record<string, unknown>) {
23+
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url')
24+
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(claims)}.`
25+
}
26+
27+
describe('Microsoft sign-in for an existing account', () => {
28+
let auth: typeof import('@/lib/auth/auth').auth
29+
let userId: string
30+
let email: string
31+
let subject: string
32+
let objectId: string
33+
34+
beforeAll(async () => {
35+
;({ auth } = await import('@/lib/auth/auth'))
36+
}, 90_000)
37+
38+
beforeEach(async () => {
39+
userId = generateId()
40+
email = `${userId}@microsoft-sign-in.test`
41+
subject = `sub-${generateId()}`
42+
objectId = generateId()
43+
const now = new Date()
44+
await db.insert(user).values({
45+
id: userId,
46+
name: 'Microsoft sign-in fixture',
47+
email,
48+
emailVerified: true,
49+
createdAt: now,
50+
updatedAt: now,
51+
})
52+
await db.insert(account).values({
53+
id: generateId(),
54+
accountId: subject,
55+
providerId: 'microsoft',
56+
userId,
57+
createdAt: now,
58+
updatedAt: now,
59+
})
60+
61+
const realFetch = globalThis.fetch
62+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
63+
const url = input instanceof Request ? input.url : String(input)
64+
if (url === MICROSOFT_TOKEN_ENDPOINT) {
65+
return Response.json({
66+
access_token: 'microsoft-fixture-access-token',
67+
token_type: 'Bearer',
68+
expires_in: 3600,
69+
scope: 'openid profile email',
70+
id_token: unsignedJwt({
71+
iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0',
72+
aud: process.env.MICROSOFT_CLIENT_ID,
73+
tid: '9188040d-6c67-4c5b-b112-36a304b66dad',
74+
sub: subject,
75+
oid: objectId,
76+
email,
77+
name: 'Microsoft sign-in fixture',
78+
}),
79+
})
80+
}
81+
if (url.startsWith('https://graph.microsoft.com/')) return new Response(null, { status: 404 })
82+
return realFetch(input, init)
83+
})
84+
})
85+
86+
afterEach(async () => {
87+
vi.restoreAllMocks()
88+
await db.delete(user).where(eq(user.id, userId))
89+
})
90+
91+
it('signs in through the account stored under the ID token subject without a duplicate link', async () => {
92+
const start = await auth.handler(
93+
new Request(`${APP_ORIGIN}/api/auth/sign-in/social`, {
94+
method: 'POST',
95+
headers: { 'content-type': 'application/json', origin: APP_ORIGIN },
96+
body: JSON.stringify({ provider: 'microsoft', callbackURL: '/workspace' }),
97+
})
98+
)
99+
expect(start.status).toBe(200)
100+
const { url } = (await start.json()) as { url: string }
101+
const state = new URL(url).searchParams.get('state')
102+
const stateCookies = start.headers
103+
.getSetCookie()
104+
.map((value) => value.split(';')[0])
105+
.join('; ')
106+
107+
const callback = await auth.handler(
108+
new Request(
109+
`${APP_ORIGIN}/api/auth/callback/microsoft?code=microsoft-fixture-code&state=${state}`,
110+
{ headers: { cookie: stateCookies } }
111+
)
112+
)
113+
114+
expect(callback.status).toBe(302)
115+
expect(callback.headers.get('location')).toMatch(/\/workspace$/)
116+
expect(callback.headers.getSetCookie().some((value) => value.includes('session_token='))).toBe(
117+
true
118+
)
119+
const accounts = await db
120+
.select({ accountId: account.accountId })
121+
.from(account)
122+
.where(eq(account.userId, userId))
123+
expect(accounts).toEqual([{ accountId: subject }])
124+
})
125+
})

‎apps/sim/lib/oauth/microsoft.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { createLogger } from '@sim/logger'
2+
import { toStringOrNull } from '@sim/utils/coerce'
23
import { generateId } from '@sim/utils/id'
4+
import { toRecord } from '@sim/utils/object'
5+
import type { BetterAuthPlugin } from 'better-auth'
36

47
/**
58
* Scoped `'Auth'` because these lines are emitted from the OAuth callback path
@@ -86,6 +89,30 @@ export function mapMicrosoftProfileToUser(
8689
return isMicrosoftEmailDomainVerified(profile) ? { emailVerified: true } : {}
8790
}
8891

92+
/**
93+
* Keeps Microsoft sign-in accounts keyed by the ID token's `sub`, the key every existing
94+
* `microsoft` account row was stored under. Better Auth's provider keys by `oid` instead, so
95+
* without this an existing user's account would not be found and sign-in would fall back to
96+
* email linking, which {@link mapMicrosoftProfileToUser} allows only for verified domains.
97+
*/
98+
export function microsoftAccountSubjectPlugin() {
99+
return {
100+
id: 'sim-microsoft-account-subject',
101+
init: (context) => ({
102+
context: {
103+
socialProviders: context.socialProviders.map((provider) =>
104+
provider.id === 'microsoft'
105+
? {
106+
...provider,
107+
accountSubject: ({ profile }) => toStringOrNull(toRecord(profile).sub) ?? '',
108+
}
109+
: provider
110+
),
111+
},
112+
}),
113+
} satisfies BetterAuthPlugin
114+
}
115+
89116
/**
90117
* Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me.
91118
* This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes.

0 commit comments

Comments
 (0)