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
pull/1018/head
Milan Raj 2025-03-16 04:57:44 -05:00 committed by GitHub
parent e394e64ea5
commit 87be440009
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 140 additions and 40 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/oidc-auth': minor
---
Add initOidcAuthMiddleware() and avoid mutating environment variables

View File

@ -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 <https://github.com/hnw>

View File

@ -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)
})
})

View File

@ -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<OidcAuthEnv>) => {
return createMiddleware(async (c, next) => {
setOidcAuthEnv(c, config)
await next()
})
}
/**
* Configure the OIDC variables.
*/
const setOidcAuthEnv = (c: Context, config?: Partial<OidcAuthEnv>) => {
const currentOidcAuthEnv = c.get('oidcAuthEnv')
if (currentOidcAuthEnv !== undefined) {
throw new HTTPException(500, { message: 'OIDC Auth env is already configured' })
}
const ev = env<Readonly<OidcAuthEnv>>(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<OidcAuthEnv>(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<OidcAuthEnv>
}