feat(oidc-auth): optional cookie name (#789)
parent
ee5d7e0e74
commit
68eec9e2bc
|
@ -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')
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in New Issue