import { jest } from '@jest/globals' import { Hono } from 'hono' import jwt from 'jsonwebtoken' import * as oauth2 from 'oauth4webapi' import crypto from 'node:crypto' const MOCK_ISSUER = 'https://accounts.google.com' const MOCK_CLIENT_ID = 'CLIENT_ID_001' 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 const MOCK_AUTH_EXPIRES = '3600' 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, }, `-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDDp5RtVoTDMre1 HZrPhMr3ic1fQPqRWnKs6f27DoBxA8JOsaHE15ApnLBlDLWKnoLoCNrHCuYGoh/+ WQuxS5LtyZb7Goe1DXjdoEohLjZS1kW0+PgDRCpzon1XHdjo5LdPV+ImhxSxeIFd vn4NDEhQa5uKKCSZblvz3PgKR36AEM1313qcewgt5YgkQAvaBZFDI5C+FId8w8hf rYL0W9GPGu4D/gfXHi74habcSni/HkEtRaqsnDh6JAi6pGZNeUnzeNHTcfFqj0ce SSYTQNJhLqFrRUCLa9kQSOMcxRFBiUAcRGgjXiB4r9vZpIa1H3RGUNf2wOOxVxLF dIl7dEzrAgMBAAECggEASjF+I4gviCXvbArx7ceZgA0NiBWH7x6xZcjFou1432Jh iJ3rjk2AKYd1jJwpK4u4cG0LKXeEivdn0nfJ602RRgKv8kC5PXsCXmiuM67mgrsm a94Njo+G2ZrAlQyIeKhiqv/Ujm+i9TmRNQ9LlX8W3QgxT06xsk0bKXqdxKgf3EfY MUAQkD2sH7i/Wn2fBj9b6wdTsWQC7SRC7UgTvadvNoinILVyZxwjYY/3BZQRbMUm 687oCLSBeNdixxF9Ip1uvtNPBap6lvkZp1U9y1iteSftcLYd82ZvTvc4qjB800XU RQRSkF5VddtHgT2kManF3hGjJHKeHsCaY6VyLxn5IQKBgQD42QtVweayQCTgL2VO V+/SpP68hLTO4sfUz2FCuaW4F+6wvs0D3NUw7ZheCtuxr3enoIr5mrYx3BdR79ge wQYBppofzXPXPKPQFtmHT3cwLXpwB6knyEMyJ3WpBVAPgcoJbYUOIRcO2eBgMxGR 9XMOU9FKLAsZ5TgDlJqd5pubZwKBgQDJRydu5OYKDtSRLbSxhOdJFF2fBb+ZcIAM 7exmaFUjQHSTiYr5DYraDdvue7JcvxIQFWJFCYdRGacRX9Rvjnvr1gRBHVb5+/FP OLowCzWz+7F86MkQYd9SgmhD7MIG8XPlxR13NyIMglS49O6euNrB6oKCEYWDE8nB ZS6TUSWT3QKBgQCs/nYa0AmIsX7xOwG6TPe0AG/2rmrjyFQTZXe/4z+Jk1mkFYCA xuyObx4VgoboJ4uPRNRYYW13jAHKPGqKNrXuP9u1cCav4sAe0UO4BU5ed78+UpUN yvKr0zLApajanufNVg3BnM9iy6RoPBhi17d8plhAsA2nmuot0wkJ7F8Q0QKBgECo f+1q0M84VmbQ1PQV6qqaRTz5fsRO1IPSxpdbOsZZRVnD3IYHKKzFuPoSeIi8xJOw GuJsnjCaWgYFz9uKXRq0pKc6Qp+JpMo7Qex/HWBVIX4r1bNSjYgW5mGzo9zRIdcV DFMoveJg19CWtjT80yFqMUSRVl92MuDSnTSr47NtAoGBAJGu7TU6+qRGN6Bp38+A jc1vz90U86BT9PnNbCzmVP3xdRfLLGZm6JWCVYNt1mm3/KAyK8bkviw4bHilcIMj HfRCXKFdIfHsYcxAhUkKDpNguFv06xjbrlP6vPkqkp/4Td4sGQ8dAVmrcwpdv56o UlMwcdSLCKw3qpSJOA08k7pz -----END PRIVATE KEY-----`, { algorithm: 'RS256' } ) const MOCK_JWT_ACTIVE_SESSION = jwt.sign( { sub: MOCK_SUBJECT, email: MOCK_EMAIL, rtk: 'DUMMY_REFRESH_TOKEN', rtkexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes }, MOCK_AUTH_SECRET, { algorithm: 'HS256', expiresIn: '1h' } ) const MOCK_JWT_TOKEN_EXPIRED_SESSION = jwt.sign( { sub: MOCK_SUBJECT, email: MOCK_EMAIL, rtk: 'DUMMY_REFRESH_TOKEN', rtkexp: Math.floor(Date.now() / 1000) - 1, // expired ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes }, MOCK_AUTH_SECRET, { algorithm: 'HS256', expiresIn: '1h' } ) const MOCK_JWT_EXPIRED_SESSION = jwt.sign( { sub: MOCK_SUBJECT, email: MOCK_EMAIL, rtk: 'DUMMY_REFRESH_TOKEN', rtkexp: Math.floor(Date.now() / 1000) - 1, // expired ssnexp: Math.floor(Date.now() / 1000) - 1, // expired }, MOCK_AUTH_SECRET, { algorithm: 'HS256', expiresIn: '1h' } ) const MOCK_JWT_INCORRECT_SECRET = jwt.sign( { sub: MOCK_SUBJECT, email: MOCK_EMAIL, rtk: 'DUMMY_REFRESH_TOKEN', rtkexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes }, 'incorrect-secret', { algorithm: 'HS256', expiresIn: '1h' } ) const MOCK_JWT_INVALID_ALGORITHM = jwt.sign( { sub: MOCK_SUBJECT, email: MOCK_EMAIL, rtk: 'DUMMY_REFRESH_TOKEN', rtkexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes }, null, { algorithm: 'none', expiresIn: '1h' } ) jest.unstable_mockModule('oauth4webapi', () => { return { ...oauth2, discoveryRequest: jest.fn(async () => { return new Response( JSON.stringify({ issuer: MOCK_ISSUER, authorization_endpoint: `${MOCK_ISSUER}/auth`, token_endpoint: `${MOCK_ISSUER}/token`, revocation_endpoint: `${MOCK_ISSUER}/revoke`, scopes_supported: ['openid', 'email', 'profile'], }) ) }), generateRandomState: jest.fn(() => MOCK_STATE), generateRandomNonce: jest.fn(() => MOCK_NONCE), authorizationCodeGrantRequest: jest.fn(async () => { return new Response( JSON.stringify({ access_token: 'DUMMY_ACCESS_TOKEN', expires_in: 3599, refresh_token: 'DUUMMY_REFRESH_TOKEN', scope: 'https://www.googleapis.com/auth/userinfo.email openid', token_type: 'Bearer', id_token: MOCK_ID_TOKEN, }) ) }), refreshTokenGrantRequest: jest.fn(async () => { return new Response( JSON.stringify({ access_token: 'DUMMY_ACCESS_TOKEN', expires_in: 3599, refresh_token: 'DUUMMY_REFRESH_TOKEN_RENEWED', scope: 'https://www.googleapis.com/auth/userinfo.email openid', token_type: 'Bearer', id_token: MOCK_ID_TOKEN, }) ) }), revocationRequest: jest.fn(async () => { return new Response(JSON.stringify({})) }), } }) 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) return c.text(`Hello ${auth?.email}! Refresh token: ${auth?.rtk}`) }) beforeEach(() => { process.env.OIDC_ISSUER = MOCK_ISSUER process.env.OIDC_CLIENT_ID = MOCK_CLIENT_ID process.env.OIDC_CLIENT_SECRET = MOCK_CLIENT_SECRET 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 delete process.env.OIDC_COOKIE_NAME delete process.env.OIDC_COOKIE_DOMAIN }) describe('oidcAuthMiddleware()', () => { test('Should respond with 200 OK if session is active', async () => { 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(200) expect(await res.text()).toBe(`Hello ${MOCK_EMAIL}! Refresh token: DUMMY_REFRESH_TOKEN`) }) test('Should respond with 200 OK with renewed refresh token', async () => { const req = new Request('http://localhost/', { method: 'GET', headers: { cookie: `oidc-auth=${MOCK_JWT_TOKEN_EXPIRED_SESSION}` }, }) const res = await app.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe( `Hello ${MOCK_EMAIL}! Refresh token: DUUMMY_REFRESH_TOKEN_RENEWED` ) }) 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}` }, }) 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&/) 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('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', }) const res = await app.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(302) }) test('Should delete session and redirect to authorization endpoint if the key of the session JWT is icorrect', async () => { const req = new Request('http://localhost/', { method: 'GET', headers: { cookie: `oidc-auth=${MOCK_JWT_INCORRECT_SECRET}` }, }) const res = await app.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(302) expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)')) }) test('Should delete session and redirect to authorization endpoint if the algorithm of the session JWT is invalid', async () => { const req = new Request('http://localhost/', { method: 'GET', headers: { cookie: `oidc-auth=${MOCK_JWT_INVALID_ALGORITHM}` }, }) const res = await app.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(302) expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)')) }) test('Should Domain attribute of the cookie not set if env value not defined', 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('set-cookie')).not.toMatch('Domain=') }) test('Should Domain attribute of the cookie set if env value defined (with renewed refresh token)', async () => { const MOCK_COOKIE_DOMAIN = (process.env.OIDC_COOKIE_DOMAIN = 'example.com') const req = new Request('http://localhost/', { method: 'GET', headers: { cookie: `oidc-auth=${MOCK_JWT_TOKEN_EXPIRED_SESSION}` }, }) const res = await app.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.headers.get('set-cookie')).toMatch(`Domain=${MOCK_COOKIE_DOMAIN}`) }) test('Should Domain attribute of the cookie set if env value defined (if session is expired)', async () => { const MOCK_COOKIE_DOMAIN = (process.env.OIDC_COOKIE_DOMAIN = 'example.com') 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('set-cookie')).toMatch(`Domain=${MOCK_COOKIE_DOMAIN}`) }) }) describe('processOAuthCallback()', () => { test('Should successfully process the OAuth2.0 callback and redirect to the continue URL', async () => { 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, {}, {}) 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('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') 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 const req = new Request(`${process.env.OIDC_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 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('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 () => { const req = new Request(`${MOCK_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, { method: 'GET', headers: { cookie: `state=abcd; 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(500) }) test('Should return an error if the code parameter is missing', async () => { const req = new Request(`${MOCK_REDIRECT_URI}?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(500) }) test('Should return an error if received OAuth2.0 error', async () => { const req = new Request( `${MOCK_REDIRECT_URI}?error=invalid_grant&error_description=Bad+Request&state=1234`, { method: 'GET', headers: { cookie: 'state=1234; nonce=1234; code_verifier=1234' }, } ) const res = await app.request(req, {}, {}) expect(res).not.toBeNull() expect(res.status).toBe(500) }) }) describe('RevokeSession()', () => { test('Should successfully revoke the session', async () => { const req = new Request('http://localhost/logout', { method: 'GET', headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` }, }) const res = await app.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=/($|,)')) }) test('Should revoke the session of the given path', async () => { 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', headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` }, }) 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}($|,)`) ) }) })