feat(mcp/oauth): added RFC 8414 path-ful issuer form to OAuth discovery

- Extended `buildWellKnownUrls` and `#resolveRegistrationEndpoint` to try `/.well-known//` as a third candidate after origin-root and path-prefixed forms.
- Fixed single-segment path handling so `/my-service` is treated as the gateway prefix rather than dropped.
- Fixed missing `await` on `#tryWellKnownForRegistration` that caused path-prefixed fallback to return an unresolved Promise.
- Added tests for single-segment prefix discovery and RFC 8414 path-ful issuer fallback.
This commit is contained in:
can1357
2026-05-26 20:19:24 +02:00
parent ab74ef3cac
commit 7f27627a90
4 changed files with 102 additions and 11 deletions
+1 -5
View File
@@ -29,8 +29,6 @@
## [15.4.1] - 2026-05-26
### Breaking Changes
### Breaking Changes
- The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode
- Hashline payload semantics are now strictly inline-first: the first payload line is whatever follows the sigil on the op line itself, and subsequent lines append after it. A newline immediately after `↑`/`↓`/`:` is no longer a free separator — it produces a blank first payload line. Use `LINE↓content` for a one-line insert, `LINE↓firstline\nsecondline` for two lines; bare `LINE↓` / `LINE↑` / `LINE:` (no inline payload) still insert/replace with one blank line as before.
@@ -82,9 +80,6 @@
- Updated `parseMcpAuthServerUrl` and `extractMcpAuthServerUrl` to accept optional `serverUrl` for relative URL resolution ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
- Updated `MCPOAuthFlow.#resolveRegistrationEndpoint` to try origin-root well-known first, then fall back to path-prefixed well-known ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
### Fixed
- Fixed missing `await` on `#tryWellKnownForRegistration` call in `#resolveRegistrationEndpoint` that caused path-prefixed well-known fallback to never actually execute, returning the unresolved Promise object instead of the registration endpoint ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
### Removed
- Removed the `installH2Fetch()` activation from CLI startup; HTTPS fetches now use Bun's default transport
- Removed the `vim` edit mode along with the `VimTool` module, prompt, and supporting buffer/engine/renderer stack
@@ -97,6 +92,7 @@
### Fixed
- Fixed missing `await` on `#tryWellKnownForRegistration` call in `#resolveRegistrationEndpoint` that caused path-prefixed well-known fallback to never actually execute, returning the unresolved Promise object instead of the registration endpoint ([1407](https://github.com/can1357/oh-my-pi/pull/1407) by [@faizhasim](https://github.com/faizhasim))
- Fixed JavaScript module reloading to refresh local re-exports when transitive dependency files are edited
- Fixed Python tool calls in warm kernels to initialize once bridge environment variables appear after startup and to return a clear `tool bridge is unavailable` error when missing
- Fixed IRC `send` handling to preserve recipient incoming messages when auto-reply timeouts instead of dropping them
@@ -407,8 +407,31 @@ function buildWellKnownUrls(wellKnownPath: string, baseUrl: string): URL[] {
const normalizedPath = parsed.pathname.replace(/\/$/, "");
const lastSlash = normalizedPath.lastIndexOf("/");
if (lastSlash <= 0) return [absUrl];
// Bare origin (no path beyond "/") — only the origin-root candidate applies.
if (lastSlash < 0) return [absUrl];
const relUrl = new URL(wellKnownPath.slice(1), `${parsed.origin}${normalizedPath.slice(0, lastSlash)}/`);
return relUrl.href !== absUrl.href ? [absUrl, relUrl] : [absUrl];
// Path-prefixed well-known (common for gateways with sub-path routing).
// Multi-segment paths drop the trailing segment (typically the MCP endpoint);
// single-segment paths (lastSlash === 0) are themselves the gateway prefix.
const prefixPath = lastSlash === 0 ? normalizedPath : normalizedPath.slice(0, lastSlash);
const relUrl = new URL(wellKnownPath.slice(1), `${parsed.origin}${prefixPath}/`);
const candidates: URL[] = [absUrl];
const seen = new Set<string>([absUrl.href]);
const push = (u: URL): void => {
if (!seen.has(u.href)) {
candidates.push(u);
seen.add(u.href);
}
};
push(relUrl);
// RFC 8414 §3.1 path-ful issuer form: /.well-known/<suffix>/<issuer-path>.
// Only meaningful for well-known metadata documents.
if (wellKnownPath.startsWith("/.well-known/")) {
const pathfulUrl = new URL(`${wellKnownPath}${normalizedPath}`, parsed.origin);
push(pathfulUrl);
}
return candidates;
}
+15 -3
View File
@@ -334,13 +334,25 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
// path-prefixed well-known for gateways (e.g. https://gateway.example.com/my-service/).
const normalizedPath = authorizationUrl.pathname.replace(/\/$/, "");
const lastSlash = normalizedPath.lastIndexOf("/");
if (lastSlash <= 0) return null;
// Bare-origin authorization URL — nothing further to try.
if (lastSlash < 0) return null;
// Single-segment paths are the gateway prefix itself; multi-segment paths
// drop the trailing segment (typically a service endpoint).
const prefixPath = lastSlash === 0 ? normalizedPath : normalizedPath.slice(0, lastSlash);
const prefixedUrl = new URL(
".well-known/oauth-authorization-server",
`${authorizationUrl.origin}${normalizedPath.slice(0, lastSlash)}/`,
`${authorizationUrl.origin}${prefixPath}/`,
).toString();
return await this.#tryWellKnownForRegistration(prefixedUrl);
const prefixedEndpoint = await this.#tryWellKnownForRegistration(prefixedUrl);
if (prefixedEndpoint) return prefixedEndpoint;
// RFC 8414 §3.1 path-ful issuer form: /.well-known/oauth-authorization-server/<path>.
const pathfulUrl = new URL(
`/.well-known/oauth-authorization-server${normalizedPath}`,
authorizationUrl.origin,
).toString();
return await this.#tryWellKnownForRegistration(pathfulUrl);
}
async #tryWellKnownForRegistration(wellKnownUrl: string): Promise<string | null> {
@@ -88,6 +88,66 @@ describe("path-prefixed auth servers", () => {
expect(calls).toContain("https://gateway.example.com/my-service/.well-known/oauth-authorization-server");
});
it("discovers endpoints via single-segment path prefix (no trailing endpoint segment)", async () => {
const calls: string[] = [];
using _hook = hookFetch(input => {
const url = String(input);
calls.push(url);
if (url === "https://gateway.example.com/.well-known/oauth-authorization-server") {
return new Response("not found", { status: 404 });
}
if (url === "https://gateway.example.com/my-service/.well-known/oauth-authorization-server") {
return new Response(
JSON.stringify({
authorization_endpoint: "https://gateway.example.com/my-service/oauth/authorize",
token_endpoint: "https://gateway.example.com/my-service/oauth/token",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
});
const oauth = await discoverOAuthEndpoints("https://gateway.example.com/my-service");
expect(oauth).toEqual({
authorizationUrl: "https://gateway.example.com/my-service/oauth/authorize",
tokenUrl: "https://gateway.example.com/my-service/oauth/token",
});
expect(calls[0]).toBe("https://gateway.example.com/.well-known/oauth-authorization-server");
expect(calls).toContain("https://gateway.example.com/my-service/.well-known/oauth-authorization-server");
});
it("falls back to RFC 8414 path-ful issuer form (/.well-known/oauth-authorization-server/<path>)", async () => {
const calls: string[] = [];
using _hook = hookFetch(input => {
const url = String(input);
calls.push(url);
if (url === "https://gateway.example.com/.well-known/oauth-authorization-server/my-service") {
return new Response(
JSON.stringify({
authorization_endpoint: "https://gateway.example.com/my-service/oauth",
token_endpoint: "https://gateway.example.com/my-service/token",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
});
const oauth = await discoverOAuthEndpoints("https://gateway.example.com/my-service");
expect(oauth).toEqual({
authorizationUrl: "https://gateway.example.com/my-service/oauth",
tokenUrl: "https://gateway.example.com/my-service/token",
});
expect(calls).toContain("https://gateway.example.com/.well-known/oauth-authorization-server/my-service");
});
it("prefers absolute well-known when it succeeds (origin-root servers still work)", async () => {
const calls: string[] = [];
using _hook = hookFetch(input => {