From 87be44000987c41209fbb79c1a0c95dcf31fadfa Mon Sep 17 00:00:00 2001 From: Milan Raj Date: Sun, 16 Mar 2025 04:57:44 -0500 Subject: [PATCH] feat(oidc-auth) Add initOidcAuthMiddleware and avoid mutating environment variables (#980) * Add setOidcAuthEnv * Avoid test relying on mutated global * Test and docs * Changeset * style * Update type import * Switch to setOidcAuthEnvMiddleware * Update changeset description * nit remove unneeded optional param on getOidcAuthEnv * Rename to initOidcAuthMiddleware --- .changeset/cold-ties-sin.md | 5 ++ packages/oidc-auth/README.md | 24 +++++- packages/oidc-auth/src/index.test.ts | 42 ++++++++++- packages/oidc-auth/src/index.ts | 109 ++++++++++++++++++--------- 4 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 .changeset/cold-ties-sin.md diff --git a/.changeset/cold-ties-sin.md b/.changeset/cold-ties-sin.md new file mode 100644 index 00000000..123764dc --- /dev/null +++ b/.changeset/cold-ties-sin.md @@ -0,0 +1,5 @@ +--- +'@hono/oidc-auth': minor +--- + +Add initOidcAuthMiddleware() and avoid mutating environment variables diff --git a/packages/oidc-auth/README.md b/packages/oidc-auth/README.md index 369cf489..10158dd5 100644 --- a/packages/oidc-auth/README.md +++ b/packages/oidc-auth/README.md @@ -41,9 +41,9 @@ npm i hono @hono/oidc-auth ## Configuration -The middleware requires the following environment variables to be set: +The middleware requires the following variables to be set as either environment variables or by calling `initOidcAuthMiddleware`: -| Environment Variable | Description | Default Value | +| 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) | @@ -140,6 +140,26 @@ Note: If explicit logout is not required, the logout handler can be omitted. If the middleware is applied to the callback URL, the default callback handling in the middleware can be used, so the explicit callback handling is not required. +## Programmatically configure auth variables + +```typescript +// Before other oidc-auth APIs are used +app.use(initOidcAuthMiddleware(config)); +``` + +Or to leverage context, use the [`Context access inside Middleware arguments`](https://hono.dev/docs/guides/middleware#context-access-inside-middleware-arguments) pattern. + +```typescript +// Before other oidc-auth APIs are used +app.use(async (c, next) => { + const config = { + // Create config using context + } + const middleware = initOidcAuthMiddleware(config) + return middleware(c, next) +}) +``` + ## Author Yoshio HANAWA diff --git a/packages/oidc-auth/src/index.test.ts b/packages/oidc-auth/src/index.test.ts index 3eef1b54..4498b9d8 100644 --- a/packages/oidc-auth/src/index.test.ts +++ b/packages/oidc-auth/src/index.test.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import jwt from 'jsonwebtoken' +import type * as oauth2 from 'oauth4webapi' import crypto from 'node:crypto' const MOCK_ISSUER = 'https://accounts.google.com' @@ -156,7 +157,7 @@ vi.mock(import('oauth4webapi'), async (importOriginal) => { } }) -const { oidcAuthMiddleware, getAuth, processOAuthCallback, revokeSession } = await import('../src') +const { oidcAuthMiddleware, getAuth, processOAuthCallback, revokeSession, initOidcAuthMiddleware, getClient } = await import('../src') const app = new Hono() app.get('/logout', async (c) => { @@ -482,6 +483,7 @@ describe('processOAuthCallback()', () => { }) test('Should respond with custom cookie name', async () => { const MOCK_COOKIE_NAME = (process.env.OIDC_COOKIE_NAME = 'custom-auth-cookie') + const defaultOidcAuthCookiePath = '/' const req = new Request(`${MOCK_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, { method: 'GET', headers: { @@ -493,7 +495,7 @@ describe('processOAuthCallback()', () => { expect(res.status).toBe(302) expect(res.headers.get('set-cookie')).toMatch( new RegExp( - `${MOCK_COOKIE_NAME}=[^;]+; Path=${process.env.OIDC_COOKIE_PATH}; HttpOnly; Secure` + `${MOCK_COOKIE_NAME}=[^;]+; Path=${defaultOidcAuthCookiePath}; HttpOnly; Secure` ) ) }) @@ -558,3 +560,39 @@ describe('RevokeSession()', () => { ) }) }) +describe('initOidcAuthMiddleware()', () => { + test('Should error if not called first in context', async () => { + const app = new Hono() + app.use('/*', oidcAuthMiddleware()) + app.use(initOidcAuthMiddleware({})) + const req = new Request('http://localhost/', { + method: 'GET', + headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` }, + }) + const res = await app.request(req, {}, {}) + expect(res).not.toBeNull() + expect(res.status).toBe(500) + }) + test('Should prefer programmatically configured varaiables', async () => { + let client: oauth2.Client | undefined + const CUSTOM_OIDC_CLIENT_ID = 'custom-client-id' + const CUSTOM_OIDC_CLIENT_SECRET = 'custom-client-secret' + const app = new Hono() + app.use(initOidcAuthMiddleware({ + OIDC_CLIENT_ID: CUSTOM_OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET: CUSTOM_OIDC_CLIENT_SECRET + })) + app.use(async (c) => { + client = getClient(c) + return c.text('finished') + }) + const req = new Request('http://localhost/', { + method: 'GET' + }) + const res = await app.request(req, {}, {}) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(client?.client_id).toBe(CUSTOM_OIDC_CLIENT_ID) + expect(client?.client_secret).toBe(CUSTOM_OIDC_CLIENT_SECRET) + }) +}) diff --git a/packages/oidc-auth/src/index.ts b/packages/oidc-auth/src/index.ts index 8b1c714c..601c652d 100644 --- a/packages/oidc-auth/src/index.ts +++ b/packages/oidc-auth/src/index.ts @@ -46,7 +46,7 @@ export type OidcAuth = { ssnexp: number // session expiration time; if it's expired, revoke session and redirect to IdP } & OidcAuthClaims -type OidcAuthEnv = { +export type OidcAuthEnv = { OIDC_AUTH_SECRET: string OIDC_AUTH_REFRESH_INTERVAL?: string OIDC_AUTH_EXPIRES?: string @@ -60,47 +60,84 @@ type OidcAuthEnv = { OIDC_COOKIE_DOMAIN?: string } +/** + * Configure the OIDC variables programmatically. + * If used, should be called before any other OIDC middleware or functions for the Hono context. + * Unconfigured values will fallback to environment variables. + */ +export const initOidcAuthMiddleware = (config: Partial) => { + return createMiddleware(async (c, next) => { + setOidcAuthEnv(c, config) + await next() + }) +} + +/** + * Configure the OIDC variables. + */ +const setOidcAuthEnv = (c: Context, config?: Partial) => { + const currentOidcAuthEnv = c.get('oidcAuthEnv') + if (currentOidcAuthEnv !== undefined) { + throw new HTTPException(500, { message: 'OIDC Auth env is already configured' }) + } + const ev = env>(c) + const oidcAuthEnv = { + OIDC_AUTH_SECRET: config?.OIDC_AUTH_SECRET ?? ev.OIDC_AUTH_SECRET, + OIDC_AUTH_REFRESH_INTERVAL: config?.OIDC_AUTH_REFRESH_INTERVAL ?? ev.OIDC_AUTH_REFRESH_INTERVAL, + OIDC_AUTH_EXPIRES: config?.OIDC_AUTH_EXPIRES ?? ev.OIDC_AUTH_EXPIRES, + OIDC_ISSUER: config?.OIDC_ISSUER ?? ev.OIDC_ISSUER, + OIDC_CLIENT_ID: config?.OIDC_CLIENT_ID ?? ev.OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET: config?.OIDC_CLIENT_SECRET ?? ev.OIDC_CLIENT_SECRET, + OIDC_REDIRECT_URI: config?.OIDC_REDIRECT_URI ?? ev.OIDC_REDIRECT_URI, + OIDC_SCOPES: config?.OIDC_SCOPES ?? ev.OIDC_SCOPES, + OIDC_COOKIE_PATH: config?.OIDC_COOKIE_PATH ?? ev.OIDC_COOKIE_PATH, + OIDC_COOKIE_NAME: config?.OIDC_COOKIE_NAME ?? ev.OIDC_COOKIE_NAME, + OIDC_COOKIE_DOMAIN: config?.OIDC_COOKIE_DOMAIN ?? ev.OIDC_COOKIE_DOMAIN, + } + if (oidcAuthEnv.OIDC_AUTH_SECRET === undefined) { + throw new HTTPException(500, { message: 'Session secret is not provided' }) + } + if (oidcAuthEnv.OIDC_AUTH_SECRET.length < 32) { + throw new HTTPException(500, { + message: 'Session secrets must be at least 32 characters long', + }) + } + if (oidcAuthEnv.OIDC_ISSUER === undefined) { + throw new HTTPException(500, { message: 'OIDC issuer is not provided' }) + } + if (oidcAuthEnv.OIDC_CLIENT_ID === undefined) { + throw new HTTPException(500, { message: 'OIDC client ID is not provided' }) + } + if (oidcAuthEnv.OIDC_CLIENT_SECRET === undefined) { + throw new HTTPException(500, { message: 'OIDC client secret is not provided' }) + } + oidcAuthEnv.OIDC_REDIRECT_URI = oidcAuthEnv.OIDC_REDIRECT_URI ?? defaultOidcRedirectUri + if (!oidcAuthEnv.OIDC_REDIRECT_URI.startsWith('/')) { + try { + new URL(oidcAuthEnv.OIDC_REDIRECT_URI) + } catch (e) { + throw new HTTPException(500, { + message: 'The OIDC redirect URI is invalid. It must be a full URL or an absolute path', + }) + } + } + oidcAuthEnv.OIDC_COOKIE_PATH = oidcAuthEnv.OIDC_COOKIE_PATH ?? defaultOidcAuthCookiePath + oidcAuthEnv.OIDC_COOKIE_NAME = oidcAuthEnv.OIDC_COOKIE_NAME ?? defaultOidcAuthCookieName + oidcAuthEnv.OIDC_AUTH_REFRESH_INTERVAL = + oidcAuthEnv.OIDC_AUTH_REFRESH_INTERVAL ?? `${defaultRefreshInterval}` + oidcAuthEnv.OIDC_AUTH_EXPIRES = oidcAuthEnv.OIDC_AUTH_EXPIRES ?? `${defaultExpirationInterval}` + oidcAuthEnv.OIDC_SCOPES = oidcAuthEnv.OIDC_SCOPES ?? '' + c.set('oidcAuthEnv', oidcAuthEnv) +} + /** * Returns the environment variables for OIDC-auth middleware. */ const getOidcAuthEnv = (c: Context) => { let oidcAuthEnv = c.get('oidcAuthEnv') if (oidcAuthEnv === undefined) { - oidcAuthEnv = env(c) - if (oidcAuthEnv.OIDC_AUTH_SECRET === undefined) { - throw new HTTPException(500, { message: 'Session secret is not provided' }) - } - if (oidcAuthEnv.OIDC_AUTH_SECRET.length < 32) { - throw new HTTPException(500, { - message: 'Session secrets must be at least 32 characters long', - }) - } - if (oidcAuthEnv.OIDC_ISSUER === undefined) { - throw new HTTPException(500, { message: 'OIDC issuer is not provided' }) - } - if (oidcAuthEnv.OIDC_CLIENT_ID === undefined) { - throw new HTTPException(500, { message: 'OIDC client ID is not provided' }) - } - if (oidcAuthEnv.OIDC_CLIENT_SECRET === undefined) { - throw new HTTPException(500, { message: 'OIDC client secret is not provided' }) - } - oidcAuthEnv.OIDC_REDIRECT_URI = oidcAuthEnv.OIDC_REDIRECT_URI ?? defaultOidcRedirectUri - if (!oidcAuthEnv.OIDC_REDIRECT_URI.startsWith('/')) { - try { - new URL(oidcAuthEnv.OIDC_REDIRECT_URI) - } catch (e) { - throw new HTTPException(500, { - message: 'The OIDC redirect URI is invalid. It must be a full URL or an absolute path', - }) - } - } - oidcAuthEnv.OIDC_COOKIE_PATH = oidcAuthEnv.OIDC_COOKIE_PATH ?? defaultOidcAuthCookiePath - oidcAuthEnv.OIDC_COOKIE_NAME = oidcAuthEnv.OIDC_COOKIE_NAME ?? defaultOidcAuthCookieName - oidcAuthEnv.OIDC_AUTH_REFRESH_INTERVAL = - oidcAuthEnv.OIDC_AUTH_REFRESH_INTERVAL ?? `${defaultRefreshInterval}` - oidcAuthEnv.OIDC_AUTH_EXPIRES = oidcAuthEnv.OIDC_AUTH_EXPIRES ?? `${defaultExpirationInterval}` - oidcAuthEnv.OIDC_SCOPES = oidcAuthEnv.OIDC_SCOPES ?? '' - c.set('oidcAuthEnv', oidcAuthEnv) + setOidcAuthEnv(c) + oidcAuthEnv = c.get('oidcAuthEnv') } return oidcAuthEnv as Required }