feat: Add OpenID Connect authentication middleware (#372)

* Add OpenID Connect authentication middleware

* add ci

* use tsup to build and use `module`

* update `yarn.lock`

* update `yarn.lock`

* Add `configureOidcAuth()` and remove the use of environment variables / Replace `Error` to `HTTPException` / Fix README / Fix version to 0.0.0

* Use environment variables for configuration of the middleware and remove `configureOidcAuth()` / Fix README

* chore: update `peerDependencies` and use latest hono

---------

Co-authored-by: Yusuke Wada <yusuke@kamawada.com>
pull/385/head
Yoshio HANAWA 2024-02-13 11:14:48 +09:00 committed by GitHub
parent 599e5f5058
commit 7777562f64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 858 additions and 4 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/oidc-auth': major
---
Releasing first version

View File

@ -0,0 +1,25 @@
name: ci-oidc-auth
on:
push:
branches: [main]
paths:
- 'packages/oidc-auth/**'
pull_request:
branches: ['*']
paths:
- 'packages/oidc-auth/**'
jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/oidc-auth
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install --frozen-lockfile
- run: yarn build
- run: yarn test

View File

@ -0,0 +1,113 @@
# OpenID Connect Authentication middleware for Hono
This is an OpenID Connect (OIDC) authentication third-party middleware for [Hono](https://github.com/honojs/hono), which depends on [oauth4webapi](https://www.npmjs.com/package/oauth4webapi).
This middleware provides storage-less login sessions.
## How does it work?
1. The middleware checks if the session cookie exists.
2. If the session cookie does not exist, the middleware redirects the user to the IdP's authentication endpoint.
3. The user is authenticated by the IdP, then the IdP redirects the user back to the application.
4. The middleware exchanges the authorization code for a refresh token and generates a session cookie as a JWT token. The token is signed with a symmetric key and verified at the edge, making it tamper-proof.
5. The middleware sends a session cookie to the user's browser with a Set-Cookie header.
6. The browser sends the session cookie with each request and the middleware validates the session.
7. After the refresh interval (default set to 15 minutes), the middleware implicitly accesses the IdP's token endpoint using the refresh token to verify that the user is still authenticated and regenerates the session cookie.
8. If the session is expired (default set to 1 day), the middleware revokes the refresh token and redirects the user to the IdP's authentication endpoint. Continue to step 3.
## Supported Identity Providers (IdPs)
This middleware requires the following features for the IdP:
- Supports OpenID Connect
- Provides the discovery endpoint
- Provides the refresh token
Here is a list of the IdPs that I have tested:
| IdP Name | OpenID issuer URL |
| ---- | ---- |
| Auth0 | `https://<tenant>.<region>.auth0.com` |
| AWS Cognito | `https://cognito-idp.<region>.amazonaws.com/<userPoolID>` |
| GitLab | `https://gitlab.com` |
| Google | `https://accounts.google.com` |
| Slack | `https://slack.com` |
## Installation
```plain
npm i hono @hono/oidc-auth
```
## Configuration
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 |
## How to Use
```typescript
import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback } from '@hono/oidc-auth';
const app = new Hono()
app.get('/logout', async (c) => {
await revokeSession(c)
return c.text('You have been successfully logged out!')
})
app.get('/callback', async (c) => {
return processOAuthCallback(c)
})
app.use('*', oidcAuthMiddleware())
app.get('/', async (c) => {
const auth = await getAuth(c)
return c.text(`Hello <${auth?.email}>!`)
})
export default app
```
## Another example: Cloudflare Pages with OpenID connect login
```typescript
import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth';
const app = new Hono()
app.use('*', oidcAuthMiddleware())
app.get('*', async (c) => {
const auth = await getAuth(c)
if (!auth?.email.endsWith('@example.com')) {
return c.text('Unauthorized', 401)
}
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
```
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.
## Author
Yoshio HANAWA <https://github.com/hnw>
## License
MIT

View File

@ -0,0 +1,6 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
collectCoverage: true,
}

View File

@ -0,0 +1,51 @@
{
"name": "@hono/oidc-auth",
"version": "0.0.0",
"description": "OpenID Connect Authentication middleware for Hono",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose --coverage",
"build": "tsup ./src/index.ts --format esm,cjs --dts",
"prerelease": "yarn build && yarn test",
"release": "yarn publish"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"hono": ">=3.*"
},
"devDependencies": {
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"hono": "^4.0.1",
"jest": "^29.7.0",
"jsonwebtoken": "^9.0.2",
"ts-jest": "^29.1.1",
"tsup": "^8.0.1",
"typescript": "^5.3.3"
},
"dependencies": {
"oauth4webapi": "^2.6.0"
}
}

View File

@ -0,0 +1,341 @@
/**
* OpenID Connect authentication middleware for hono
*/
import type { Context, MiddlewareHandler } from 'hono'
import { createMiddleware } from 'hono/factory'
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
import { sign, verify } from 'hono/jwt'
import { HTTPException } from 'hono/http-exception'
import { env } from 'hono/adapter'
import * as oauth2 from 'oauth4webapi'
declare module 'hono' {
interface ContextVariableMap {
oidcAuthEnv: OidcAuthEnv
oidcAuthorizationServer : oauth2.AuthorizationServer
oidcClient: oauth2.Client
oidcAuth: OidcAuth | null
oidcAuthJwt: string
}
}
const oidcAuthCookieName = 'oidc-auth'
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
}
type OidcAuthEnv = {
OIDC_AUTH_SECRET : string
OIDC_AUTH_REFRESH_INTERVAL? : string
OIDC_AUTH_EXPIRES? : string
OIDC_ISSUER : string
OIDC_CLIENT_ID : string
OIDC_CLIENT_SECRET : string
OIDC_REDIRECT_URI : string
}
/**
* 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' })
}
if (oidcAuthEnv.OIDC_REDIRECT_URI === undefined) {
throw new HTTPException(500, { message: 'OIDC redirect URI is not provided' })
}
c.set('oidcAuthEnv', oidcAuthEnv)
}
return oidcAuthEnv
}
/**
* Returns the OAuth2 authorization server metadata.
* If the metadata is not cached, it will be retrieved from the discovery endpoint.
*/
export const getAuthorizationServer = async (c: Context) : Promise<oauth2.AuthorizationServer> => {
const env = getOidcAuthEnv(c)
let as = c.get('oidcAuthorizationServer')
if (as === undefined) {
const issuer = new URL(env.OIDC_ISSUER)
const response = await oauth2.discoveryRequest(issuer)
as = await oauth2.processDiscoveryResponse(issuer, response)
c.set('oidcAuthorizationServer', as)
}
return as
}
/**
* Returns the OAuth2 client metadata.
*/
export const getClient = (c: Context) : oauth2.Client => {
const env = getOidcAuthEnv(c)
let client = c.get('oidcClient')
if (client === undefined) {
client = {
client_id: env.OIDC_CLIENT_ID,
client_secret: env.OIDC_CLIENT_SECRET,
token_endpoint_auth_method: 'client_secret_basic',
}
c.set('oidcClient', client)
}
return client
}
/**
* Validates and parses session JWT and returns the OIDC user metadata.
* If the session is invalid or expired, revokes the session and returns null.
*/
export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
const env = getOidcAuthEnv(c)
let auth = c.get('oidcAuth')
if (auth === undefined) {
const session_jwt = getCookie(c, oidcAuthCookieName)
if (session_jwt === undefined) {
return null
}
try {
auth = await verify(session_jwt, env.OIDC_AUTH_SECRET)
} catch (e) {
deleteCookie(c, oidcAuthCookieName)
return null
}
if (auth === null || auth.rtkexp === undefined || auth.ssnexp === undefined) {
throw new HTTPException(500, { message: 'Invalid session' })
}
const now = Math.floor(Date.now() / 1000);
// Revoke the session if it has expired
if (auth.ssnexp < now) {
revokeSession(c)
return null
}
if (auth.rtkexp < now) {
// Refresh the token if it has expired
if (auth.rtk === undefined || auth.rtk === "") {
deleteCookie(c, oidcAuthCookieName)
return null
}
const as = await getAuthorizationServer(c)
const client = getClient(c)
const response = await oauth2.refreshTokenGrantRequest(as, client, auth.rtk)
const result = await oauth2.processRefreshTokenResponse(as, client, response)
if (oauth2.isOAuth2Error(result)) {
// The refresh_token might be expired or revoked
deleteCookie(c, oidcAuthCookieName)
return null
}
auth = await updateAuth(c, auth, result)
}
c.set('oidcAuth', auth)
}
return auth
}
/**
* Generates a new session JWT and sets the session cookie.
*/
const setAuth = async (c: Context, response: oauth2.OpenIDTokenEndpointResponse): Promise<OidcAuth> => {
return updateAuth(c, undefined, response)
}
/**
* Updates the session JWT and sets the new session cookie.
*/
const updateAuth = async (c: Context, orig: OidcAuth | undefined, response: oauth2.OpenIDTokenEndpointResponse | oauth2.TokenEndpointResponse): Promise<OidcAuth> => {
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 || '',
rtk: response.refresh_token || orig?.rtk || '',
rtkexp: Math.floor(Date.now() / 1000) + authRefreshInterval,
ssnexp: orig?.ssnexp || Math.floor(Date.now() / 1000) + authExpires,
}
const session_jwt = await sign(updated, env.OIDC_AUTH_SECRET)
setCookie(c, oidcAuthCookieName, session_jwt, { path: '/', httpOnly: true, secure: true })
c.set('oidcAuthJwt', session_jwt)
return updated
}
/**
* Revokes the refresh token of the current session and deletes the session cookie
*/
export const revokeSession = async (c: Context): Promise<void> => {
const session_jwt = getCookie(c, oidcAuthCookieName)
if (session_jwt !== undefined) {
const env = getOidcAuthEnv(c)
deleteCookie(c, oidcAuthCookieName)
const auth: OidcAuth = await verify(session_jwt, env.OIDC_AUTH_SECRET)
if (auth.rtk !== undefined && auth.rtk !== "") {
// revoke refresh token
const as = await getAuthorizationServer(c)
const client = getClient(c)
if (as.revocation_endpoint !== undefined) {
const response = await oauth2.revocationRequest(as, client, auth.rtk)
const result = await oauth2.processRevocationResponse(response)
if (oauth2.isOAuth2Error(result)) {
throw new HTTPException(500, { message: `OAuth2Error: [${result.error}] ${result.error_description}` })
}
}
}
}
c.set('oidcAuth', undefined)
}
/**
* Generates the authorization request URL for the OpenID Connect flow.
* @param c - The Hono context object.
* @param state - The state parameter for CSRF protection.
* @param nonce - The nonce parameter for replay attack protection.
* @param code_challenge - The code challenge for PKCE (Proof Key for Code Exchange).
* @returns The authorization request URL.
* @throws Error if OpenID Connect or email scopes are not supported by the authorization server.
*/
const generateAuthorizationRequestUrl = async (c: Context, state: string, nonce: string, code_challenge: string) => {
const env = getOidcAuthEnv(c)
const as = await getAuthorizationServer(c)
const client = getClient(c)
const authorizationRequestUrl = new URL(as.authorization_endpoint!)
authorizationRequestUrl.searchParams.set('client_id', client.client_id)
authorizationRequestUrl.searchParams.set('redirect_uri', env.OIDC_REDIRECT_URI)
authorizationRequestUrl.searchParams.set('response_type', 'code')
if (as.scopes_supported === undefined || as.scopes_supported.length === 0) {
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 {
authorizationRequestUrl.searchParams.set('scope', 'openid email offline_access')
}
authorizationRequestUrl.searchParams.set('state', state)
authorizationRequestUrl.searchParams.set('nonce', nonce)
authorizationRequestUrl.searchParams.set('code_challenge', code_challenge)
authorizationRequestUrl.searchParams.set('code_challenge_method', 'S256')
if (as.issuer === 'https://accounts.google.com') {
// Google requires 'access_type=offline' and 'prompt=consent' to obtain a refresh token
authorizationRequestUrl.searchParams.set('access_type', 'offline')
authorizationRequestUrl.searchParams.set('prompt', 'consent')
}
return authorizationRequestUrl.toString()
}
/**
* Processes the OAuth2 callback request.
*/
export const processOAuthCallback = async (c: Context) => {
const env = getOidcAuthEnv(c)
const as = await getAuthorizationServer(c)
const client = getClient(c)
// Parses the authorization response and validates the state parameter
const state = getCookie(c, 'state')
deleteCookie(c, 'state')
const currentUrl: URL = new URL(c.req.url)
const params = oauth2.validateAuthResponse(as, client, currentUrl, state)
if (oauth2.isOAuth2Error(params)) {
throw new HTTPException(500, { message: `OAuth2Error: [${params.error}] ${params.error_description}` })
}
// Exchanges the authorization code for a refresh token
const code = c.req.query('code')
const nonce = getCookie(c, 'nonce')
deleteCookie(c, 'nonce')
const code_verifier = getCookie(c, 'code_verifier')
deleteCookie(c, 'code_verifier')
const continue_url = getCookie(c, 'continue')
deleteCookie(c, 'continue')
if (code === undefined || nonce === undefined || code_verifier === undefined) {
throw new HTTPException(500, { message: 'Missing required parameters / cookies' })
}
const result = await exchangeAuthorizationCode(as, client, params, env.OIDC_REDIRECT_URI, nonce, code_verifier)
await setAuth(c, result)
return c.redirect(continue_url || '/')
}
/**
* Exchanges the authorization code for a refresh token.
*/
const exchangeAuthorizationCode = async (as: oauth2.AuthorizationServer, client: oauth2.Client, params: URLSearchParams, redirect_uri: string, nonce: string, code_verifier: string) => {
const response = await oauth2.authorizationCodeGrantRequest(
as,
client,
params,
redirect_uri,
code_verifier,
)
// Handle www-authenticate challenges
const challenges = oauth2.parseWwwAuthenticateChallenges(response)
if (challenges !== undefined) {
throw new HTTPException(500, { message: `www-authenticate error: ${JSON.stringify(challenges)}` })
}
const result = await oauth2.processAuthorizationCodeOpenIDResponse(as, client, response, nonce)
if (oauth2.isOAuth2Error(result)) {
throw new HTTPException(500, { message: `OAuth2Error: [${result.error}] ${result.error_description}` })
}
return result
}
/**
* Returns a middleware that requires OIDC authentication.
*/
export const oidcAuthMiddleware = () : MiddlewareHandler => {
return createMiddleware(async (c, next) => {
const env = getOidcAuthEnv(c)
const uri = c.req.url.split('?')[0]
if (uri === env.OIDC_REDIRECT_URI) {
return processOAuthCallback(c)
}
try {
const auth = await getAuth(c)
if (auth === null) {
// Redirect to IdP for login
const state = oauth2.generateRandomState()
const nonce = oauth2.generateRandomNonce()
const code_verifier = oauth2.generateRandomCodeVerifier()
const code_challenge = await oauth2.calculatePKCECodeChallenge(code_verifier)
const url = await generateAuthorizationRequestUrl(c, state, nonce, code_challenge)
setCookie(c, 'state', state, { path: '/' , httpOnly: true, secure: true})
setCookie(c, 'nonce', nonce, { path: '/' , httpOnly: true, secure: true})
setCookie(c, 'code_verifier', code_verifier, { path: '/' , httpOnly: true, secure: true})
setCookie(c, 'continue', c.req.url, { path: '/' , httpOnly: true, secure: true})
return c.redirect(url)
}
} catch (e) {
deleteCookie(c, oidcAuthCookieName)
throw new HTTPException(500, { message: 'Invalid session' })
}
await next()
c.res.headers.set('Cache-Control', 'private, no-cache')
// Workaround to set the session cookie when the response is returned by the origin server
const session_jwt = c.get('oidcAuthJwt')
if (session_jwt !== undefined) {
setCookie(c, oidcAuthCookieName, session_jwt, { path: '/', httpOnly: true, secure: true })
}
})
}

View File

@ -0,0 +1,262 @@
import { Hono } from 'hono'
import { jest } from '@jest/globals'
import crypto from 'node:crypto'
import jwt from 'jsonwebtoken'
import * as oauth2 from 'oauth4webapi'
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_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,
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, revokeSession } = await import("../src");
const app = new Hono()
app.get('/logout', async (c) => {
await revokeSession(c)
return c.text('OK')
})
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
});
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&/)
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 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('oidc-auth=;')
})
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('oidc-auth=;')
})
});
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, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('http://localhost/1234')
})
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('oidc-auth=;')
})
});

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
},
"include": [
"src/**/*.ts"
],
}

View File

@ -1543,6 +1543,24 @@ __metadata:
languageName: unknown
linkType: soft
"@hono/oidc-auth@workspace:packages/oidc-auth":
version: 0.0.0-use.local
resolution: "@hono/oidc-auth@workspace:packages/oidc-auth"
dependencies:
"@types/jest": "npm:^29.5.11"
"@types/jsonwebtoken": "npm:^9.0.5"
hono: "npm:^4.0.1"
jest: "npm:^29.7.0"
jsonwebtoken: "npm:^9.0.2"
oauth4webapi: "npm:^2.6.0"
ts-jest: "npm:^29.1.1"
tsup: "npm:^8.0.1"
typescript: "npm:^5.3.3"
peerDependencies:
hono: ">=3.*"
languageName: unknown
linkType: soft
"@hono/prometheus@workspace:packages/prometheus":
version: 0.0.0-use.local
resolution: "@hono/prometheus@workspace:packages/prometheus"
@ -3517,6 +3535,15 @@ __metadata:
languageName: node
linkType: hard
"@types/jsonwebtoken@npm:^9.0.5":
version: 9.0.5
resolution: "@types/jsonwebtoken@npm:9.0.5"
dependencies:
"@types/node": "npm:*"
checksum: c582b8420586f3b9550f7e34992cb32be300bc953636f3b087ed9c180ce7ea5c2e4b35090be2d57f0d3168cc3ca1074932907caa2afe09f4e9c84cf5c0daefa8
languageName: node
linkType: hard
"@types/keyv@npm:^3.1.1, @types/keyv@npm:^3.1.4":
version: 3.1.4
resolution: "@types/keyv@npm:3.1.4"
@ -8882,9 +8909,16 @@ __metadata:
linkType: hard
"hono@npm:^3.12.0":
version: 3.12.6
resolution: "hono@npm:3.12.6"
checksum: 74475dc0519f064a6c25b4d588a65ad06b9e3217c914e1d60aad9f3fc7516786dbda44e5bab527a6e6b8a10fdf30c37d25c2d20bab03681325d65edd6909a913
version: 3.12.10
resolution: "hono@npm:3.12.10"
checksum: 43ad255e54bc5dd47154ad057565f41f2fda3dc866416f9a25fa2022e86c552b42109972f8c75101ed1da704c2b967d8bd474869db094878ab9438650379663c
languageName: node
linkType: hard
"hono@npm:^4.0.1":
version: 4.0.1
resolution: "hono@npm:4.0.1"
checksum: 0f4fe93d376ce4063d42ccc35eee445756f17ca4044ef1f59e276b4737dacc5e76334e230a59101a4092aa88a066f7585303da7631ca4a46325384d55db48df3
languageName: node
linkType: hard
@ -11103,7 +11137,7 @@ __metadata:
languageName: node
linkType: hard
"jsonwebtoken@npm:^9.0.0":
"jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2":
version: 9.0.2
resolution: "jsonwebtoken@npm:9.0.2"
dependencies:
@ -13279,6 +13313,13 @@ __metadata:
languageName: node
linkType: hard
"oauth4webapi@npm:^2.6.0":
version: 2.9.0
resolution: "oauth4webapi@npm:2.9.0"
checksum: 7bbd2ab446c0621c71f6855c695b900d6ba1f56474b105751134112abf5cff9fbe2fe715d2ae0f57f0ed1adc600c37ae6bf007669af9f760dc982aaeaeb0968f
languageName: node
linkType: hard
"object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0":
version: 4.1.1
resolution: "object-assign@npm:4.1.1"