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 initOidcAuthMiddlewarepull/1018/head
parent
e394e64ea5
commit
87be440009
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/oidc-auth': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add initOidcAuthMiddleware() and avoid mutating environment variables
|
|
@ -41,9 +41,9 @@ npm i hono @hono/oidc-auth
|
||||||
|
|
||||||
## Configuration
|
## 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_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_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 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.
|
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
|
## Author
|
||||||
|
|
||||||
Yoshio HANAWA <https://github.com/hnw>
|
Yoshio HANAWA <https://github.com/hnw>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
import type * as oauth2 from 'oauth4webapi'
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
const MOCK_ISSUER = 'https://accounts.google.com'
|
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()
|
const app = new Hono()
|
||||||
app.get('/logout', async (c) => {
|
app.get('/logout', async (c) => {
|
||||||
|
@ -482,6 +483,7 @@ describe('processOAuthCallback()', () => {
|
||||||
})
|
})
|
||||||
test('Should respond with custom cookie name', async () => {
|
test('Should respond with custom cookie name', async () => {
|
||||||
const MOCK_COOKIE_NAME = (process.env.OIDC_COOKIE_NAME = 'custom-auth-cookie')
|
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}`, {
|
const req = new Request(`${MOCK_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -493,7 +495,7 @@ describe('processOAuthCallback()', () => {
|
||||||
expect(res.status).toBe(302)
|
expect(res.status).toBe(302)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
new RegExp(
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -46,7 +46,7 @@ export type OidcAuth = {
|
||||||
ssnexp: number // session expiration time; if it's expired, revoke session and redirect to IdP
|
ssnexp: number // session expiration time; if it's expired, revoke session and redirect to IdP
|
||||||
} & OidcAuthClaims
|
} & OidcAuthClaims
|
||||||
|
|
||||||
type OidcAuthEnv = {
|
export type OidcAuthEnv = {
|
||||||
OIDC_AUTH_SECRET: string
|
OIDC_AUTH_SECRET: string
|
||||||
OIDC_AUTH_REFRESH_INTERVAL?: string
|
OIDC_AUTH_REFRESH_INTERVAL?: string
|
||||||
OIDC_AUTH_EXPIRES?: string
|
OIDC_AUTH_EXPIRES?: string
|
||||||
|
@ -61,12 +61,39 @@ type OidcAuthEnv = {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the environment variables for OIDC-auth middleware.
|
* 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.
|
||||||
*/
|
*/
|
||||||
const getOidcAuthEnv = (c: Context) => {
|
export const initOidcAuthMiddleware = (config: Partial<OidcAuthEnv>) => {
|
||||||
let oidcAuthEnv = c.get('oidcAuthEnv')
|
return createMiddleware(async (c, next) => {
|
||||||
if (oidcAuthEnv === undefined) {
|
setOidcAuthEnv(c, config)
|
||||||
oidcAuthEnv = env<OidcAuthEnv>(c)
|
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) {
|
if (oidcAuthEnv.OIDC_AUTH_SECRET === undefined) {
|
||||||
throw new HTTPException(500, { message: 'Session secret is not provided' })
|
throw new HTTPException(500, { message: 'Session secret is not provided' })
|
||||||
}
|
}
|
||||||
|
@ -101,6 +128,16 @@ const getOidcAuthEnv = (c: Context) => {
|
||||||
oidcAuthEnv.OIDC_AUTH_EXPIRES = oidcAuthEnv.OIDC_AUTH_EXPIRES ?? `${defaultExpirationInterval}`
|
oidcAuthEnv.OIDC_AUTH_EXPIRES = oidcAuthEnv.OIDC_AUTH_EXPIRES ?? `${defaultExpirationInterval}`
|
||||||
oidcAuthEnv.OIDC_SCOPES = oidcAuthEnv.OIDC_SCOPES ?? ''
|
oidcAuthEnv.OIDC_SCOPES = oidcAuthEnv.OIDC_SCOPES ?? ''
|
||||||
c.set('oidcAuthEnv', oidcAuthEnv)
|
c.set('oidcAuthEnv', oidcAuthEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the environment variables for OIDC-auth middleware.
|
||||||
|
*/
|
||||||
|
const getOidcAuthEnv = (c: Context) => {
|
||||||
|
let oidcAuthEnv = c.get('oidcAuthEnv')
|
||||||
|
if (oidcAuthEnv === undefined) {
|
||||||
|
setOidcAuthEnv(c)
|
||||||
|
oidcAuthEnv = c.get('oidcAuthEnv')
|
||||||
}
|
}
|
||||||
return oidcAuthEnv as Required<OidcAuthEnv>
|
return oidcAuthEnv as Required<OidcAuthEnv>
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue