diff --git a/.changeset/gorgeous-berries-float.md b/.changeset/gorgeous-berries-float.md new file mode 100644 index 00000000..91fe961a --- /dev/null +++ b/.changeset/gorgeous-berries-float.md @@ -0,0 +1,5 @@ +--- +'@hono/oidc-auth': minor +--- + +define custom scope, access oauth response and set custom session claims diff --git a/packages/oidc-auth/README.md b/packages/oidc-auth/README.md index 63d6390e..2ed2bb90 100644 --- a/packages/oidc-auth/README.md +++ b/packages/oidc-auth/README.md @@ -25,13 +25,13 @@ This middleware requires the following features for the IdP: Here is a list of the IdPs that I have tested: -| IdP Name | OpenID issuer URL | -| ---- | ---- | -| Auth0 | `https://..auth0.com` | +| IdP Name | OpenID issuer URL | +| ----------- | --------------------------------------------------------- | +| Auth0 | `https://..auth0.com` | | AWS Cognito | `https://cognito-idp..amazonaws.com/` | -| GitLab | `https://gitlab.com` | -| Google | `https://accounts.google.com` | -| Slack | `https://slack.com` | +| GitLab | `https://gitlab.com` | +| Google | `https://accounts.google.com` | +| Slack | `https://slack.com` | ## Installation @@ -43,22 +43,23 @@ npm i hono @hono/oidc-auth The middleware requires the following environment variables to be set: -| Environment 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) | -| OIDC_AUTH_EXPIRES | The interval (in seconds) after which the session should be considered expired. Once expired, the user will be redirected to the IdP for re-authentication. | 60 * 60 * 24 (1 day) | -| OIDC_ISSUER | The issuer URL of the OpenID Connect (OIDC) discovery. This URL is used to retrieve the OIDC provider's configuration. | None, must be provided | -| OIDC_CLIENT_ID | The OAuth 2.0 client ID assigned to your application. This ID is used to identify your application to the OIDC provider. | None, must be provided | -| OIDC_CLIENT_SECRET | The OAuth 2.0 client secret assigned to your application. This secret is used to authenticate your application to 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_COOKIE_PATH | The path to which the `oidc-auth` cookie is set. Restrict to not send it with every request to your domain | / | +| Environment 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) | +| OIDC_AUTH_EXPIRES | The interval (in seconds) after which the session should be considered expired. Once expired, the user will be redirected to the IdP for re-authentication. | 60 _ 60 _ 24 (1 day) | +| OIDC_ISSUER | The issuer URL of the OpenID Connect (OIDC) discovery. This URL is used to retrieve the OIDC provider's configuration. | None, must be provided | +| OIDC_CLIENT_ID | The OAuth 2.0 client ID assigned to your application. This ID is used to identify your application to the OIDC provider. | None, must be provided | +| OIDC_CLIENT_SECRET | The OAuth 2.0 client secret assigned to your application. This secret is used to authenticate your application to 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_COOKIE_PATH | The path to which the `oidc-auth` cookie is set. Restrict to not send it with every request to your domain | / | ## How to Use ```typescript import { Hono } from 'hono' -import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback } from '@hono/oidc-auth'; +import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback } from '@hono/oidc-auth' const app = new Hono() @@ -82,7 +83,7 @@ export default app ```typescript import { Hono } from 'hono' -import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth'; +import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth' const app = new Hono() @@ -92,15 +93,48 @@ app.get('*', async (c) => { if (!auth?.email.endsWith('@example.com')) { return c.text('Unauthorized', 401) } - const response = await c.env.ASSETS.fetch(c.req.raw); + const response = await c.env.ASSETS.fetch(c.req.raw) // clone the response to return a response with modifiable headers const newResponse = new Response(response.body, response) return newResponse -}); +}) export default app ``` +## Using original response or additional claims + +```typescript +import type { IDToken, OidcAuth, TokenEndpointResponses } from '@hono/oidc-auth'; +import { processOAuthCallback } from '@hono/oidc-auth'; +import type { Context, OidcAuthClaims } from 'hono'; + +declare module 'hono' { + interface OidcAuthClaims { + name: string + sub: string + } +} + +const oidcClaimsHook = async (orig: OidcAuth | undefined, claims: IDToken | undefined, _response: TokenEndpointResponses): Promise => { + /* + const { someOtherInfo } = await fetch(c.get('oidcAuthorizationServer').userinfo_endpoint, { + header: _response.access_token + }).then((res) => res.json()) + */ + return { + name: claims?.name as string ?? orig?.name ?? '', + sub: claims?.sub ?? orig?.sub ?? '' + }; +}), +... +app.get('/callback', async (c) => { + c.set('oidcClaimsHook', oidcClaimsHook); // also assure to set before any getAuth(), in case the token is refreshed + return processOAuthCallback(c); +}) +... +``` + 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. diff --git a/packages/oidc-auth/src/index.ts b/packages/oidc-auth/src/index.ts index bcf67cdd..0fb1a0d1 100644 --- a/packages/oidc-auth/src/index.ts +++ b/packages/oidc-auth/src/index.ts @@ -2,7 +2,7 @@ * OpenID Connect authentication middleware for hono */ -import type { Context, MiddlewareHandler } from 'hono' +import type { Context, MiddlewareHandler, OidcAuthClaims } from 'hono' import { env } from 'hono/adapter' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { createMiddleware } from 'hono/factory' @@ -10,13 +10,27 @@ import { HTTPException } from 'hono/http-exception' import { sign, verify } from 'hono/jwt' import * as oauth2 from 'oauth4webapi' +export type IDToken = oauth2.IDToken +export type TokenEndpointResponses = + | oauth2.OpenIDTokenEndpointResponse + | oauth2.TokenEndpointResponse +export type OidcClaimsHook = ( + orig: OidcAuth | undefined, + claims: IDToken | undefined, + response: TokenEndpointResponses +) => Promise + declare module 'hono' { + export interface OidcAuthClaims { + readonly [claim: string]: oauth2.JsonValue | undefined + } interface ContextVariableMap { oidcAuthEnv: OidcAuthEnv oidcAuthorizationServer: oauth2.AuthorizationServer oidcClient: oauth2.Client oidcAuth: OidcAuth | null oidcAuthJwt: string + oidcClaimsHook?: OidcClaimsHook } } @@ -25,12 +39,10 @@ const defaultRefreshInterval = 15 * 60 // 15 minutes const defaultExpirationInterval = 60 * 60 * 24 // 1 day export type OidcAuth = { - sub: string - email: string rtk: string // refresh token rtkexp: number // token expiration time ; refresh token if it's expired ssnexp: number // session expiration time; if it's expired, revoke session and redirect to IdP -} +} & OidcAuthClaims type OidcAuthEnv = { OIDC_AUTH_SECRET: string @@ -40,6 +52,7 @@ type OidcAuthEnv = { OIDC_CLIENT_ID: string OIDC_CLIENT_SECRET: string OIDC_REDIRECT_URI: string + OIDC_SCOPES?: string OIDC_COOKIE_PATH?: string } @@ -114,7 +127,7 @@ export const getClient = (c: Context): oauth2.Client => { */ export const getAuth = async (c: Context): Promise => { const env = getOidcAuthEnv(c) - let auth = c.get('oidcAuth') + let auth: Partial | null = c.get('oidcAuth') if (auth === undefined) { const session_jwt = getCookie(c, oidcAuthCookieName) if (session_jwt === undefined) { @@ -150,11 +163,11 @@ export const getAuth = async (c: Context): Promise => { deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' }) return null } - auth = await updateAuth(c, auth, result) + auth = await updateAuth(c, auth as OidcAuth, result) } - c.set('oidcAuth', auth) + c.set('oidcAuth', auth as OidcAuth) } - return auth + return auth as OidcAuth } /** @@ -173,15 +186,22 @@ const setAuth = async ( const updateAuth = async ( c: Context, orig: OidcAuth | undefined, - response: oauth2.OpenIDTokenEndpointResponse | oauth2.TokenEndpointResponse + response: TokenEndpointResponses ): Promise => { const env = getOidcAuthEnv(c) const claims = oauth2.getValidatedIdTokenClaims(response) const authRefreshInterval = Number(env.OIDC_AUTH_REFRESH_INTERVAL!) || defaultRefreshInterval const authExpires = Number(env.OIDC_AUTH_EXPIRES!) || defaultExpirationInterval - const updated: OidcAuth = { - sub: claims?.sub || orig?.sub || '', - email: (claims?.email as string) || orig?.email || '', + const claimsHook: OidcClaimsHook = + c.get('oidcClaimsHook') ?? + (async (orig, claims) => { + return { + sub: claims?.sub || orig?.sub || '', + email: (claims?.email as string) || orig?.email || '', + } + }) + const updated = { + ...(await claimsHook(orig, claims, response)), rtk: response.refresh_token || orig?.rtk || '', rtkexp: Math.floor(Date.now() / 1000) + authRefreshInterval, ssnexp: orig?.ssnexp || Math.floor(Date.now() / 1000) + authExpires, @@ -249,12 +269,17 @@ const generateAuthorizationRequestUrl = async ( throw new HTTPException(500, { message: 'The supported scopes information is not provided by the IdP', }) - } else if (as.scopes_supported.indexOf('email') === -1) { - throw new HTTPException(500, { message: 'The "email" scope is not supported by the IdP' }) - } else if (as.scopes_supported.indexOf('offline_access') === -1) { - authorizationRequestUrl.searchParams.set('scope', 'openid email') + } else if (env.OIDC_SCOPES != null) { + for (const scope of env.OIDC_SCOPES.split(' ')) { + if (as.scopes_supported.indexOf(scope) === -1) { + throw new HTTPException(500, { + message: `The '${scope}' scope is not supported by the IdP`, + }) + } + } + authorizationRequestUrl.searchParams.set('scope', env.OIDC_SCOPES) } else { - authorizationRequestUrl.searchParams.set('scope', 'openid email offline_access') + authorizationRequestUrl.searchParams.set('scope', as.scopes_supported.join(' ')) } authorizationRequestUrl.searchParams.set('state', state) authorizationRequestUrl.searchParams.set('nonce', nonce) diff --git a/packages/oidc-auth/test/index.test.ts b/packages/oidc-auth/test/index.test.ts index c77ab35d..d28e8c46 100644 --- a/packages/oidc-auth/test/index.test.ts +++ b/packages/oidc-auth/test/index.test.ts @@ -10,6 +10,7 @@ const MOCK_CLIENT_SECRET = 'CLIENT_SECRET_001' const MOCK_REDIRECT_URI = 'http://localhost/callback' const MOCK_SUBJECT = 'USER_ID_001' const MOCK_EMAIL = 'user001@example.com' +const MOCK_NAME = 'John Doe' const MOCK_STATE = crypto.randomBytes(16).toString('hex') // 32 bytes const MOCK_NONCE = crypto.randomBytes(16).toString('hex') // 32 bytes const MOCK_AUTH_SECRET = crypto.randomBytes(16).toString('hex') // 32 bytes @@ -19,6 +20,8 @@ const MOCK_ID_TOKEN = jwt.sign( iss: MOCK_ISSUER, aud: MOCK_CLIENT_ID, sub: MOCK_SUBJECT, + email: MOCK_EMAIL, + name: MOCK_NAME, exp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes nonce: MOCK_NONCE, }, @@ -153,13 +156,21 @@ jest.unstable_mockModule('oauth4webapi', () => { } }) -const { oidcAuthMiddleware, getAuth, revokeSession } = await import('../src') +const { oidcAuthMiddleware, getAuth, processOAuthCallback, revokeSession } = await import('../src') const app = new Hono() app.get('/logout', async (c) => { await revokeSession(c) return c.text('OK') }) +app.get('/callback-custom', async (c) => { + c.set('oidcClaimsHook', async (orig, claims, response) => ({ + name: (claims?.name as string) ?? orig?.name ?? '', + sub: claims?.sub ?? orig?.sub ?? '', + token: response.access_token, + })) + return processOAuthCallback(c) +}) app.use('/*', oidcAuthMiddleware()) app.all('/*', async (c) => { const auth = await getAuth(c) @@ -173,6 +184,7 @@ beforeEach(() => { process.env.OIDC_REDIRECT_URI = MOCK_REDIRECT_URI process.env.OIDC_AUTH_SECRET = MOCK_AUTH_SECRET process.env.OIDC_AUTH_EXPIRES = MOCK_AUTH_EXPIRES + delete process.env.OIDC_SCOPES delete process.env.OIDC_COOKIE_PATH }) describe('oidcAuthMiddleware()', () => { @@ -199,6 +211,22 @@ describe('oidcAuthMiddleware()', () => { ) }) test('Should redirect to authorization endpoint if session is expired', async () => { + const req = new Request('http://localhost/', { + method: 'GET', + headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` }, + }) + const res = await app.request(req, {}, {}) + expect(res).not.toBeNull() + expect(res.status).toBe(302) + expect(res.headers.get('location')).toMatch(/scope=openid(%20|\+)email(%20|\+)profile&/) + expect(res.headers.get('location')).toMatch('access_type=offline&prompt=consent') + expect(res.headers.get('set-cookie')).toMatch(`state=${MOCK_STATE}`) + expect(res.headers.get('set-cookie')).toMatch(`nonce=${MOCK_NONCE}`) + expect(res.headers.get('set-cookie')).toMatch('code_verifier=') + expect(res.headers.get('set-cookie')).toMatch('continue=http%3A%2F%2Flocalhost%2F') + }) + test('Should use custom scope, if defined', async () => { + process.env.OIDC_SCOPES = 'openid email' const req = new Request('http://localhost/', { method: 'GET', headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` }, @@ -213,6 +241,16 @@ describe('oidcAuthMiddleware()', () => { expect(res.headers.get('set-cookie')).toMatch('code_verifier=') expect(res.headers.get('set-cookie')).toMatch('continue=http%3A%2F%2Flocalhost%2F') }) + test('Custom scope is limited to supported scopes', async () => { + process.env.OIDC_SCOPES = 'openid email salary' + const req = new Request('http://localhost/', { + method: 'GET', + headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` }, + }) + const res = await app.request(req, {}, {}) + expect(res).not.toBeNull() + expect(res.status).toBe(500) + }) test('Should redirect to authorization endpoint if no session cookie is found', async () => { const req = new Request('http://localhost/', { method: 'GET', @@ -251,18 +289,62 @@ describe('processOAuthCallback()', () => { }, }) const res = await app.request(req, {}, {}) + const { email, name, sub } = JSON.parse( + atob( + res.headers + .get('set-cookie') + ?.match(/oidc-auth=[^;]+/)?.[0] + ?.split('.')[1] as string + ) + ) + expect(sub).toBe(MOCK_SUBJECT) + expect(email).toBe(MOCK_EMAIL) + expect(name).toBeUndefined() + expect(res).not.toBeNull() + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('http://localhost/1234') + }) + test('Should respond with customized claims', async () => { + const req = new Request(`${MOCK_REDIRECT_URI}-custom?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, {}, {}) + const { email, name, sub } = JSON.parse( + atob( + res.headers + .get('set-cookie') + ?.match(/oidc-auth=[^;]+/)?.[0] + ?.split('.')[1] as string + ) + ) + expect(sub).toBe(MOCK_SUBJECT) + expect(email).toBeUndefined() + expect(name).toBe(MOCK_NAME) const path = new URL(MOCK_REDIRECT_URI).pathname expect(res).not.toBeNull() expect(res.status).toBe(302) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`state=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=[^;]+; Path=/; HttpOnly; Secure')) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`state=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp('oidc-auth=[^;]+; Path=/; HttpOnly; Secure') + ) expect(res.headers.get('location')).toBe('http://localhost/1234') }) test('Should restrict the auth cookie to a given path', async () => { - const MOCK_COOKIE_PATH = process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication' + const MOCK_COOKIE_PATH = (process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication') process.env.OIDC_REDIRECT_URI = `http://localhost${MOCK_COOKIE_PATH}/callback` const parentApp = new Hono().route(MOCK_COOKIE_PATH, app) const path = new URL(process.env.OIDC_REDIRECT_URI).pathname @@ -275,11 +357,21 @@ describe('processOAuthCallback()', () => { const res = await parentApp.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(302) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`state=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`)) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`oidc-auth=[^;]+; Path=${process.env.OIDC_COOKIE_PATH}; HttpOnly; Secure`)) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`state=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`) + ) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`oidc-auth=[^;]+; Path=${process.env.OIDC_COOKIE_PATH}; HttpOnly; Secure`) + ) expect(res.headers.get('location')).toBe('http://localhost/1234') }) test('Should return an error if the state parameter does not match', async () => { @@ -329,7 +421,7 @@ describe('RevokeSession()', () => { expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)')) }) test('Should revoke the session of the given path', async () => { - const MOCK_COOKIE_PATH = process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication' + const MOCK_COOKIE_PATH = (process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication') const parentApp = new Hono().route(MOCK_COOKIE_PATH, app) const req = new Request(`http://localhost${MOCK_COOKIE_PATH}/logout`, { method: 'GET', @@ -338,6 +430,8 @@ describe('RevokeSession()', () => { const res = await parentApp.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(200) - expect(res.headers.get('set-cookie')).toMatch(new RegExp(`oidc-auth=; Max-Age=0; Path=${MOCK_COOKIE_PATH}($|,)`)) + expect(res.headers.get('set-cookie')).toMatch( + new RegExp(`oidc-auth=; Max-Age=0; Path=${MOCK_COOKIE_PATH}($|,)`) + ) }) })