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
5 changes: 5 additions & 0 deletions .changeset/fresh-parents-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openauthjs/openauth": minor
---

Allow running from sub-paths
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/openauth/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 67 additions & 2 deletions packages/openauth/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,16 @@ 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

/**
* @internal
*/
export let basePath: string | undefined = undefined

export interface IssuerInput<
Providers extends Record<string, Provider<any>>,
Subjects extends SubjectSchema,
Expand All @@ -217,6 +223,35 @@ export interface IssuerInput<
>
}[keyof Providers],
> {
/**
* 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:**<br/>
* 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({
* 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/auth", // if OpenAuth is mounted at `/authpath`
* clientID: "123",
* })
* ```
*/
basePath?: string
/**
* The shape of the subjects that you want to return.
*
Expand Down Expand Up @@ -452,6 +487,8 @@ export function issuer<
>
}[keyof Providers],
>(input: IssuerInput<Providers, Subjects, Result>) {
basePath = input.basePath
basePath = basePath?.replace(/\/$/, "") // Remove trailing slash
const error =
input.error ??
function (err) {
Expand Down Expand Up @@ -722,7 +759,12 @@ export function issuer<
}

function issuer(ctx: Context) {
return new URL(getRelativeUrl(ctx, "/")).origin
const host = new URL(getRelativeUrl(ctx, "/")).origin
if (!basePath) return host

const url = new URL(host)
url.pathname = basePath
return url.toString()
}

const app = new Hono<{
Expand All @@ -731,6 +773,29 @@ export function issuer<
}
}>().use(logger())

// Only edit local redirects if baseP
if (basePath) {
app.use(
createMiddleware(async (c, next) => {
Copy link

@rumbcam rumbcam Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this middleware handle lines 1130-1132 of this same file?

    if (provider) return c.redirect(`/${provider}/authorize`)
    const providers = Object.keys(input.providers)
    if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`)

Here it's redirecting and this needs to make sure it redirect with the correct basePath

await next()

if (basePath) {
// Normalize the basePath (remove leading/trailing slashes)
const bp = 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<any>()
route.use(async (c, next) => {
Expand Down Expand Up @@ -780,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"],
})
},
Expand Down
3 changes: 2 additions & 1 deletion packages/openauth/src/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -73,7 +74,7 @@ export function Select(props?: SelectProps) {
const icon = ICON[key]
return (
<a
href={`/${key}/authorize`}
href={`${basePath ? basePath : ""}/${key}/authorize`}
data-component="button"
data-color="ghost"
>
Expand Down
109 changes: 109 additions & 0 deletions packages/openauth/test/issuer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -391,3 +392,111 @@ describe("user info", () => {
expect(userinfo).toStrictEqual({ userID: "123" })
})
})

describe("code flow with basePath", () => {
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({
issuer: "https://auth.example.com" + customBasePath,
clientID: "123",
fetch: (a, b) => Promise.resolve(authWithBasePath.request(a, b)),
})

const { challenge, url } = await client.authorize(
"https://client.example.com/callback",
"code",
{
pkce: true,
},
)

// Verify URL has the correct base path
expect(url).toContain(customBasePath)

let response = await authWithBasePath.request(url)
expect(response.status).toBe(302)

// Check that the location header is redirecting within the base path
const redirectLocation = response.headers.get("location")!
expect(redirectLocation).toContain(customBasePath)

response = await authWithBasePath.request(redirectLocation, {
headers: {
cookie: response.headers.get("set-cookie")!,
},
})
expect(response.status).toBe(302)
const location = new URL(response.headers.get("location")!)
const code = location.searchParams.get("code")
expect(code).not.toBeNull()

const exchanged = await client.exchange(
code!,
"https://client.example.com/callback",
challenge.verifier,
)
if (exchanged.err) throw exchanged.err
const tokens = exchanged.tokens
expect(tokens).toStrictEqual({
access: expectNonEmptyString,
refresh: expectNonEmptyString,
expiresIn: 60,
})

const verified = await client.verify(subjects, tokens.access)
if (verified.err) throw verified.err
expect(verified.subject).toStrictEqual({
type: "user",
properties: {
userID: "123",
},
})
})

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",
)
})
})
35 changes: 35 additions & 0 deletions www/src/content/docs/docs/issuer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ Create an OpenAuth server, a Hono app.
## IssuerInput
<Segment>
<Section type="parameters">
- <p>[<code class="key">basePath?</code>](#issuerinput.basepath) <code class="primitive">string</code></p>
- <p>[<code class="key">providers</code>](#issuerinput.providers) <code class="primitive">Record</code><code class="symbol">&lt;</code><code class="primitive">string</code>, <code class="type">Provider</code><code class="symbol">&gt;</code></p>
- <p>[<code class="key">storage?</code>](#issuerinput.storage) <code class="type">StorageAdapter</code></p>
- <p>[<code class="key">subjects</code>](#issuerinput.subjects) [<code class="type">SubjectSchema</code>](/docs/subject#subjectschema)</p>
Expand All @@ -167,6 +168,40 @@ Create an OpenAuth server, a Hono app.
- <p>[<code class="key">success</code>](#issuerinput.success) <code class="primitive">(response: [<code class="type">OnSuccessResponder</code>](#onsuccessresponder), input: <code class="type">Result</code>, req: <code class="type">Request</code>) => <code class="primitive">Promise</code><code class="symbol">&lt;</code><code class="type">Response</code><code class="symbol">&gt;</code></code></p>
</Section>
</Segment>
<NestedTitle id="issuerinput.basepath" Tag="h4" parent="IssuerInput.">basePath?</NestedTitle>
<Segment>
<Section type="parameters">
<InlineSection>
**Type** <code class="primitive">string</code>
</InlineSection>
</Section>
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:**<br/>
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",
// ...
})
```


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/auth", // if OpenAuth is mounted at `/authpath`
clientID: "123",
})
```
</Segment>
<NestedTitle id="issuerinput.providers" Tag="h4" parent="IssuerInput.">providers</NestedTitle>
<Segment>
<Section type="parameters">
Expand Down