Skip to content

Commit 432c8ab

Browse files
fix(plugin-auth): mount the OIDC discovery routes before the socket opens, not after the auth instance builds (#21132)
Fixes #21078 Clause-②: no ## What changed `plugin-auth` mounts its OIDC discovery routes during the same synchronous call that registers its other routes. That call runs from the `kernel:ready` route hook, which comes before `kernel:listening` opens the socket. Each handler then gets the better-auth instance on each request. - **Before.** `registerAuthRoutes` started `registerOidcDiscoveryRoutes(...)` with `void` and did not wait for it. That method awaited `getAuthInstance()` before it called `rawApp.get(...)`, so the routes were added whenever the instance finished building. Sometimes that was after the server had already matched a first request. Hono freezes its matcher on the first match. The late `get` then threw `Can not add a route since the matcher is already built`, the error was caught and logged, and both doors answered 404 until the process restarted. - **After.** `registerOidcDiscoveryRoutes` is synchronous and every `rawApp.get` runs before it returns. If a route cannot be mounted, the error leaves the hook and fails the boot through the kernel's propagating `kernel:ready` dispatch. It is not caught. ### Config-level vs instance-level (zone 2, item 2) | Fact | Known | Where it is decided now | |---|---|---| | Discovery on at all (`resolveOidcProviderEnabled`) | config | at mount (unchanged) | | Issuer, plain-HTTP posture, both transport notices | config (`getAuthIssuer()`) | at mount | | RFC 8414 path-inserted alias (base path) | config | at mount | | RFC 9728 documents: env switch and `isMcpOAuthEnabled()` | config | at mount | | oauthProvider plugin degraded (`getDegradedAuthFeatures`) | instance | per request: `next()`, the answer the path gave when it was not mounted | | The instance the vendor document builders read | instance | per request, through `getAuthInstance()` (single-flight and memoised in `AuthManager`) | All of the mount decisions can be made from config, so the change needed no late `get` and there was no fork to report. ### Kept on purpose: one boot-time preparation call, which mounts nothing After the routes are mounted, the method calls the same instance-level resolution once and does not wait for it. The old await-then-mount code did three things at boot, and this call keeps them: 1. The better-auth instance is still built in the background at boot. It is not moved to the first request that needs it. That would put the vendor plugins' `init` writes inside the serving window, which is a separate change. 2. A degraded oauthProvider is still reported once at boot, at ERROR, naming the cause. 3. If the documents cannot be built from the instance, the failure is still reported at boot, at ERROR, under the same opening words: `Failed to register OIDC discovery routes`. `scripts/publish-smoke.sh` (SMOKE_BOOT_ERROR_PATTERN) matches that text by name in its boot window, so a pin holds those words as a grep anchor. Both outcomes are logged, and a request that hits the same failure gets the server's error response (500). The next request tries the build again. The old code answered 404 for the life of the process. ## Census: late route registration in `plugin-auth` (zone 2, item 3) | Site | Verdict | Evidence | |---|---|---| | `/.well-known/openid-configuration` | **fixed** | was `rawApp.get` after `await getAuthInstance()`; now synchronous | | `/.well-known/oauth-authorization-server` | **fixed** | same | | `/.well-known/oauth-authorization-server` plus the auth base path (RFC 8414 §3.1 alias) | **fixed** | same method, same shape | | `/.well-known/oauth-protected-resource` (RFC 9728) | **fixed** | same method, mounted after the await whenever the MCP OAuth track was live | | `/.well-known/oauth-protected-resource` plus the MCP path | **fixed** | same | | Every other route in `registerAuthRoutes` (IP-gate `use`, `config` / `bootstrap-status` GETs, the admin / SSO / organization / OAuth-application / verification POSTs, the `all` catch-all) | already compliant | `registerAuthRoutes` returns `void` and is synchronous. Every `await` in its body is inside a handler, never before a `rawApp.*` call. | | Other `void` dispatches in `auth-plugin.ts` (settings re-apply callbacks for branding, localization and auth settings) | out of scope: they register no route | they only re-read settings | | Other files in `plugin-auth/src` | none mount routes | `getRawApp`, `'http-server'` and `IHttpServer` appear only in `auth-plugin.ts` | ## Pins and ablations New file `src/oidc-discovery-sync-mount.test.ts` runs the real `registerAuthRoutes` on a real Hono app, with the real `@better-auth/oauth-provider` document builders over a fake `auth.api`. Only `AuthManager` is a stand-in, so the test controls when the instance resolves. | Test | HEAD | Ablation A: forbidden shape put back (mount after `await`, `void`, caught and logged) | Ablation B: the pre-fix `auth-plugin.ts` itself (BASE `53ed3d10`) | |---|---|---|---| | control: late but IDLE (instance resolves, then the first request) | pass | **pass** | **pass** | | a request that lands before the instance resolves does not freeze the doors out | pass | FAIL: `[openid-configuration, 404]` | FAIL: 404 | | a discovery request that arrives first waits, then 200 | pass | FAIL: `[404, 404]` | FAIL | | alias and RFC 9728 documents survive the early request | pass | FAIL | FAIL | | every discovery path is on the router when `registerAuthRoutes` returns | pass | FAIL: 5 paths missing | FAIL | | a route that cannot mount fails the hook (not caught and logged) | pass | FAIL: nothing thrown | FAIL | | degraded oauthProvider: doors answer byte-identical to the unmounted composition, reported once naming the cause | pass | pass | FAIL on the report only. The doors matched the unmounted composition on BASE too, so the wire answer is unchanged. | | instance cannot be built: smoke anchor logged once, 500, then 200 after recovery | pass | FAIL: 404 | FAIL | Ablation A went through `scripts/ablation-replace.mjs`: anchor 1 to 0, marker 0 to 1, blob `c11a3fa6` to `83ec14c2`, restored to blob == HEAD and `git diff HEAD` empty. Ablation B wrote BASE's blob `f2324f20` over the file under an `EXIT INT TERM` trap and restored to blob `c11a3fa6` == HEAD with `git diff HEAD` empty. The subject is imported from `src` by relative path, so neither leg needs a rebuild. Three route-only fixtures (`auth-catchall-fallthrough`, `auth-catchall-yield`, `admin-sso-bridge-gate`) now compose the plugin with `plugins: { oidcProvider: false }`. Their manager stand-ins carry only the catch-all and bridge seams. Before this change, the discovery mount failed asynchronously on every one of their runs and the error went into a logger mock. With the mount synchronous, they read `getAuthIssuer` from a stub that did not have it. ## Production-shaped boot measurement (the triage raise rule) `objectstack start` (NODE_ENV=production, empty kernel, default composition, local SQLite, fresh home) was booted through the real CLI dist in this container. The readiness probe was `GET /api/v1/health`, fired every 5 ms until the socket answered. Both doors were read 3 s later. The idle control waits 5 s after the socket opens before the first HTTP request. Times are ms since spawn, on a shared box. | Leg | async-or-sync mount line | HTTP server started | first request | doors | |---|---|---|---|---| | pre-fix dist, early 1 | 6292 | 6523 | 6545 (200) | 200 / 200 | | pre-fix dist, early 2 | 5806 | 6098 | 6120 (200) | 200 / 200 | | pre-fix dist, early 3 | 6079 | 6293 | 6312 (200) | 200 / 200 | | pre-fix dist, idle control | 5516 | 5713 | 10719 | 200 / 200 | | fixed dist, early | 5444 | 6064 | 6091 (200) | 200 / 200 | | fixed dist, idle control | 6173 | 6930 | 11937 | 200 / 200 | On this boot shape, the pre-fix async mount landed 197 to 292 ms **before** the socket opened in 3 of 3 runs, so the window was not hit. **The raise rule is not met by this measurement.** NOT MEASURED: the cloud control-plane composition where the card measured the 404, and any boot against a remote database, where the instance build is longer. The pre-fix dist was planted and verified with `ablation-dist-preflight` (marker present), then restored and rebuilt, and the preflight confirmed the marker absent. No boot in either leg logged an ERROR line. ## Local verification (HEAD `eb85e827`) - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 116 files, 2480 tests passed. - `pnpm --filter @objectstack/plugin-auth run typecheck`: exit 0. `check:test-typecheck` held at 10 files, 94 errors, 23 signatures. The new test file compiles clean. - Dependency closure: `turbo run build --filter='@objectstack/cli...'`, 59 tasks, exit 0. - Lint, narrowed and proven: eslint `--no-inline-config --format json` over the 5 touched TS files reported 5 files linted, 0 errors and 0 warnings. The population is every `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (`eslint.config.mjs`). This repo never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. - Derived gates (`dispatch-gates.mjs --commands`, 65 commands): all 65 run at `eb85e827` with exit codes recorded, and all 65 exit 0. `--ran` reconciliation: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) because nine unrelated packages had no `dist/`. Once their `dist/` was present it was re-run and exited 0 (105 entry points across 66 packages load). `check:auth-mount-ledger`: OK, 20 auth mounts all accounted for. `check:nul-bytes`: OK. ## Acceptance notes - `scripts/publish-smoke.sh` says plugin-auth logs the anchor line "when OIDC discovery route registration throws". After this change, a route that cannot be registered fails the boot instead, and the anchor line means the discovery documents could not be prepared from the auth instance. The pattern still matches and its gate still works. Only the comment is stale. Carrier: none. - `auth-manager.ts` (the `authBuild` docblock) and the `auth-manager-instance-single-flight.test.ts` header still name "`registerOidcDiscoveryRoutes()` with `void`" as the boot caller of `getAuthInstance()`. That caller is now the boot-time preparation call. The single-flight reasoning still holds. Those files are outside this card's file surface. Carrier: none. - In the degraded-oauthProvider case, the plain-HTTP transport notices are now emitted at mount, before the instance exists. On a public plain-HTTP origin, that notice says the documents are reachable. The degraded ERROR line that follows says they are not served, and it decides what clients get. #21078 is the only card this PR closes. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 399e3aa commit 432c8ab

6 files changed

Lines changed: 492 additions & 35 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the OIDC discovery documents answer on every boot, including one whose first request arrives before the auth instance is built
6+
7+
Clause-②: no
8+
9+
- `GET /.well-known/openid-configuration` and `GET /.well-known/oauth-authorization-server` used to be mounted only after the better-auth instance finished building, in the background. When the server answered any request before that (a readiness probe, for example), the router was already sealed. The late mount failed, the failure was logged, and both documents answered 404 until the process restarted. The RFC 8414 path-inserted alias and the two RFC 9728 protected-resource documents had the same problem.
10+
- All five routes are now mounted while the auth routes are registered, before the server opens its socket. Each request waits for the auth instance and then serves the document. A route that cannot be mounted now fails the boot instead of being logged and skipped.
11+
- When the OIDC provider plugin is degraded, these paths answer as they did before, as if they were not mounted. A boot-time error line still reports this.
12+
- If the auth instance cannot be built, a discovery request answers a server error, and the next request tries the build again. Before, these paths kept answering 404.

‎packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,14 @@ async function mountBridges(deps: {
9696
}) {
9797
const app = new Hono();
9898
const ctx = mockCtx();
99-
const plugin = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long!!' });
99+
// [#21078] Discovery off: the OIDC `.well-known` routes now mount
100+
// synchronously from `registerAuthRoutes` and read the manager while they
101+
// do, and this stand-in carries only the bridges' two seams. The discovery
102+
// mount has its own file (`oidc-discovery-sync-mount.test.ts`).
103+
const plugin = new AuthPlugin({
104+
secret: 'test-secret-at-least-32-chars-long!!',
105+
plugins: { oidcProvider: false },
106+
});
100107
await plugin.init(ctx);
101108
(plugin as any).authManager = {
102109
handleRequest: deps.handleRequest,

‎packages/plugins/plugin-auth/src/auth-catchall-fallthrough.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@ async function mountCatchAll(owned: Record<string, () => Response>) {
4848
getKernel: vi.fn(),
4949
} as any;
5050

51-
const plugin = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long!!' });
51+
// [#21078] Discovery off: the OIDC `.well-known` routes now mount
52+
// synchronously from `registerAuthRoutes` and read the manager while they
53+
// do, and this stand-in carries only the catch-all's two seams. The
54+
// discovery mount has its own file (`oidc-discovery-sync-mount.test.ts`).
55+
const plugin = new AuthPlugin({
56+
secret: 'test-secret-at-least-32-chars-long!!',
57+
plugins: { oidcProvider: false },
58+
});
5259
await plugin.init(ctx);
5360

5461
const handleRequest = vi.fn(async (req: Request) => {

‎packages/plugins/plugin-auth/src/auth-catchall-yield.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,14 @@ async function mountCatchAll(
8282
getKernel: vi.fn(),
8383
} as any;
8484

85-
const plugin = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long!!' });
85+
// [#21078] Discovery off: the OIDC `.well-known` routes now mount
86+
// synchronously from `registerAuthRoutes` and read the manager while they
87+
// do, and this stand-in carries only the catch-all's two seams. The
88+
// discovery mount has its own file (`oidc-discovery-sync-mount.test.ts`).
89+
const plugin = new AuthPlugin({
90+
secret: 'test-secret-at-least-32-chars-long!!',
91+
plugins: { oidcProvider: false },
92+
});
8693
await plugin.init(ctx);
8794

8895
const ownership = buildBetterAuthRouteOwnership(api as any);

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 140 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3225,11 +3225,16 @@ export class AuthPlugin implements Plugin {
32253225
// mount when an operator flipped the env var on without editing the
32263226
// config file, leaving external OIDC/MCP clients unable to discover
32273227
// the authorization server.
3228+
//
3229+
// [#21078] Called SYNCHRONOUSLY, like every route above: the discovery
3230+
// routes are on the router before this `kernel:ready` hook returns, so
3231+
// they land ahead of `kernel:listening` (the phase that opens the socket)
3232+
// and ahead of the first request. A throw here — a route that cannot
3233+
// mount — propagates out of the hook and fails the boot, the same as the
3234+
// routes above; ⛔ never `void` it, and ⛔ never catch-and-log it.
32283235
const oidcEnabled = resolveOidcProviderEnabled(this.options.plugins);
32293236
if (oidcEnabled) {
3230-
void this.registerOidcDiscoveryRoutes(rawApp, ctx).catch((error) => {
3231-
ctx.logger.error('Failed to register OIDC discovery routes', error as Error);
3232-
});
3237+
this.registerOidcDiscoveryRoutes(rawApp, ctx);
32333238
}
32343239

32353240
ctx.logger.info(`Auth routes registered: All requests under ${basePath}/* forwarded to better-auth`);
@@ -3240,27 +3245,52 @@ export class AuthPlugin implements Plugin {
32403245
* URL. Required by RFC 8414 §3 and OpenID Connect Discovery 1.0 §4 — the
32413246
* documents must live at `/.well-known/{oauth-authorization-server,openid-configuration}`
32423247
* relative to the issuer, not under the auth basePath.
3248+
*
3249+
* ## [#21078] Every route is mounted HERE, synchronously — never after an await
3250+
*
3251+
* Hono builds its route matcher on the first request it matches, and from
3252+
* then on `app.get(...)` throws `Can not add a route since the matcher is
3253+
* already built`. This method used to `await` the better-auth instance
3254+
* first and mount after it, and its caller dispatched it with `void`, so the
3255+
* mounts landed whenever that build finished — after `kernel:listening`
3256+
* whenever the build outlasted the rest of the boot. A first request inside
3257+
* that window (a readiness probe is the likely one) built the matcher, the
3258+
* late mount threw, the throw was caught and logged, and both discovery
3259+
* doors answered 404 for the life of the process.
3260+
*
3261+
* So the work is split by what is knowable WHEN:
3262+
*
3263+
* - CONFIG-level — known at mount, decided here: whether discovery is on
3264+
* at all (the caller's `resolveOidcProviderEnabled`), the issuer and its
3265+
* transport posture, the base path the RFC 8414 §3.1 alias inserts, and
3266+
* whether the RFC 9728 protected-resource documents go up (the MCP
3267+
* surface env switch and `isMcpOAuthEnabled()`, both configuration
3268+
* reads). Every route is put on the router before this returns.
3269+
* - INSTANCE-level — known only once better-auth is built: whether the
3270+
* oauthProvider plugin actually initialised (`getDegradedAuthFeatures`),
3271+
* and the instance the vendor's document builders read. Each handler
3272+
* resolves that per request, through `getAuthInstance()` — single-flight
3273+
* and memoised in `AuthManager`, so a request costs a cached promise, and
3274+
* a rebuild after a settings patch is served from the live instance
3275+
* rather than the one that happened to exist at boot.
3276+
*
3277+
* ⛔ No `rawApp.get` after an `await`, in this method or anywhere it calls.
32433278
*/
3244-
private async registerOidcDiscoveryRoutes(rawApp: any, ctx: PluginContext): Promise<void> {
3245-
const auth = await this.authManager!.getAuthInstance();
3279+
private registerOidcDiscoveryRoutes(rawApp: any, ctx: PluginContext): void {
3280+
const manager = this.authManager!;
32463281

32473282
// The oauthProvider plugin may have been skipped by the optional-plugin
32483283
// isolation in AuthManager.buildPluginList (a failing optional plugin no
32493284
// longer fails the whole instance — the 15.1.0 lesson). Advertising
32503285
// .well-known discovery documents for an IdP whose endpoints are not
3251-
// mounted would send every external client into 404s, so skip the mounts
3252-
// and say why. Core auth is already up at this point.
3253-
const degradedOidc = this.authManager!
3254-
.getDegradedAuthFeatures()
3255-
.find((d) => d.feature === 'oidcProvider');
3256-
if (degradedOidc) {
3257-
ctx.logger.error(
3258-
'OIDC provider is configured but its better-auth plugin failed to initialize; ' +
3259-
'skipping /.well-known discovery mounts. External SSO/MCP clients cannot use this ' +
3260-
`deployment as an IdP until the underlying error is fixed: ${degradedOidc.error}`,
3261-
);
3262-
return;
3263-
}
3286+
// mounted would send every external client into 404s. That is an
3287+
// INSTANCE-level fact, so it cannot decide whether a route is mounted
3288+
// (the routes are mounted before the instance exists); it decides how a
3289+
// mounted route ANSWERS — `next()`, which is exactly the answer the path
3290+
// gave when it was not mounted at all: whatever else matches, else the
3291+
// server's not-found response.
3292+
const degradedOidc = () =>
3293+
manager.getDegradedAuthFeatures().find((d) => d.feature === 'oidcProvider');
32643294

32653295
// ── Plain-HTTP OAuth notices (maintainer ruling 2026-09-21; how the
32663296
// ruled sentence is carried: ruling batch #210 item 5) ───────────
@@ -3302,7 +3332,14 @@ export class AuthPlugin implements Plugin {
33023332
// TLS. Both read off the PUBLISHED issuer — the authorization-server
33033333
// identity these documents are about — so each names the exact URL a
33043334
// client is sent to rather than a value only this method can see.
3305-
const authIssuer = this.authManager!.getAuthIssuer();
3335+
//
3336+
// [#21078] Both are CONFIG-level facts — the issuer is configuration — so
3337+
// they are emitted here, at mount, before the instance exists. They used
3338+
// to sit behind the degraded-oauthProvider early return; a deployment
3339+
// with that plugin degraded now also gets its own boot-time ERROR line
3340+
// below, which says the documents are NOT served, and that line is the
3341+
// one that decides what a client meets.
3342+
const authIssuer = manager.getAuthIssuer();
33063343
const servedOverPlainHttp = /^http:\/\//i.test(authIssuer);
33073344
if (servedOverPlainHttp && isOAuthEligibleBaseUrl(authIssuer)) {
33083345
ctx.logger.warn(
@@ -3327,12 +3364,26 @@ export class AuthPlugin implements Plugin {
33273364
);
33283365
}
33293366

3330-
const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import(
3331-
'@better-auth/oauth-provider'
3332-
);
3333-
3334-
const authServerHandler = oauthProviderAuthServerMetadata(auth as any);
3335-
const openidConfigHandler = oauthProviderOpenIdConfigMetadata(auth as any);
3367+
// INSTANCE-level: the vendor's document builders, bound to the live
3368+
// instance. `null` = the oauthProvider plugin is degraded on that
3369+
// instance, so the documents are not served (see `degradedOidc` above).
3370+
// A rejection (the instance cannot be built) is NOT caught here: a
3371+
// request meets it as the server's error response, and the next request
3372+
// retries the build — `getOrCreateAuth` drops a failed build.
3373+
type DocumentHandler = (req: Request) => Promise<Response>;
3374+
const resolveDiscoveryDocuments = async (): Promise<
3375+
{ authServer: DocumentHandler; openidConfig: DocumentHandler } | null
3376+
> => {
3377+
const auth = await manager.getAuthInstance();
3378+
if (degradedOidc()) return null;
3379+
const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import(
3380+
'@better-auth/oauth-provider'
3381+
);
3382+
return {
3383+
authServer: oauthProviderAuthServerMetadata(auth as any),
3384+
openidConfig: oauthProviderOpenIdConfigMetadata(auth as any),
3385+
};
3386+
};
33363387

33373388
// Cache-Control for OIDC discovery docs. These describe stable issuer
33383389
// configuration (endpoints, supported scopes, signing algs); they
@@ -3349,18 +3400,27 @@ export class AuthPlugin implements Plugin {
33493400
return resp;
33503401
};
33513402

3352-
rawApp.get('/.well-known/oauth-authorization-server', (c: any) => withDiscoveryCache(authServerHandler, c.req.raw));
3353-
rawApp.get('/.well-known/openid-configuration', (c: any) => withDiscoveryCache(openidConfigHandler, c.req.raw));
3403+
// One handler shape for every document route: resolve the instance-level
3404+
// half for THIS request, then serve — or hand on exactly as an unmounted
3405+
// path would when the provider is degraded.
3406+
const serveDocument =
3407+
(pick: (docs: { authServer: DocumentHandler; openidConfig: DocumentHandler }) => DocumentHandler) =>
3408+
async (c: any, next: () => Promise<void>) => {
3409+
const docs = await resolveDiscoveryDocuments();
3410+
if (!docs) return next();
3411+
return withDiscoveryCache(pick(docs), c.req.raw);
3412+
};
3413+
3414+
rawApp.get('/.well-known/oauth-authorization-server', serveDocument((d) => d.authServer));
3415+
rawApp.get('/.well-known/openid-configuration', serveDocument((d) => d.openidConfig));
33543416

33553417
// RFC 8414 §3.1 path-insertion variant. Our issuer identifier carries a
33563418
// path component (`<origin>/api/v1/auth`), so spec-conforming clients
33573419
// (including every MCP client bootstrapping from protected-resource
33583420
// metadata) request `/.well-known/oauth-authorization-server/api/v1/auth`
33593421
// — alias it to the same document.
33603422
const basePath = (this.options.basePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, '');
3361-
rawApp.get(`/.well-known/oauth-authorization-server${basePath}`, (c: any) =>
3362-
withDiscoveryCache(authServerHandler, c.req.raw),
3363-
);
3423+
rawApp.get(`/.well-known/oauth-authorization-server${basePath}`, serveDocument((d) => d.authServer));
33643424

33653425
// ── MCP protected-resource metadata (RFC 9728, #2698) ──────────────
33663426
// `/api/v1/mcp` is an OAuth 2.1 protected resource; its metadata points
@@ -3369,10 +3429,17 @@ export class AuthPlugin implements Plugin {
33693429
// transport rule satisfied — TLS, or plain HTTP on a loopback / private
33703430
// / link-local host): when it is off, nothing is advertised and the
33713431
// endpoint stays API-key-only, fail-closed.
3372-
const manager = this.authManager!;
3432+
//
3433+
// [#21078] Mounted on the CONFIG-level decision (the env switch and
3434+
// `isMcpOAuthEnabled()` are both configuration reads). The document points
3435+
// clients at the authorization server above, so it carries the same
3436+
// INSTANCE-level gate: a degraded oauthProvider hands the request on,
3437+
// exactly as the unmounted path used to answer.
33733438
if (readMcpServerEnabledEnv() && typeof manager.isMcpOAuthEnabled === 'function') {
33743439
if (manager.isMcpOAuthEnabled()) {
3375-
const prmHandler = () => {
3440+
const prmHandler = async (_c: unknown, next: () => Promise<void>) => {
3441+
await manager.getAuthInstance();
3442+
if (degradedOidc()) return next();
33763443
const body = JSON.stringify(manager.getMcpProtectedResourceMetadata());
33773444
return new Response(body, {
33783445
status: 200,
@@ -3399,5 +3466,46 @@ export class AuthPlugin implements Plugin {
33993466
ctx.logger.info(
34003467
'OIDC discovery endpoints mounted at /.well-known/{oauth-authorization-server,openid-configuration}',
34013468
);
3469+
3470+
// ── Boot-time preparation — mounts NOTHING ──────────────────────────
3471+
//
3472+
// Every route is already on the router above. This runs the SAME
3473+
// instance-level resolution a request runs, once, at boot, for the three
3474+
// things the old await-then-mount shape did at boot and that are kept:
3475+
//
3476+
// 1. the better-auth instance is built in the background at boot, not
3477+
// by whichever request first needs it — moving that build (and the
3478+
// vendor plugins' own `init` writes) into the serving window is a
3479+
// separate change, not this one;
3480+
// 2. a degraded oauthProvider is reported at boot, once, at ERROR;
3481+
// 3. documents that cannot be built from the instance are reported at
3482+
// boot, at ERROR, under the line's established opening words —
3483+
// `scripts/publish-smoke.sh` asserts them BY NAME in its boot window
3484+
// (SMOKE_BOOT_ERROR_PATTERN), so they are a grep anchor and are kept
3485+
// verbatim at the front of the line.
3486+
//
3487+
// Not awaited — nothing above depends on it and nothing here can add a
3488+
// route — and not swallowed: both outcomes are logged, and a request that
3489+
// meets the same failure gets the server's error response.
3490+
void resolveDiscoveryDocuments().then(
3491+
(docs) => {
3492+
const degraded = docs ? undefined : degradedOidc();
3493+
if (!degraded) return;
3494+
ctx.logger.error(
3495+
'OIDC provider is configured but its better-auth plugin failed to initialize, so the ' +
3496+
'/.well-known discovery documents are NOT served: those paths answer as if they were not ' +
3497+
'mounted. External SSO/MCP clients cannot use this deployment as an IdP until the ' +
3498+
`underlying error is fixed: ${degraded.error}`,
3499+
);
3500+
},
3501+
(error) => {
3502+
ctx.logger.error(
3503+
'Failed to register OIDC discovery routes: the /.well-known paths are mounted, but the auth ' +
3504+
'instance their documents are built from could not be prepared, so every discovery request ' +
3505+
'answers an error until it can be',
3506+
error as Error,
3507+
);
3508+
},
3509+
);
34023510
}
34033511
}

0 commit comments

Comments
 (0)