diff --git a/.changeset/rare-mangos-shop.md b/.changeset/rare-mangos-shop.md new file mode 100644 index 00000000..f61e5586 --- /dev/null +++ b/.changeset/rare-mangos-shop.md @@ -0,0 +1,5 @@ +--- +'@hono/oidc-auth': major +--- + +Releasing first version diff --git a/.github/workflows/ci-oidc-auth.yml b/.github/workflows/ci-oidc-auth.yml new file mode 100644 index 00000000..ea407bc3 --- /dev/null +++ b/.github/workflows/ci-oidc-auth.yml @@ -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 diff --git a/packages/oidc-auth/README.md b/packages/oidc-auth/README.md new file mode 100644 index 00000000..b5205fab --- /dev/null +++ b/packages/oidc-auth/README.md @@ -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://..auth0.com` | +| AWS Cognito | `https://cognito-idp..amazonaws.com/` | +| 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 + +## License + +MIT diff --git a/packages/oidc-auth/jest.config.js b/packages/oidc-auth/jest.config.js new file mode 100644 index 00000000..01b94968 --- /dev/null +++ b/packages/oidc-auth/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + collectCoverage: true, +} diff --git a/packages/oidc-auth/package.json b/packages/oidc-auth/package.json new file mode 100644 index 00000000..bbd2817f --- /dev/null +++ b/packages/oidc-auth/package.json @@ -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" + } +} diff --git a/packages/oidc-auth/src/index.ts b/packages/oidc-auth/src/index.ts new file mode 100644 index 00000000..c8dcf33c --- /dev/null +++ b/packages/oidc-auth/src/index.ts @@ -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(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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 }) + } + }) +} diff --git a/packages/oidc-auth/test/index.test.ts b/packages/oidc-auth/test/index.test.ts new file mode 100644 index 00000000..cdcbb6f6 --- /dev/null +++ b/packages/oidc-auth/test/index.test.ts @@ -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=;') + }) +}); diff --git a/packages/oidc-auth/tsconfig.json b/packages/oidc-auth/tsconfig.json new file mode 100644 index 00000000..acfcd843 --- /dev/null +++ b/packages/oidc-auth/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 841467b8..68195163 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"