diff --git a/.changeset/public-badgers-win.md b/.changeset/public-badgers-win.md new file mode 100644 index 00000000..d2596db3 --- /dev/null +++ b/.changeset/public-badgers-win.md @@ -0,0 +1,5 @@ +--- +'@hono/oidc-auth': minor +--- + +Add OIDC_AUTH_EXTERNAL_URL to support reverse proxies diff --git a/packages/oidc-auth/README.md b/packages/oidc-auth/README.md index d4a4a695..46130d3c 100644 --- a/packages/oidc-auth/README.md +++ b/packages/oidc-auth/README.md @@ -45,20 +45,21 @@ npm i hono @hono/oidc-auth The middleware requires the following variables to be set as either environment variables or by calling `initOidcAuthMiddleware`: -| Variable | Description | Default Value | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OIDC_AUTH_SECRET | The secret key used for signing the session JWT. It is used to verify the JWT in the cookie and prevent tampering. (Must be at least 32 characters long) | None, must be provided | -| OIDC_AUTH_REFRESH_INTERVAL | The interval (in seconds) at which the session should be implicitly refreshed. | 15 \* 60 (15 minutes) | -| OIDC_AUTH_EXPIRES | The interval (in seconds) after which the session should be considered expired. Once expired, the user will be redirected to the IdP for re-authentication. | 60 _ 60 _ 24 (1 day) | -| OIDC_ISSUER | The issuer URL of the OpenID Connect (OIDC) discovery. This URL is used to retrieve the OIDC provider's configuration. | None, must be provided | -| OIDC_CLIENT_ID | The OAuth 2.0 client ID assigned to your application. This ID is used to identify your application to the OIDC provider. | None, must be provided | -| OIDC_CLIENT_SECRET | The OAuth 2.0 client secret assigned to your application. This secret is used to authenticate your application to the OIDC provider. | None, must be provided | -| OIDC_REDIRECT_URI | The URL to which the OIDC provider should redirect the user after authentication. This URL must be registered as a redirect URI in the OIDC provider. | `/callback` | -| OIDC_SCOPES | The scopes that should be used for the OIDC authentication | The server provided `scopes_supported` | -| OIDC_COOKIE_PATH | The path to which the `oidc-auth` cookie is set. Restrict to not send it with every request to your domain | / | -| OIDC_COOKIE_NAME | The name of the cookie to be set | `oidc-auth` | -| OIDC_COOKIE_DOMAIN | The custom domain of the cookie. For example, set this like `example.com` to enable authentication across subdomains (e.g., `a.example.com` and `b.example.com`). | Domain of the request | -| OIDC_AUDIENCE | The audience for the access token | No default, optional. Primarily intended for use with Auth0. [`audience`](https://community.auth0.com/t/what-is-the-audience/71414) is required by Auth0 to receive a non-opaque access token, for other providers you may not need this. | +| Variable | Description | Default Value | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OIDC_AUTH_SECRET | The secret key used for signing the session JWT. It is used to verify the JWT in the cookie and prevent tampering. (Must be at least 32 characters long) | None, must be provided | +| OIDC_AUTH_REFRESH_INTERVAL | The interval (in seconds) at which the session should be implicitly refreshed. | 15 \* 60 (15 minutes) | +| OIDC_AUTH_EXPIRES | The interval (in seconds) after which the session should be considered expired. Once expired, the user will be redirected to the IdP for re-authentication. | 60 _ 60 _ 24 (1 day) | +| OIDC_ISSUER | The issuer URL of the OpenID Connect (OIDC) discovery. This URL is used to retrieve the OIDC provider's configuration. | None, must be provided | +| OIDC_CLIENT_ID | The OAuth 2.0 client ID assigned to your application. This ID is used to identify your application to the OIDC provider. | None, must be provided | +| OIDC_CLIENT_SECRET | The OAuth 2.0 client secret assigned to your application. This secret is used to authenticate your application to the OIDC provider. | None, must be provided | +| OIDC_REDIRECT_URI | The URL to which the OIDC provider should redirect the user after authentication. This URL must be registered as a redirect URI in the OIDC provider. | `/callback` | +| OIDC_SCOPES | The scopes that should be used for the OIDC authentication | The server provided `scopes_supported` | +| OIDC_COOKIE_PATH | The path to which the `oidc-auth` cookie is set. Restrict to not send it with every request to your domain | / | +| OIDC_COOKIE_NAME | The name of the cookie to be set | `oidc-auth` | +| OIDC_COOKIE_DOMAIN | The custom domain of the cookie. For example, set this like `example.com` to enable authentication across subdomains (e.g., `a.example.com` and `b.example.com`). | Domain of the request | +| OIDC_AUDIENCE | The audience for the access token | No default, optional. Primarily intended for use with Auth0. [`audience`](https://community.auth0.com/t/what-is-the-audience/71414) is required by Auth0 to receive a non-opaque access token, for other providers you may not need this. | +| OIDC_AUTH_EXTERNAL_URL | The full, public-facing base URL of the application, including the protocol and any path prefixes (e.g., `https://app.example.com` or `https://example.com/myapp`). This is used to construct the correct redirect URL after login when running behind a reverse proxy. | None. The middleware will use the URL from the incoming request. | ## How to Use diff --git a/packages/oidc-auth/src/index.test.ts b/packages/oidc-auth/src/index.test.ts index a8875216..abb16932 100644 --- a/packages/oidc-auth/src/index.test.ts +++ b/packages/oidc-auth/src/index.test.ts @@ -197,6 +197,7 @@ beforeEach(() => { delete process.env.OIDC_COOKIE_NAME delete process.env.OIDC_COOKIE_DOMAIN delete process.env.OIDC_AUDIENCE + delete process.env.OIDC_AUTH_EXTERNAL_URL }) describe('oidcAuthMiddleware()', () => { test('Should respond with 200 OK if session is active', async () => { @@ -403,6 +404,30 @@ describe('oidcAuthMiddleware()', () => { expect(res.status).toBe(302) expect(res.headers.get('location')).not.toMatch(/audience=/) }) + test('Should use external URL for continue cookie when OIDC_AUTH_EXTERNAL_URL is set', async () => { + process.env.OIDC_AUTH_EXTERNAL_URL = 'https://public.example.com/app' + const req = new Request('http://internal.host/sub/path?q=1#hash', { + method: 'GET', + headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` }, + }) + const res = await app.request(req, {}, {}) + expect(res).not.toBeNull() + expect(res.status).toBe(302) + const expectedContinueUrl = 'https://public.example.com/app/sub/path?q=1#hash' + expect(res.headers.get('set-cookie')).toMatch( + `continue=${encodeURIComponent(expectedContinueUrl)}` + ) + }) + test('Should return an error when OIDC_AUTH_EXTERNAL_URL is an invalid URL', async () => { + process.env.OIDC_AUTH_EXTERNAL_URL = 'invalid-url' + const req = new Request('http://localhost/', { + method: 'GET', + headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` }, + }) + const res = await app.request(req, {}, {}) + expect(res).not.toBeNull() + expect(res.status).toBe(500) + }) }) describe('processOAuthCallback()', () => { test('Should successfully process the OAuth2.0 callback and redirect to the continue URL', async () => { diff --git a/packages/oidc-auth/src/index.ts b/packages/oidc-auth/src/index.ts index 17f12e5c..7e1d5e69 100644 --- a/packages/oidc-auth/src/index.ts +++ b/packages/oidc-auth/src/index.ts @@ -59,6 +59,7 @@ export type OidcAuthEnv = { OIDC_COOKIE_NAME?: string OIDC_COOKIE_DOMAIN?: string OIDC_AUDIENCE?: string + OIDC_AUTH_EXTERNAL_URL?: string } /** @@ -95,6 +96,7 @@ const setOidcAuthEnv = (c: Context, config?: Partial) => { OIDC_COOKIE_NAME: config?.OIDC_COOKIE_NAME ?? ev.OIDC_COOKIE_NAME, OIDC_COOKIE_DOMAIN: config?.OIDC_COOKIE_DOMAIN ?? ev.OIDC_COOKIE_DOMAIN, OIDC_AUDIENCE: config?.OIDC_AUDIENCE ?? ev.OIDC_AUDIENCE, + OIDC_AUTH_EXTERNAL_URL: config?.OIDC_AUTH_EXTERNAL_URL ?? ev.OIDC_AUTH_EXTERNAL_URL, } if (oidcAuthEnv.OIDC_AUTH_SECRET === undefined) { throw new HTTPException(500, { message: 'Session secret is not provided' }) @@ -117,12 +119,21 @@ const setOidcAuthEnv = (c: Context, config?: Partial) => { if (!oidcAuthEnv.OIDC_REDIRECT_URI.startsWith('/')) { try { new URL(oidcAuthEnv.OIDC_REDIRECT_URI) - } catch (e) { + } catch { throw new HTTPException(500, { message: 'The OIDC redirect URI is invalid. It must be a full URL or an absolute path', }) } } + if (oidcAuthEnv.OIDC_AUTH_EXTERNAL_URL) { + try { + new URL(oidcAuthEnv.OIDC_AUTH_EXTERNAL_URL) + } catch { + throw new HTTPException(500, { + message: 'The OIDC external URL is invalid. It must be a full URL.', + }) + } + } oidcAuthEnv.OIDC_COOKIE_PATH = oidcAuthEnv.OIDC_COOKIE_PATH ?? defaultOidcAuthCookiePath oidcAuthEnv.OIDC_COOKIE_NAME = oidcAuthEnv.OIDC_COOKIE_NAME ?? defaultOidcAuthCookieName oidcAuthEnv.OIDC_AUTH_REFRESH_INTERVAL = @@ -191,7 +202,7 @@ export const getAuth = async (c: Context): Promise => { } try { auth = (await verify(session_jwt, env.OIDC_AUTH_SECRET)) as OidcAuth - } catch (e) { + } catch { deleteCookie(c, env.OIDC_COOKIE_NAME, { path: env.OIDC_COOKIE_PATH }) return null } @@ -462,10 +473,22 @@ export const oidcAuthMiddleware = (): MiddlewareHandler => { setCookie(c, 'state', state, cookieOptions) setCookie(c, 'nonce', nonce, cookieOptions) setCookie(c, 'code_verifier', code_verifier, cookieOptions) - setCookie(c, 'continue', c.req.url, cookieOptions) + const continueUrl = env.OIDC_AUTH_EXTERNAL_URL + ? (() => { + const externalUrl = new URL(env.OIDC_AUTH_EXTERNAL_URL) + const originalUrl = new URL(c.req.url) + externalUrl.pathname = `${externalUrl.pathname.replace(/\/$/, '')}${ + originalUrl.pathname + }` + externalUrl.search = originalUrl.search + externalUrl.hash = originalUrl.hash + return externalUrl.toString() + })() + : c.req.url + setCookie(c, 'continue', continueUrl, cookieOptions) return c.redirect(url) } - } catch (e) { + } catch { deleteCookie(c, env.OIDC_COOKIE_NAME, { path: env.OIDC_COOKIE_PATH }) throw new HTTPException(500, { message: 'Invalid session' }) }