feat(oidc-auth): optional cookie name (#789)

pull/795/head
tempepe 2024-10-25 11:25:00 +09:00 committed by GitHub
parent ee5d7e0e74
commit 68eec9e2bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 47 additions and 17 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/oidc-auth': minor
---
Optionally specify a custom cookie name using the OIDC_COOKIE_NAME environment variable (default is 'oidc-auth')

View File

@ -54,6 +54,7 @@ The middleware requires the following environment variables to be set:
| 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. | 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. | None, must be provided |
| OIDC_SCOPES | The scopes that should be used for the OIDC authentication | The server provided `scopes_supported` | | 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_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` |
## How to Use ## How to Use

View File

@ -34,7 +34,8 @@ declare module 'hono' {
} }
} }
const oidcAuthCookieName = 'oidc-auth' const defaultOidcAuthCookieName = 'oidc-auth'
const defaultOidcAuthCookiePath = '/'
const defaultRefreshInterval = 15 * 60 // 15 minutes const defaultRefreshInterval = 15 * 60 // 15 minutes
const defaultExpirationInterval = 60 * 60 * 24 // 1 day const defaultExpirationInterval = 60 * 60 * 24 // 1 day
@ -54,6 +55,7 @@ type OidcAuthEnv = {
OIDC_REDIRECT_URI: string OIDC_REDIRECT_URI: string
OIDC_SCOPES?: string OIDC_SCOPES?: string
OIDC_COOKIE_PATH?: string OIDC_COOKIE_PATH?: string
OIDC_COOKIE_NAME?: string
} }
/** /**
@ -83,9 +85,15 @@ const getOidcAuthEnv = (c: Context) => {
if (oidcAuthEnv.OIDC_REDIRECT_URI === undefined) { if (oidcAuthEnv.OIDC_REDIRECT_URI === undefined) {
throw new HTTPException(500, { message: 'OIDC redirect URI is not provided' }) throw new HTTPException(500, { message: 'OIDC redirect URI is not provided' })
} }
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) c.set('oidcAuthEnv', oidcAuthEnv)
} }
return oidcAuthEnv return oidcAuthEnv as Required<OidcAuthEnv>
} }
/** /**
@ -129,14 +137,14 @@ export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
const env = getOidcAuthEnv(c) const env = getOidcAuthEnv(c)
let auth: Partial<OidcAuth> | null = c.get('oidcAuth') let auth: Partial<OidcAuth> | null = c.get('oidcAuth')
if (auth === undefined) { if (auth === undefined) {
const session_jwt = getCookie(c, oidcAuthCookieName) const session_jwt = getCookie(c, env.OIDC_COOKIE_NAME)
if (session_jwt === undefined) { if (session_jwt === undefined) {
return null return null
} }
try { try {
auth = await verify(session_jwt, env.OIDC_AUTH_SECRET) auth = await verify(session_jwt, env.OIDC_AUTH_SECRET)
} catch (e) { } catch (e) {
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' }) deleteCookie(c, env.OIDC_COOKIE_NAME, { path: env.OIDC_COOKIE_PATH })
return null return null
} }
if (auth === null || auth.rtkexp === undefined || auth.ssnexp === undefined) { if (auth === null || auth.rtkexp === undefined || auth.ssnexp === undefined) {
@ -151,7 +159,7 @@ export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
if (auth.rtkexp < now) { if (auth.rtkexp < now) {
// Refresh the token if it has expired // Refresh the token if it has expired
if (auth.rtk === undefined || auth.rtk === '') { if (auth.rtk === undefined || auth.rtk === '') {
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' }) deleteCookie(c, env.OIDC_COOKIE_NAME, { path: env.OIDC_COOKIE_PATH })
return null return null
} }
const as = await getAuthorizationServer(c) const as = await getAuthorizationServer(c)
@ -160,7 +168,7 @@ export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
const result = await oauth2.processRefreshTokenResponse(as, client, response) const result = await oauth2.processRefreshTokenResponse(as, client, response)
if (oauth2.isOAuth2Error(result)) { if (oauth2.isOAuth2Error(result)) {
// The refresh_token might be expired or revoked // The refresh_token might be expired or revoked
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' }) deleteCookie(c, env.OIDC_COOKIE_NAME, { path: env.OIDC_COOKIE_PATH })
return null return null
} }
auth = await updateAuth(c, auth as OidcAuth, result) auth = await updateAuth(c, auth as OidcAuth, result)
@ -190,8 +198,8 @@ const updateAuth = async (
): Promise<OidcAuth> => { ): Promise<OidcAuth> => {
const env = getOidcAuthEnv(c) const env = getOidcAuthEnv(c)
const claims = oauth2.getValidatedIdTokenClaims(response) const claims = oauth2.getValidatedIdTokenClaims(response)
const authRefreshInterval = Number(env.OIDC_AUTH_REFRESH_INTERVAL!) || defaultRefreshInterval const authRefreshInterval = Number(env.OIDC_AUTH_REFRESH_INTERVAL)
const authExpires = Number(env.OIDC_AUTH_EXPIRES!) || defaultExpirationInterval const authExpires = Number(env.OIDC_AUTH_EXPIRES)
const claimsHook: OidcClaimsHook = const claimsHook: OidcClaimsHook =
c.get('oidcClaimsHook') ?? c.get('oidcClaimsHook') ??
(async (orig, claims) => { (async (orig, claims) => {
@ -207,8 +215,8 @@ const updateAuth = async (
ssnexp: orig?.ssnexp || Math.floor(Date.now() / 1000) + authExpires, ssnexp: orig?.ssnexp || Math.floor(Date.now() / 1000) + authExpires,
} }
const session_jwt = await sign(updated, env.OIDC_AUTH_SECRET) const session_jwt = await sign(updated, env.OIDC_AUTH_SECRET)
setCookie(c, oidcAuthCookieName, session_jwt, { setCookie(c, env.OIDC_COOKIE_NAME, session_jwt, {
path: env.OIDC_COOKIE_PATH ?? '/', path: env.OIDC_COOKIE_PATH,
httpOnly: true, httpOnly: true,
secure: true, secure: true,
}) })
@ -220,10 +228,10 @@ const updateAuth = async (
* Revokes the refresh token of the current session and deletes the session cookie * Revokes the refresh token of the current session and deletes the session cookie
*/ */
export const revokeSession = async (c: Context): Promise<void> => { export const revokeSession = async (c: Context): Promise<void> => {
const session_jwt = getCookie(c, oidcAuthCookieName) const env = getOidcAuthEnv(c)
const session_jwt = getCookie(c, env.OIDC_COOKIE_NAME)
if (session_jwt !== undefined) { if (session_jwt !== undefined) {
const env = getOidcAuthEnv(c) deleteCookie(c, env.OIDC_COOKIE_NAME, { path: env.OIDC_COOKIE_PATH })
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' })
const auth = await verify(session_jwt, env.OIDC_AUTH_SECRET) const auth = await verify(session_jwt, env.OIDC_AUTH_SECRET)
if (auth.rtk !== undefined && auth.rtk !== '') { if (auth.rtk !== undefined && auth.rtk !== '') {
// revoke refresh token // revoke refresh token
@ -269,7 +277,7 @@ const generateAuthorizationRequestUrl = async (
throw new HTTPException(500, { throw new HTTPException(500, {
message: 'The supported scopes information is not provided by the IdP', message: 'The supported scopes information is not provided by the IdP',
}) })
} else if (env.OIDC_SCOPES != null) { } else if (env.OIDC_SCOPES !== '') {
for (const scope of env.OIDC_SCOPES.split(' ')) { for (const scope of env.OIDC_SCOPES.split(' ')) {
if (as.scopes_supported.indexOf(scope) === -1) { if (as.scopes_supported.indexOf(scope) === -1) {
throw new HTTPException(500, { throw new HTTPException(500, {
@ -397,7 +405,7 @@ export const oidcAuthMiddleware = (): MiddlewareHandler => {
return c.redirect(url) return c.redirect(url)
} }
} catch (e) { } catch (e) {
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' }) deleteCookie(c, env.OIDC_COOKIE_NAME, { path: env.OIDC_COOKIE_PATH })
throw new HTTPException(500, { message: 'Invalid session' }) throw new HTTPException(500, { message: 'Invalid session' })
} }
await next() await next()
@ -405,8 +413,8 @@ export const oidcAuthMiddleware = (): MiddlewareHandler => {
// Workaround to set the session cookie when the response is returned by the origin server // Workaround to set the session cookie when the response is returned by the origin server
const session_jwt = c.get('oidcAuthJwt') const session_jwt = c.get('oidcAuthJwt')
if (session_jwt !== undefined) { if (session_jwt !== undefined) {
setCookie(c, oidcAuthCookieName, session_jwt, { setCookie(c, env.OIDC_COOKIE_NAME, session_jwt, {
path: env.OIDC_COOKIE_PATH ?? '/', path: env.OIDC_COOKIE_PATH,
httpOnly: true, httpOnly: true,
secure: true, secure: true,
}) })

View File

@ -186,6 +186,7 @@ beforeEach(() => {
process.env.OIDC_AUTH_EXPIRES = MOCK_AUTH_EXPIRES process.env.OIDC_AUTH_EXPIRES = MOCK_AUTH_EXPIRES
delete process.env.OIDC_SCOPES delete process.env.OIDC_SCOPES
delete process.env.OIDC_COOKIE_PATH delete process.env.OIDC_COOKIE_PATH
delete process.env.OIDC_COOKIE_NAME
}) })
describe('oidcAuthMiddleware()', () => { describe('oidcAuthMiddleware()', () => {
test('Should respond with 200 OK if session is active', async () => { test('Should respond with 200 OK if session is active', async () => {
@ -374,6 +375,21 @@ describe('processOAuthCallback()', () => {
) )
expect(res.headers.get('location')).toBe('http://localhost/1234') expect(res.headers.get('location')).toBe('http://localhost/1234')
}) })
test('Should respond with custom cookie name', async () => {
const MOCK_COOKIE_NAME = (process.env.OIDC_COOKIE_NAME = 'custom-auth-cookie')
const req = new Request(`${MOCK_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, {
method: 'GET',
headers: {
cookie: `state=${MOCK_STATE}; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
},
})
const res = await app.request(req, {}, {})
expect(res).not.toBeNull()
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`)
)
})
test('Should return an error if the state parameter does not match', async () => { test('Should return an error if the state parameter does not match', async () => {
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',