Skip to content

Commit e981e4d

Browse files
fix(providers): conform Entra ID when responding with the wrong issuer (#11980)
* update to OIDC * fix: modify the tenantId of the discovery document * pass all config * refactor * works, but ugly... * drop iss check * group symbols, refactor Entra ID * re-export Microsoft Entra ID as Azure AD * tweak B2C
1 parent e91073f commit e981e4d

File tree

12 files changed

+181
-323
lines changed

12 files changed

+181
-323
lines changed
Lines changed: 1 addition & 1 deletion
Loading

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import type { CredentialInput, Provider } from "./providers/index.js"
6767
import { JWT, JWTOptions } from "./jwt.js"
6868
import { isAuthAction } from "./lib/utils/actions.js"
6969

70-
export { customFetch } from "./lib/utils/custom-fetch.js"
70+
export { customFetch } from "./lib/symbols.js"
7171
export { skipCSRFCheck, raw, setEnvDefaults, createActionURL, isAuthAction }
7272

7373
export async function Auth(

packages/core/src/lib/actions/callback/oauth/callback.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import type {
1717
import { type OAuthConfigInternal } from "../../../../providers/index.js"
1818
import type { Cookie } from "../../../utils/cookie.js"
1919
import { isOIDCProvider } from "../../../utils/providers.js"
20-
import { fetchOpt } from "../../../utils/custom-fetch.js"
20+
import { conformInternal, customFetch } from "../../../symbols.js"
21+
import { decodeJwt } from "jose"
2122

2223
function formUrlEncode(token: string) {
2324
return encodeURIComponent(token).replace(/%20/g, "+")
@@ -61,27 +62,21 @@ export async function handleOAuth(
6162
// We assume that issuer is always defined as this has been asserted earlier
6263

6364
const issuer = new URL(provider.issuer!)
64-
// TODO: move away from allowing insecure HTTP requests
6565
const discoveryResponse = await o.discoveryRequest(issuer, {
66-
...fetchOpt(provider),
6766
[o.allowInsecureRequests]: true,
67+
[o.customFetch]: provider[customFetch],
6868
})
69-
const discoveredAs = await o.processDiscoveryResponse(
70-
issuer,
71-
discoveryResponse
72-
)
69+
as = await o.processDiscoveryResponse(issuer, discoveryResponse)
7370

74-
if (!discoveredAs.token_endpoint)
71+
if (!as.token_endpoint)
7572
throw new TypeError(
7673
"TODO: Authorization server did not provide a token endpoint."
7774
)
7875

79-
if (!discoveredAs.userinfo_endpoint)
76+
if (!as.userinfo_endpoint)
8077
throw new TypeError(
8178
"TODO: Authorization server did not provide a userinfo endpoint."
8279
)
83-
84-
as = discoveredAs
8580
} else {
8681
as = {
8782
issuer: provider.issuer ?? "https://authjs.dev", // TODO: review fallback issuer
@@ -172,7 +167,7 @@ export async function handleOAuth(
172167
if (!provider.checks.includes("pkce")) {
173168
args[1].body.delete("code_verifier")
174169
}
175-
return fetchOpt(provider)[o.customFetch](...args)
170+
return (provider[customFetch] ?? fetch)(...args)
176171
},
177172
}
178173
)
@@ -185,20 +180,50 @@ export async function handleOAuth(
185180

186181
let profile: Profile = {}
187182

188-
const isOidc = isOIDCProvider(provider)
183+
const requireIdToken = isOIDCProvider(provider)
184+
185+
if (provider[conformInternal]) {
186+
switch (provider.id) {
187+
case "microsoft-entra-id":
188+
case "azure-ad": {
189+
/**
190+
* These providers need the authorization server metadata to be re-processed
191+
* based on the `id_token`'s `tid` claim
192+
* @see https://github.com/MicrosoftDocs/azure-docs/issues/113944
193+
*/
194+
const { tid } = decodeJwt(
195+
(await codeGrantResponse.clone().json()).id_token
196+
)
197+
if (typeof tid === "string") {
198+
const tenantRe = /microsoftonline\.com\/(\w+)\/v2\.0/
199+
const tenantId = as.issuer?.match(tenantRe)?.[1] ?? "common"
200+
const issuer = new URL(as.issuer.replace(tenantId, tid))
201+
const discoveryResponse = await o.discoveryRequest(issuer, {
202+
[o.customFetch]: provider[customFetch],
203+
})
204+
as = await o.processDiscoveryResponse(issuer, discoveryResponse)
205+
}
206+
break
207+
}
208+
default:
209+
throw new TypeError(
210+
`Unrecognized provider conformation (${provider.id}).`
211+
)
212+
}
213+
}
189214
const processedCodeResponse = await o.processAuthorizationCodeResponse(
190215
as,
191216
client,
192217
codeGrantResponse,
193218
{
194219
expectedNonce: await checks.nonce.use(cookies, resCookies, options),
195-
requireIdToken: isOidc,
220+
requireIdToken,
196221
}
197222
)
198223

199224
const tokens: TokenSet & Pick<Account, "expires_at"> = processedCodeResponse
200225

201-
if (isOidc) {
226+
if (requireIdToken) {
202227
const idTokenClaims = o.getValidatedIdTokenClaims(processedCodeResponse)!
203228
profile = idTokenClaims
204229

@@ -208,7 +233,7 @@ export async function handleOAuth(
208233
client,
209234
processedCodeResponse.access_token,
210235
{
211-
...fetchOpt(provider),
236+
[o.customFetch]: provider[customFetch],
212237
// TODO: move away from allowing insecure HTTP requests
213238
[o.allowInsecureRequests]: true,
214239
}
@@ -230,7 +255,7 @@ export async function handleOAuth(
230255
as,
231256
client,
232257
processedCodeResponse.access_token,
233-
fetchOpt(provider)
258+
{ [o.customFetch]: provider[customFetch] }
234259
)
235260
profile = await userinfoResponse.json()
236261
} else {

packages/core/src/lib/actions/signin/authorization-url.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as o from "oauth4webapi"
33

44
import type { InternalOptions, RequestInternal } from "../../../types.js"
55
import type { Cookie } from "../../utils/cookie.js"
6-
import { fetchOpt } from "../../utils/custom-fetch.js"
6+
import { customFetch } from "../../symbols.js"
77

88
/**
99
* Generates an authorization/request token URL.
@@ -25,9 +25,9 @@ export async function getAuthorizationUrl(
2525
// We check this in assert.ts
2626

2727
const issuer = new URL(provider.issuer!)
28-
// TODO: move away from allowing insecure HTTP requests
2928
const discoveryResponse = await o.discoveryRequest(issuer, {
30-
...fetchOpt(options.provider),
29+
[o.customFetch]: provider[customFetch],
30+
// TODO: move away from allowing insecure HTTP requests
3131
[o.allowInsecureRequests]: true,
3232
})
3333
const as = await o.processDiscoveryResponse(issuer, discoveryResponse)

packages/core/src/lib/index.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { validateCSRF } from "./actions/callback/oauth/csrf-token.js"
77

88
import type { RequestInternal, ResponseInternal } from "../types.js"
99
import type { AuthConfig } from "../index.js"
10+
import { skipCSRFCheck } from "./symbols.js"
11+
12+
export { customFetch, raw, skipCSRFCheck } from "./symbols.js"
1013

1114
/** @internal */
1215
export async function AuthInternal(
@@ -92,25 +95,3 @@ export async function AuthInternal(
9295
}
9396
throw new UnknownAction(`Cannot handle action: ${action}`)
9497
}
95-
96-
/**
97-
* :::danger
98-
* This option is intended for framework authors.
99-
* :::
100-
*
101-
* Auth.js comes with built-in CSRF protection, but
102-
* if you are implementing a framework that is already protected against CSRF attacks, you can skip this check by
103-
* passing this value to {@link AuthConfig.skipCSRFCheck}.
104-
*/
105-
export const skipCSRFCheck = Symbol("skip-csrf-check")
106-
107-
/**
108-
* :::danger
109-
* This option is intended for framework authors.
110-
* :::
111-
*
112-
* Auth.js returns a web standard {@link Response} by default, but
113-
* if you are implementing a framework you might want to get access to the raw internal response
114-
* by passing this value to {@link AuthConfig.raw}.
115-
*/
116-
export const raw = Symbol("return-type-raw")

packages/core/src/lib/symbols.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* :::danger
3+
* This option is intended for framework authors.
4+
* :::
5+
*
6+
* Auth.js comes with built-in CSRF protection, but
7+
* if you are implementing a framework that is already protected against CSRF attacks, you can skip this check by
8+
* passing this value to {@link AuthConfig.skipCSRFCheck}.
9+
*/
10+
export const skipCSRFCheck = Symbol("skip-csrf-check")
11+
12+
/**
13+
* :::danger
14+
* This option is intended for framework authors.
15+
* :::
16+
*
17+
* Auth.js returns a web standard {@link Response} by default, but
18+
* if you are implementing a framework you might want to get access to the raw internal response
19+
* by passing this value to {@link AuthConfig.raw}.
20+
*/
21+
export const raw = Symbol("return-type-raw")
22+
23+
/**
24+
* :::danger
25+
* This option allows you to override the default `fetch` function used by the provider
26+
* to make requests to the provider's OAuth endpoints directly.
27+
* Used incorrectly, it can have security implications.
28+
* :::
29+
*
30+
* It can be used to support corporate proxies, custom fetch libraries, cache discovery endpoints,
31+
* add mocks for testing, logging, set custom headers/params for non-spec compliant providers, etc.
32+
*
33+
* @example
34+
* ```ts
35+
* import { Auth, customFetch } from "@auth/core"
36+
* import GitHub from "@auth/core/providers/github"
37+
*
38+
* const dispatcher = new ProxyAgent("my.proxy.server")
39+
* function proxy(...args: Parameters<typeof fetch>): ReturnType<typeof fetch> {
40+
* return undici(args[0], { ...(args[1] ?? {}), dispatcher })
41+
* }
42+
*
43+
* const response = await Auth(request, {
44+
* providers: [GitHub({ [customFetch]: proxy })]
45+
* })
46+
* ```
47+
*
48+
* @see https://undici.nodejs.org/#/docs/api/ProxyAgent?id=example-basic-proxy-request-with-local-agent-dispatcher
49+
* @see https://authjs.dev/guides/corporate-proxy
50+
*/
51+
export const customFetch = Symbol("custom-fetch")
52+
53+
/**
54+
* @internal
55+
*
56+
* Used to mark some providers for processing within the core library.
57+
*
58+
* **Do not use or you will be fired.**
59+
*/
60+
export const conformInternal = Symbol("conform-internal")

packages/core/src/lib/utils/custom-fetch.ts

Lines changed: 0 additions & 34 deletions
This file was deleted.

packages/core/src/lib/utils/providers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
} from "../../providers/index.js"
1111
import type { InternalProvider, Profile } from "../../types.js"
1212
import { type AuthConfig } from "../../index.js"
13-
import { customFetch } from "../utils/custom-fetch.js"
13+
import { customFetch } from "../symbols.js"
1414

1515
/**
1616
* Adds `signinUrl` and `callbackUrl` to each provider

packages/core/src/providers/azure-ad-b2c.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface AzureADB2CProfile {
2727
postalCode: string
2828
emails: string[]
2929
tfp: string
30+
preferred_username: string
3031
}
3132

3233
/**
@@ -103,21 +104,16 @@ export interface AzureADB2CProfile {
103104
* :::
104105
*/
105106
export default function AzureADB2C(
106-
options: OIDCUserConfig<AzureADB2CProfile> & {
107-
primaryUserFlow?: string
108-
tenantId?: string
109-
}
107+
options: OIDCUserConfig<AzureADB2CProfile>
110108
): OIDCConfig<AzureADB2CProfile> {
111-
const { tenantId, primaryUserFlow } = options
112-
options.issuer ??= `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${primaryUserFlow}/v2.0`
113109
return {
114110
id: "azure-ad-b2c",
115111
name: "Azure AD B2C",
116112
type: "oidc",
117113
profile(profile) {
118114
return {
119115
id: profile.sub,
120-
name: profile.name,
116+
name: profile.name ?? profile.preferred_username,
121117
email: profile?.emails?.[0],
122118
image: null,
123119
}

0 commit comments

Comments
 (0)