Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(oidc-auth) Add initOidcAuthMiddleware and avoid mutating environment variables #980

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/cold-ties-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/oidc-auth': minor
---

Add initOidcAuthMiddleware() and avoid mutating environment variables
24 changes: 22 additions & 2 deletions packages/oidc-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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>
Expand Down
42 changes: 40 additions & 2 deletions packages/oidc-auth/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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: {
Expand All @@ -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`
)
)
})
Expand Down Expand Up @@ -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)
})
})
109 changes: 73 additions & 36 deletions packages/oidc-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Copy link
Author

Choose a reason for hiding this comment

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

Changed to take a Readonly reference to the environment variables to avoid accidental mutation

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,
}
Comment on lines +84 to +96
Copy link
Author

Choose a reason for hiding this comment

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

fallback to env variables when not provided in config

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)
Comment on lines +97 to +130
Copy link
Author

Choose a reason for hiding this comment

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

This logic is unchanged, just moved

}

/**
* 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>
}
Expand Down