You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 432c8ab
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
0 commit comments