From 3891699718d92a7c432b3be564b90326e6bc97ce Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 04:25:55 +0100 Subject: [PATCH 01/10] feat: add basePath option --- packages/openauth/src/issuer.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index f4c1f277..0a161514 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -202,6 +202,7 @@ import { DynamoStorage } from "./storage/dynamo.js" import { MemoryStorage } from "./storage/memory.js" import { cors } from "hono/cors" import { logger } from "hono/logger" +import { createMiddleware } from "hono/factory" /** @internal */ export const aws = awsHandle @@ -217,6 +218,21 @@ export interface IssuerInput< > }[keyof Providers], > { + /** + * The base path of the issuer. This is used to generate URLs. + * Useful if the issuer is not at root url, e.g. behind a reverse proxy. + * + * **Caution: the `/.well-know/` routes still need to be at the root.** + * Please specify this rewrite in your reverse proxy. + * + * @example + * ```ts + * { + * basePath: "/auth" + * } + * ``` + */ + basePath?: string /** * The shape of the subjects that you want to return. * @@ -731,6 +747,29 @@ export function issuer< } }>().use(logger()) + // Only edit local redirects if baseP + if (input.basePath) { + app.use( + createMiddleware(async (c, next) => { + await next() + + if (input.basePath) { + // Normalize the basePath (remove leading/trailing slashes) + const bp = input.basePath.replace(/^\/+|\/+$/g, "") + + // Check if the response is a redirect + const loc = c.res.headers.get("Location") + if (loc && loc.startsWith("/")) { + // Prepend /{bp} to the local location (ensure a leading slash) + const newLoc = `/${bp}${loc}` + c.res.headers.set("Location", newLoc) + } + } + return c.res + }), + ) + } + for (const [name, value] of Object.entries(input.providers)) { const route = new Hono() route.use(async (c, next) => { From efe58de39887a4a1892a0740c9530f78fe2b5ac3 Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 04:54:45 +0100 Subject: [PATCH 02/10] feat: add tests --- packages/openauth/test/issuer.test.ts | 133 ++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index be303d77..85864205 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -391,3 +391,136 @@ describe("user info", () => { expect(userinfo).toStrictEqual({ userID: "123" }) }) }) + +describe("code flow with basePath", () => { + const basePath = "/superbasepath" + const issuerConfigWithBasePath = { + basePath, + storage, + subjects, + allow: async () => true, + ttl: { + access: 60, + refresh: 6000, + refreshReuse: 60, + refreshRetention: 6000, + }, + providers: { + dummy: { + type: "dummy", + init(route, ctx) { + route.get("/authorize", async (c) => { + return ctx.success(c, { + email: "foo@bar.com", + }) + }) + }, + client: async ({ clientID, clientSecret }) => { + if (clientID !== "myuser" && clientSecret !== "mypass") { + throw new Error("Wrong credentials") + } + return { + email: "foo@bar.com", + } + }, + } satisfies Provider<{ email: string }>, + }, + success: async (ctx, value) => { + if (value.provider === "dummy") { + return ctx.subject("user", { + userID: "123", + }) + } + throw new Error("Invalid provider: " + value.provider) + }, + } + const authWithBasePath = issuer(issuerConfigWithBasePath) + + // Helper function to strip the basePath from a URL path + function getInternalPath(url: string, basePathToRemove: string): string { + const parsedPath = url + if (parsedPath.startsWith(basePathToRemove)) { + const internal = parsedPath.substring(basePathToRemove.length) + // Ensure the path starts with a slash if it's not empty + return internal.startsWith("/") || internal === "" + ? internal + : "/" + internal + } + return parsedPath // Return original path if basePath not found + } + + test("success with basePath", async () => { + const client = createClient({ + // Client still uses the public issuer URL (without basePath internally) + issuer: "https://auth.example.com", + clientID: "123", + // The fetch function uses the issuer instance directly + fetch: (a, b) => Promise.resolve(authWithBasePath.request(a, b)), + }) + const redirectUri = "https://client.example.com/callback" + const { challenge, url } = await client.authorize(redirectUri, "code", { + pkce: true, + }) + + // The initial authorize URL generated by the client should contain the basePath + // because the client constructs it based on the issuer URL and standard endpoints. + // Note: client.authorize might need adjustment if it doesn't automatically add /authorize + // For this test, we assume `url` correctly points to `https://.../auth/authorize` + expect(new URL(url).pathname).toBe(`/authorize`) + + // Make the first request to the authorize endpoint (with basePath) + let response = await authWithBasePath.request(url) + expect(response.status).toBe(302) // Redirects to provider's authorize + + // The redirect location header will have the basePath added by the middleware + let redirectLocation = response.headers.get("location")! + expect(redirectLocation).toContain(basePath) // e.g., /auth/dummy/authorize + + // Simulate the reverse proxy: determine the internal path Hono expects + // by stripping the basePath from the redirectLocation's path. + const internalPath = getInternalPath(redirectLocation, basePath) // Should be /dummy/authorize + + // Make the next request using the *internal* path (without basePath) + response = await authWithBasePath.request(internalPath, { + headers: { + cookie: response.headers.get("set-cookie")!, + }, + }) + // This request should now hit the correct route '/dummy/authorize' inside Hono + expect(response.status).toBe(302) // Redirects back to client with code + + // The final redirect back to the client should NOT have the basePath added, + // as it's an external URL. + const finalRedirectUrl = response.headers.get("location")! + expect(finalRedirectUrl.startsWith(redirectUri)).toBeTrue() + expect(finalRedirectUrl).not.toContain(basePath) // Verify basePath isn't added to external redirects + + const location = new URL(finalRedirectUrl) + const code = location.searchParams.get("code") + expect(code).not.toBeNull() + + // Exchange the code - this hits the /token endpoint (no basePath needed for internal request) + const exchanged = await client.exchange( + code!, + redirectUri, + challenge.verifier, + ) + if (exchanged.err) throw exchanged.err + const tokens = exchanged.tokens + expect(tokens).toStrictEqual({ + access: expectNonEmptyString, + refresh: expectNonEmptyString, + expiresIn: 60, + }) + + // Verify the token + const verified = await client.verify(subjects, tokens.access) + if (verified.err) throw verified.err + expect(verified.subject).toStrictEqual({ + type: "user", + properties: { + userID: "123", + }, + }) + }) +}) From dbf1a674dad7f63087be97e898b76b4cfddc8435 Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 05:14:36 +0100 Subject: [PATCH 03/10] chore: write docs --- packages/openauth/src/issuer.ts | 25 +++++++++++++++-------- www/src/content/docs/docs/issuer.mdx | 30 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index 0a161514..6b41d974 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -219,17 +219,26 @@ export interface IssuerInput< }[keyof Providers], > { /** - * The base path of the issuer. This is used to generate URLs. - * Useful if the issuer is not at root url, e.g. behind a reverse proxy. + * With `basePath`, OpenAuth can be mounted on any sub-path of a domain. * - * **Caution: the `/.well-know/` routes still need to be at the root.** - * Please specify this rewrite in your reverse proxy. + * :::caution + * The `/.well-known` path still needs to be at the root path. + * Please rewrite from your reverse proxy to OpenAuth. + * ::: * * @example - * ```ts - * { - * basePath: "/auth" - * } + * ```ts title="issuer.ts" + * issuer({ + * basePath: "/auth", + * }) + * ``` + * + * The base path needs to be reflected in the issuer url for the client: + * ```ts title="client.ts" + * const client = createClient({ + * issuer: "https://example.com/authpath", // if OpenAuth is mounted at `/authpath` + * clientID: "123", + * }) * ``` */ basePath?: string diff --git a/www/src/content/docs/docs/issuer.mdx b/www/src/content/docs/docs/issuer.mdx index a9674b03..537948c8 100644 --- a/www/src/content/docs/docs/issuer.mdx +++ b/www/src/content/docs/docs/issuer.mdx @@ -153,6 +153,7 @@ Create an OpenAuth server, a Hono app. ## IssuerInput
+-

[basePath?](#issuerinput.basepath) string

-

[providers](#issuerinput.providers) Record<string, Provider>

-

[storage?](#issuerinput.storage) StorageAdapter

-

[subjects](#issuerinput.subjects) [SubjectSchema](/docs/subject#subjectschema)

@@ -167,6 +168,35 @@ Create an OpenAuth server, a Hono app. -

[success](#issuerinput.success) (response: [OnSuccessResponder](#onsuccessresponder), input: Result, req: Request) => Promise<Response>

+basePath? + +
+ +**Type** string + +
+With `basePath`, OpenAuth can be mounted on any sub-path of a domain. + +:::caution +The `/.well-known` path still needs to be at the root path. +Please rewrite from your reverse proxy to OpenAuth. +::: +```ts title="issuer.ts" +issuer({ + basePath: "/auth", +}) +``` + + +The base path needs to be reflected in the issuer url for the client: + +```ts title="client.ts" +const client = createClient({ + issuer: "https://example.com/authpath", // if OpenAuth is mounted at `/authpath` + clientID: "123", +}) +``` +
providers
From 10f60c501726ad8ee1b7f2b80df9ab491b93190d Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 05:20:44 +0100 Subject: [PATCH 04/10] Allow running from sub-paths --- .changeset/fresh-parents-talk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-parents-talk.md diff --git a/.changeset/fresh-parents-talk.md b/.changeset/fresh-parents-talk.md new file mode 100644 index 00000000..002526e3 --- /dev/null +++ b/.changeset/fresh-parents-talk.md @@ -0,0 +1,5 @@ +--- +"@openauthjs/openauth": minor +--- + +Allow running from sub-paths From 35bcd42845871319c11014ef5287e16b274164eb Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 13:19:53 +0100 Subject: [PATCH 05/10] feat: use basePath in Select UI --- bun.lockb | Bin 255216 -> 255216 bytes packages/openauth/src/issuer.ts | 12 +++++++++--- packages/openauth/src/ui/select.tsx | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/bun.lockb b/bun.lockb index 7f4b865820733fdfc374b2177bd115d17dacfb6e..25c80f8d91fa07716b7b8ad1233502ef9a212c6e 100755 GIT binary patch delta 217 zcmexxlK;a={tXE(jAD}mHS{M3xNvalGchnoF)%bBL>Pf0@+cx#85tPl;35JYoE$&_ zL9l@0DvQlCUEP@{FYqv!JYhQLW=T(W14ib_3k}RSM^tifPgZEL;C#pcv4e5)LrwF| zH(ETHCqHPnV66cPi%q`UZp~>AWJv6m6rt(g0MswYpiR6FiZ^$aWb=Uz00CHtJ_NE+LUb}vHQcNM#?7Z% z!kFtVOm}=}VF4;tN(9qTLqG=SE8x<An>, Subjects extends SubjectSchema, @@ -477,6 +482,7 @@ export function issuer< > }[keyof Providers], >(input: IssuerInput) { + basePath = input.basePath const error = input.error ?? function (err) { @@ -757,14 +763,14 @@ export function issuer< }>().use(logger()) // Only edit local redirects if baseP - if (input.basePath) { + if (basePath) { app.use( createMiddleware(async (c, next) => { await next() - if (input.basePath) { + if (basePath) { // Normalize the basePath (remove leading/trailing slashes) - const bp = input.basePath.replace(/^\/+|\/+$/g, "") + const bp = basePath.replace(/^\/+|\/+$/g, "") // Check if the response is a redirect const loc = c.res.headers.get("Location") diff --git a/packages/openauth/src/ui/select.tsx b/packages/openauth/src/ui/select.tsx index 159b6132..93882755 100644 --- a/packages/openauth/src/ui/select.tsx +++ b/packages/openauth/src/ui/select.tsx @@ -24,6 +24,7 @@ */ /** @jsxImportSource hono/jsx */ +import { basePath } from "../issuer.js" import { Layout } from "./base.js" import { ICON_GITHUB, ICON_GOOGLE } from "./icon.js" @@ -73,7 +74,7 @@ export function Select(props?: SelectProps) { const icon = ICON[key] return ( From f6deb377f7c166fd147263804175a6454610dbf5 Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 21:54:21 +0100 Subject: [PATCH 06/10] fix: add `basePath` to issuer --- packages/openauth/src/issuer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index 461687c2..1841398c 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -753,7 +753,10 @@ export function issuer< } function issuer(ctx: Context) { - return new URL(getRelativeUrl(ctx, "/")).origin + const host = new URL(getRelativeUrl(ctx, "/")).origin + const url = new URL(host) + url.pathname = basePath ?? "/" + return url.toString() } const app = new Hono<{ From 9b8e076a8b203a995d4962947a67939683390277 Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 22:33:33 +0100 Subject: [PATCH 07/10] chore: update docs --- packages/openauth/src/issuer.ts | 9 +++------ www/src/content/docs/docs/issuer.mdx | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index 1841398c..78d3b01f 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -225,23 +225,20 @@ export interface IssuerInput< > { /** * With `basePath`, OpenAuth can be mounted on any sub-path of a domain. - * - * :::caution - * The `/.well-known` path still needs to be at the root path. - * Please rewrite from your reverse proxy to OpenAuth. - * ::: + * This means OpenAuth can be nested in a larger app. * * @example * ```ts title="issuer.ts" * issuer({ * basePath: "/auth", + * // ... * }) * ``` * * The base path needs to be reflected in the issuer url for the client: * ```ts title="client.ts" * const client = createClient({ - * issuer: "https://example.com/authpath", // if OpenAuth is mounted at `/authpath` + * issuer: "https://example.com/auth", // if OpenAuth is mounted at `/authpath` * clientID: "123", * }) * ``` diff --git a/www/src/content/docs/docs/issuer.mdx b/www/src/content/docs/docs/issuer.mdx index 537948c8..5bb5980d 100644 --- a/www/src/content/docs/docs/issuer.mdx +++ b/www/src/content/docs/docs/issuer.mdx @@ -176,14 +176,11 @@ Create an OpenAuth server, a Hono app.
With `basePath`, OpenAuth can be mounted on any sub-path of a domain. - -:::caution -The `/.well-known` path still needs to be at the root path. -Please rewrite from your reverse proxy to OpenAuth. -::: +This means OpenAuth can be nested in a larger app. ```ts title="issuer.ts" issuer({ basePath: "/auth", + // ... }) ``` @@ -192,7 +189,7 @@ The base path needs to be reflected in the issuer url for the client: ```ts title="client.ts" const client = createClient({ - issuer: "https://example.com/authpath", // if OpenAuth is mounted at `/authpath` + issuer: "https://example.com/auth", // if OpenAuth is mounted at `/authpath` clientID: "123", }) ``` From a60f61b3fc2ae32d4c023de20d0cde4af9158951 Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 23:07:02 +0100 Subject: [PATCH 08/10] fix: no trailing slash when base path not set --- packages/openauth/src/issuer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index 78d3b01f..0cae9bdf 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -751,8 +751,10 @@ export function issuer< function issuer(ctx: Context) { const host = new URL(getRelativeUrl(ctx, "/")).origin + if (!basePath) return host + const url = new URL(host) - url.pathname = basePath ?? "/" + url.pathname = basePath return url.toString() } From ebcf633467a8bc107fe1e0a3b1f74a759dcdee0a Mon Sep 17 00:00:00 2001 From: finxol Date: Sat, 29 Mar 2025 23:22:24 +0100 Subject: [PATCH 09/10] fix: tests now pass --- packages/openauth/src/issuer.ts | 1 + packages/openauth/test/issuer.test.ts | 160 +++++++++++--------------- 2 files changed, 69 insertions(+), 92 deletions(-) diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index 0cae9bdf..295c5e4d 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -480,6 +480,7 @@ export function issuer< }[keyof Providers], >(input: IssuerInput) { basePath = input.basePath + basePath = basePath?.replace(/\/$/, "") // Remove trailing slash const error = input.error ?? function (err) { diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index 85864205..5a4fe676 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -12,6 +12,7 @@ import { createClient } from "../src/client.js" import { createSubjects } from "../src/subject.js" import { MemoryStorage } from "../src/storage/memory.js" import { Provider } from "../src/provider/provider.js" +import { Hono } from "hono" const subjects = createSubjects({ user: object({ @@ -393,116 +394,52 @@ describe("user info", () => { }) describe("code flow with basePath", () => { - const basePath = "/superbasepath" - const issuerConfigWithBasePath = { - basePath, - storage, - subjects, - allow: async () => true, - ttl: { - access: 60, - refresh: 6000, - refreshReuse: 60, - refreshRetention: 6000, - }, - providers: { - dummy: { - type: "dummy", - init(route, ctx) { - route.get("/authorize", async (c) => { - return ctx.success(c, { - email: "foo@bar.com", - }) - }) - }, - client: async ({ clientID, clientSecret }) => { - if (clientID !== "myuser" && clientSecret !== "mypass") { - throw new Error("Wrong credentials") - } - return { - email: "foo@bar.com", - } - }, - } satisfies Provider<{ email: string }>, - }, - success: async (ctx, value) => { - if (value.provider === "dummy") { - return ctx.subject("user", { - userID: "123", - }) - } - throw new Error("Invalid provider: " + value.provider) - }, - } - const authWithBasePath = issuer(issuerConfigWithBasePath) - - // Helper function to strip the basePath from a URL path - function getInternalPath(url: string, basePathToRemove: string): string { - const parsedPath = url - if (parsedPath.startsWith(basePathToRemove)) { - const internal = parsedPath.substring(basePathToRemove.length) - // Ensure the path starts with a slash if it's not empty - return internal.startsWith("/") || internal === "" - ? internal - : "/" + internal - } - return parsedPath // Return original path if basePath not found - } - test("success with basePath", async () => { + const customBasePath = "/custom-auth" + const bp = issuer({ + ...issuerConfig, + basePath: customBasePath, + }) + const authWithBasePath = new Hono() + authWithBasePath.route(customBasePath, bp) + const client = createClient({ - // Client still uses the public issuer URL (without basePath internally) - issuer: "https://auth.example.com", + issuer: "https://auth.example.com" + customBasePath, clientID: "123", - // The fetch function uses the issuer instance directly fetch: (a, b) => Promise.resolve(authWithBasePath.request(a, b)), }) - const redirectUri = "https://client.example.com/callback" - const { challenge, url } = await client.authorize(redirectUri, "code", { - pkce: true, - }) - // The initial authorize URL generated by the client should contain the basePath - // because the client constructs it based on the issuer URL and standard endpoints. - // Note: client.authorize might need adjustment if it doesn't automatically add /authorize - // For this test, we assume `url` correctly points to `https://.../auth/authorize` - expect(new URL(url).pathname).toBe(`/authorize`) + const { challenge, url } = await client.authorize( + "https://client.example.com/callback", + "code", + { + pkce: true, + }, + ) - // Make the first request to the authorize endpoint (with basePath) - let response = await authWithBasePath.request(url) - expect(response.status).toBe(302) // Redirects to provider's authorize + // Verify URL has the correct base path + expect(url).toContain(customBasePath) - // The redirect location header will have the basePath added by the middleware - let redirectLocation = response.headers.get("location")! - expect(redirectLocation).toContain(basePath) // e.g., /auth/dummy/authorize + let response = await authWithBasePath.request(url) + expect(response.status).toBe(302) - // Simulate the reverse proxy: determine the internal path Hono expects - // by stripping the basePath from the redirectLocation's path. - const internalPath = getInternalPath(redirectLocation, basePath) // Should be /dummy/authorize + // Check that the location header is redirecting within the base path + const redirectLocation = response.headers.get("location")! + expect(redirectLocation).toContain(customBasePath) - // Make the next request using the *internal* path (without basePath) - response = await authWithBasePath.request(internalPath, { + response = await authWithBasePath.request(redirectLocation, { headers: { cookie: response.headers.get("set-cookie")!, }, }) - // This request should now hit the correct route '/dummy/authorize' inside Hono - expect(response.status).toBe(302) // Redirects back to client with code - - // The final redirect back to the client should NOT have the basePath added, - // as it's an external URL. - const finalRedirectUrl = response.headers.get("location")! - expect(finalRedirectUrl.startsWith(redirectUri)).toBeTrue() - expect(finalRedirectUrl).not.toContain(basePath) // Verify basePath isn't added to external redirects - - const location = new URL(finalRedirectUrl) + expect(response.status).toBe(302) + const location = new URL(response.headers.get("location")!) const code = location.searchParams.get("code") expect(code).not.toBeNull() - // Exchange the code - this hits the /token endpoint (no basePath needed for internal request) const exchanged = await client.exchange( code!, - redirectUri, + "https://client.example.com/callback", challenge.verifier, ) if (exchanged.err) throw exchanged.err @@ -513,7 +450,6 @@ describe("code flow with basePath", () => { expiresIn: 60, }) - // Verify the token const verified = await client.verify(subjects, tokens.access) if (verified.err) throw verified.err expect(verified.subject).toStrictEqual({ @@ -523,4 +459,44 @@ describe("code flow with basePath", () => { }, }) }) + + test("JWKS and authorization server discovery with basePath", async () => { + const customBasePath = "/custom-auth" + const bp = issuer({ + ...issuerConfig, + basePath: customBasePath, + }) + const authWithBasePath = new Hono() + authWithBasePath.route(customBasePath, bp) + + // Test JWKS endpoint + const jwksResponse = await authWithBasePath.request( + "https://auth.example.com" + customBasePath + "/.well-known/jwks.json", + ) + expect(jwksResponse.status).toBe(200) + const jwksData = await jwksResponse.json() + expect(jwksData.keys).toBeDefined() + expect(Array.isArray(jwksData.keys)).toBe(true) + + // Test OAuth authorization server metadata + const wellKnownResponse = await authWithBasePath.request( + "https://auth.example.com" + + customBasePath + + "/.well-known/oauth-authorization-server", + ) + expect(wellKnownResponse.status).toBe(200) + const metadata = await wellKnownResponse.json() + + // Check that the issuer and endpoints use the base path + expect(metadata.issuer).toBe("https://auth.example.com" + customBasePath) + expect(metadata.authorization_endpoint).toBe( + "https://auth.example.com" + customBasePath + "/authorize", + ) + expect(metadata.token_endpoint).toBe( + "https://auth.example.com" + customBasePath + "/token", + ) + expect(metadata.jwks_uri).toBe( + "https://auth.example.com" + customBasePath + "/.well-known/jwks.json", + ) + }) }) From b6999d9f21d027b8905fe9ffc3994496535ea0e4 Mon Sep 17 00:00:00 2001 From: finxol Date: Mon, 31 Mar 2025 05:00:23 +0200 Subject: [PATCH 10/10] fix: spec compliance Misread spec, Well-Known URIs need to be at root. Issuer now links .well-known from root Client now reads .well-known from root --- packages/openauth/src/client.ts | 2 +- packages/openauth/src/issuer.ts | 10 +++++++++- www/src/content/docs/docs/issuer.mdx | 8 ++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/openauth/src/client.ts b/packages/openauth/src/client.ts index c4e282a3..7ef8ee9d 100644 --- a/packages/openauth/src/client.ts +++ b/packages/openauth/src/client.ts @@ -555,7 +555,7 @@ export function createClient(input: ClientInput): Client { const cached = issuerCache.get(issuer!) if (cached) return cached const wellKnown = (await (f || fetch)( - `${issuer}/.well-known/oauth-authorization-server`, + new URL("/.well-known/oauth-authorization-server", issuer).toString(), ).then((r) => r.json())) as WellKnown issuerCache.set(issuer!, wellKnown) return wellKnown diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index 295c5e4d..eaba0793 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -227,6 +227,14 @@ export interface IssuerInput< * With `basePath`, OpenAuth can be mounted on any sub-path of a domain. * This means OpenAuth can be nested in a larger app. * + * :::caution + * The Well-Known endpoints still need to be at the root of the domain. + * You need to perform a proxy pass to the OpenAuth server for `/.well-known/oauth-authorization-server` and `/.well-known/jwks.json`. + * + * **Example:**
+ * If you mount OpenAuth at `/auth`, `/.well-known/oauth-authorization-server` and `/.well-known/jwks.json` need to be proxied to `/auth/.well-known/oauth-authorization-server` and `/auth/.well-known/jwks.json`. + * ::: + * * @example * ```ts title="issuer.ts" * issuer({ @@ -837,7 +845,7 @@ export function issuer< issuer: iss, authorization_endpoint: `${iss}/authorize`, token_endpoint: `${iss}/token`, - jwks_uri: `${iss}/.well-known/jwks.json`, + jwks_uri: new URL("/.well-known/jwks.json", iss).toString(), response_types_supported: ["code", "token"], }) }, diff --git a/www/src/content/docs/docs/issuer.mdx b/www/src/content/docs/docs/issuer.mdx index 5bb5980d..f04ba6db 100644 --- a/www/src/content/docs/docs/issuer.mdx +++ b/www/src/content/docs/docs/issuer.mdx @@ -177,6 +177,14 @@ Create an OpenAuth server, a Hono app. With `basePath`, OpenAuth can be mounted on any sub-path of a domain. This means OpenAuth can be nested in a larger app. + +:::caution +The .well-known endpoints still need to be at the root of the domain to be spec compliant. +You need to perform a proxy pass to the OpenAuth server for `/.well-known/oauth-authorization-server` and `/.well-known/jwks.json`. + +**Example:**
+If you mount OpenAuth at `/auth`, `/.well-known/oauth-authorization-server` and `/.well-known/jwks.json` need to be proxied to `/auth/.well-known/oauth-authorization-server` and `/auth/.well-known/jwks.json`. +::: ```ts title="issuer.ts" issuer({ basePath: "/auth",