Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
131 changes: 130 additions & 1 deletion src/server/oauth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,37 @@ import {
callbackUrl,
getAuthorizationSignature,
} from "./convexAuth.js";
import { decodeJwt } from "jose";

function formUrlEncode(token: string) {
return encodeURIComponent(token).replace(/%20/g, "+");
}

/**
* ConvexAuth: Detect `@auth/core`'s internal `conformInternal` symbol on a
* provider config without importing it. The symbol is marked `@internal` and
* is not exposed by the package's `exports` map, so any direct import from
* `@auth/core/lib/symbols.js` breaks under bundlers that enforce package
* exports (e.g. Vite during `vitest`).
*
* Providers that opt into core conformance logic (currently
* `microsoft-entra-id`, `azure-ad`, `apple`) set
* `[conformInternal]: true` using a `Symbol("conform-internal")` instance
* from `@auth/core`'s internal module. We identify that same symbol on the
* provider by description and read its value.
*/
function hasConformInternalSymbol(provider: object): boolean {
for (const sym of Object.getOwnPropertySymbols(provider)) {
if (
sym.description === "conform-internal" &&
(provider as Record<symbol, unknown>)[sym] === true
) {
return true;
}
}
return false;
}

/**
* Formats client_id and client_secret as an HTTP Basic Authentication header as per the OAuth 2.0
* specified in RFC6749.
Expand Down Expand Up @@ -52,7 +78,11 @@ export async function handleOAuth(
const { provider } = options;

// ConvexAuth: The `token` property is not used here
const { userinfo, as } = provider;
// ConvexAuth: `as` is `let` because some providers (e.g. Microsoft Entra ID
// multi-tenant) require re-running OIDC discovery after the token exchange
// using a claim from the ID token. See the `conformInternal` block below.
const { userinfo } = provider;
let { as } = provider;

const client: o.Client = {
client_id: provider.clientId,
Expand Down Expand Up @@ -151,6 +181,105 @@ export async function handleOAuth(

let profile: Profile = {};

// ConvexAuth: Ported from @auth/core's `handleOAuth`
// (packages/core/src/lib/actions/callback/oauth/callback.ts).
// Some providers (currently Microsoft Entra ID / Azure AD) need the
// authorization server metadata to be re-processed after the token exchange
// because the initial discovery document uses a placeholder issuer (e.g.
// `https://login.microsoftonline.com/{tenantid}/v2.0`) and the real tenant id
// is only known once we have the ID token. Without this, `validateIssuer`
// inside `processAuthorizationCodeResponse` rejects the ID token whenever the
// app is configured as multi-tenant (`/common`, `/organizations`, `/consumers`).
//
// `@auth/core` gates this on its internal `conformInternal` symbol, but that
// symbol is not exported from the package's public entry points (it lives in
// `@auth/core/lib/symbols.js`, which is not listed in the package's `exports`
// map). To avoid relying on a deep import that breaks bundlers / Vite, we
// detect the symbol structurally by description on the provider object.
if (hasConformInternalSymbol(provider)) {
switch (provider.id) {
case "microsoft-entra-id":
case "azure-ad": {
/**
* These providers return errors in the response body and
* need the authorization server metadata to be re-processed
* based on the `id_token`'s `tid` claim.
* @see: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#error-response-1
*/
const responseJson = await codeGrantResponse.clone().json();
if (responseJson.error) {
const cause = {
providerId: provider.id,
...responseJson,
};
logWithLevel("DEBUG", "OAuthCallbackError", cause);
throw new Error(
`OAuth Provider returned an error: ${responseJson.error}`,
{ cause },
);
}
const { tid } = decodeJwt(responseJson.id_token);
if (typeof tid === "string") {
// Match any URL path segment so we cover both authority names like
// `common`/`organizations`/`consumers` and hyphenated tenant GUIDs.
// Rewrite via the segment-anchored regex (not a substring replace)
// so we can't accidentally clobber an unrelated part of the URL.
const tenantSegmentRe = /microsoftonline\.com\/[^/]+\/v2\.0/;
const issuer = new URL(
as.issuer.replace(
tenantSegmentRe,
`microsoftonline.com/${tid}/v2.0`,
),
);
// ConvexAuth: Rewrite the `{tenantid}` placeholder Microsoft returns
// in the discovery document's `issuer` field using the real `tid`
// from the ID token. The provider's own `customFetch` hook (in
// `@auth/core@0.37.x`) derives the replacement from `config.issuer`
// captured at construction, so it can't see the per-tenant authority
// during this re-discovery. The upstream fix at
// https://github.com/nextauthjs/next-auth/pull/13440 derives the
// tenant from the request URL instead; once an `@auth/core` release
// including it is pinned, this wrapper can defer to
// `provider[customFetch]`.
const discoveryResponse = await o.discoveryRequest(issuer, {
[o.customFetch]: async (
...args: Parameters<typeof fetch>
) => {
const response = await fetch(...args);
const first = args[0];
const requestUrl =
typeof first === "string"
? first
: first instanceof URL
? first.toString()
: first.url;
if (
!requestUrl.endsWith(".well-known/openid-configuration")
) {
return response;
}
const json = await response.clone().json();
if (
typeof json?.issuer === "string" &&
json.issuer.includes("{tenantid}")
) {
return Response.json({
...json,
issuer: json.issuer.replace("{tenantid}", tid),
});
}
return response;
},
});
as = await o.processDiscoveryResponse(issuer, discoveryResponse);
}
break;
}
default:
break;
}
}

// ConvexAuth: We use the value of the nonce later, aside from feeding it into the
// `processAuthorizationCodeResponse` function.
const nonce = await checks.nonce.use(cookies, resCookies, options);
Expand Down
15 changes: 15 additions & 0 deletions test/convex/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import GitHub from "@auth/core/providers/github";
import Google from "@auth/core/providers/google";
import Resend from "@auth/core/providers/resend";
import Apple from "@auth/core/providers/apple";
import MicrosoftEntraID from "@auth/core/providers/microsoft-entra-id";
import { Anonymous } from "@convex-dev/auth/providers/Anonymous";
import { Password } from "@convex-dev/auth/providers/Password";
import { ConvexError } from "convex/values";
Expand Down Expand Up @@ -45,6 +46,20 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
},
profile: undefined,
}),
MicrosoftEntraID({
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
// Skip the profile-photo fetch in tests; the default `profile` callback
// calls `https://graph.microsoft.com/...` which our test fetch mocks
// don't expect.
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
};
},
}),
Resend({
from: process.env.AUTH_EMAIL ?? "My App <onboarding@resend.dev>",
}),
Expand Down
Loading