├── .gitignore ├── README.md ├── examples ├── basic │ └── index.ts └── hot-link-protections │ ├── package.json │ ├── site │ └── static │ │ └── images │ │ └── hono-title.png │ ├── src │ └── index.ts │ ├── tsconfig.json │ └── wrangler.toml ├── jest.config.js ├── package.json ├── src ├── index.ts └── signed-request.ts ├── test └── signed-request.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | sandbox -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SignedRequest Middleware for Hono 2 | 3 | Based on [Cloudflare Docs](https://developers.cloudflare.com/workers/examples/signing-requests/). 4 | 5 | ## Usage 6 | 7 | ```ts 8 | import { verifySignedRequest, generateSignedURL } from './middleware' 9 | 10 | const secretKey = 'foo' 11 | const expirationSec = 10 12 | 13 | const app = new Hono() 14 | 15 | app.get( 16 | '/verify/*', 17 | verifySignedRequest({ 18 | secretKey 19 | }), 20 | (c) => c.text('Verify') 21 | ) 22 | 23 | app.get('/generate/*', async (c) => { 24 | const url = new URL(c.req.url) 25 | 26 | const prefix = '/generate/' 27 | url.pathname = `/verify/${url.pathname.slice(prefix.length)}` 28 | 29 | const signedURL = await generateSignedURL(url, { secretKey, expirationMs: 1000 * expirationSec }) 30 | return c.text(signedURL.toString()) 31 | }) 32 | ``` 33 | 34 | ## Related projects 35 | 36 | * 37 | * 38 | * 39 | 40 | ## Author 41 | 42 | Yusuke Wada 43 | 44 | ## License 45 | 46 | MIT 47 | -------------------------------------------------------------------------------- /examples/basic/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { verifySignedRequest, generateSignedURL } from '../../src' 3 | 4 | const secretKey = 'foo' 5 | const expirationSec = 10 6 | 7 | const app = new Hono() 8 | 9 | app.get( 10 | '/verify/*', 11 | verifySignedRequest({ 12 | secretKey 13 | }), 14 | (c) => c.text('Verify') 15 | ) 16 | 17 | app.get('/generate/*', async (c) => { 18 | const url = new URL(c.req.url) 19 | 20 | const prefix = '/generate/' 21 | url.pathname = `/verify/${url.pathname.slice(prefix.length)}` 22 | 23 | const signedURL = await generateSignedURL(url, { secretKey, expirationMs: 1000 * expirationSec }) 24 | return c.text(signedURL.toString()) 25 | }) 26 | 27 | app.showRoutes() 28 | 29 | export default app 30 | -------------------------------------------------------------------------------- /examples/hot-link-protections/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "wrangler dev src/index.ts", 4 | "deploy": "wrangler deploy --minify src/index.ts" 5 | }, 6 | "dependencies": { 7 | "hono": "^3.2.1" 8 | }, 9 | "devDependencies": { 10 | "@cloudflare/workers-types": "^4.20221111.1", 11 | "wrangler": "^3.0.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/hot-link-protections/site/static/images/hono-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusukebe/signed-request-middleware/052b78d55a70b5fa49b9035b57071bac6bf3e7f9/examples/hot-link-protections/site/static/images/hono-title.png -------------------------------------------------------------------------------- /examples/hot-link-protections/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { html } from 'hono/html' 3 | import { serveStatic } from 'hono/cloudflare-workers' 4 | import { generateSignedURL, verifySignedRequest } from '../../../src' 5 | 6 | const secretKey = 'foo' 7 | const expirationSec = 5 8 | 9 | const app = new Hono() 10 | 11 | app.get('/static/images/*', verifySignedRequest({ secretKey })) 12 | 13 | app.get('/static/*', serveStatic({ root: './' })) 14 | 15 | app.get('/', async (c, next) => { 16 | await next() 17 | class AttributeRewriter { 18 | private attributeName: string 19 | constructor(attributeName: string) { 20 | this.attributeName = attributeName 21 | } 22 | async element(element: Element) { 23 | const attribute = element.getAttribute(this.attributeName) 24 | if (attribute) { 25 | const url = new URL(attribute, c.req.url) 26 | const generatedURL = await generateSignedURL(url, { 27 | secretKey, 28 | expirationMs: 1000 * expirationSec 29 | }) 30 | element.setAttribute(this.attributeName, generatedURL.toString()) 31 | } 32 | } 33 | } 34 | 35 | const rewriter = new HTMLRewriter().on('img', new AttributeRewriter('src')) 36 | c.res = rewriter.transform(c.res) 37 | }) 38 | 39 | app.get('/', (c) => { 40 | return c.html( 41 | html` 42 | 43 |

SignedRequest Middleware Demo

44 | 45 | 46 | ` 47 | ) 48 | }) 49 | 50 | export default app 51 | -------------------------------------------------------------------------------- /examples/hot-link-protections/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "lib": [ 10 | "esnext" 11 | ], 12 | "types": [ 13 | "@cloudflare/workers-types", 14 | "node" 15 | ], 16 | "jsx": "react-jsx", 17 | "jsxImportSource": "hono/jsx" 18 | }, 19 | } -------------------------------------------------------------------------------- /examples/hot-link-protections/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "signed-request-middleware-demo" 2 | compatibility_date = "2023-01-01" 3 | 4 | [site] 5 | bucket = "./site" -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/test/**/*.+(ts|tsx|js)'], 3 | transform: { 4 | '^.+\\.(ts|tsx)$': 'ts-jest' 5 | }, 6 | testEnvironment: 'miniflare' 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "jest", 4 | "dev": "wrangler dev src/index.ts", 5 | "deploy": "wrangler deploy --minify src/index.ts" 6 | }, 7 | "peerDependencies": { 8 | "hono": "*" 9 | }, 10 | "devDependencies": { 11 | "@cloudflare/workers-types": "^4.20221111.1", 12 | "@types/jest": "^29.5.1", 13 | "hono": "^3.2.1", 14 | "jest": "^29.5.0", 15 | "jest-environment-miniflare": "^2.14.0", 16 | "ts-jest": "^29.1.0", 17 | "typescript": "^5.0.4", 18 | "wrangler": "^3.0.0" 19 | }, 20 | "license": "MIT" 21 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { generateSignedURL, verifySignedRequest } from './signed-request' 2 | -------------------------------------------------------------------------------- /src/signed-request.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareHandler } from 'hono' 2 | import { HTTPException } from 'hono/http-exception' 3 | import { encodeBase64 } from 'hono/utils/encode' 4 | 5 | export const verifySignedRequest = (options: { secretKey: string }): MiddlewareHandler => { 6 | const handler: MiddlewareHandler = async (c, next) => { 7 | const encoder = new TextEncoder() 8 | const secretKeyData = encoder.encode(options.secretKey) 9 | 10 | const { mac, expiry } = c.req.query() 11 | 12 | if (!mac || !expiry) { 13 | throw new HTTPException(403, { 14 | res: exceptionResponse('Missing query parameter') 15 | }) 16 | } 17 | 18 | const key = await crypto.subtle.importKey('raw', secretKeyData, { name: 'HMAC', hash: 'SHA-256' }, false, [ 19 | 'verify' 20 | ]) 21 | const expiryNumber = Number.parseInt(expiry, 10) 22 | 23 | const dataToAuthenticate = `${c.req.path}@${expiryNumber}` 24 | let receivedMac: Uint8Array 25 | try { 26 | receivedMac = byteStringToUint8Array(atob(mac)) 27 | } catch (e) { 28 | throw new HTTPException(403, { 29 | res: exceptionResponse('Invalid sign') 30 | }) 31 | } 32 | 33 | const verified = await crypto.subtle.verify('HMAC', key, receivedMac, encoder.encode(dataToAuthenticate)) 34 | 35 | if (!verified) { 36 | throw new HTTPException(403, { 37 | res: exceptionResponse('Invalid MAC') 38 | }) 39 | } 40 | 41 | if (Date.now() > expiryNumber) { 42 | throw new HTTPException(403, { 43 | res: exceptionResponse('URL expired') 44 | }) 45 | } 46 | 47 | await next() 48 | } 49 | 50 | return handler 51 | } 52 | 53 | export const generateSignedURL = async ( 54 | url: URL, 55 | options: { 56 | secretKey: string 57 | expirationMs: number 58 | } 59 | ) => { 60 | const encoder = new TextEncoder() 61 | const secretKeyData = encoder.encode(options.secretKey) 62 | const key = await crypto.subtle.importKey('raw', secretKeyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) 63 | 64 | const expirationMs = options.expirationMs 65 | const expiry = Date.now() + expirationMs 66 | const dataToAuthenticate = `${url.pathname}@${expiry}` 67 | 68 | const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(dataToAuthenticate)) 69 | const base64Mac = encodeBase64(mac) 70 | 71 | url.searchParams.set('mac', base64Mac) 72 | url.searchParams.set('expiry', expiry.toString()) 73 | 74 | return url 75 | } 76 | 77 | const exceptionResponse = (message: string) => { 78 | return new Response(message, { 79 | status: 403 80 | }) 81 | } 82 | 83 | const byteStringToUint8Array = (byteString: string) => { 84 | const ui = new Uint8Array(byteString.length) 85 | for (let i = 0; i < byteString.length; ++i) { 86 | ui[i] = byteString.charCodeAt(i) 87 | } 88 | return ui 89 | } 90 | -------------------------------------------------------------------------------- /test/signed-request.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { generateSignedURL, verifySignedRequest } from '../src' 3 | 4 | describe('SignedRequest Middleware', () => { 5 | const secretKey = 'foo' 6 | const expirationSec = 10 7 | 8 | const app = new Hono() 9 | 10 | app.get( 11 | '/verify/*', 12 | verifySignedRequest({ 13 | secretKey 14 | }), 15 | (c) => c.text('Verify') 16 | ) 17 | 18 | app.get('/generate/*', async (c) => { 19 | const url = new URL(c.req.url) 20 | 21 | const prefix = '/generate/' 22 | url.pathname = `/verify/${url.pathname.slice(prefix.length)}` 23 | 24 | const signedURL = await generateSignedURL(url, { secretKey, expirationMs: 1000 * expirationSec }) 25 | return c.text(signedURL.toString()) 26 | }) 27 | 28 | it('Should return 200 response', async () => { 29 | let res = await app.request('/generate/foo') 30 | expect(res).not.toBeNull() 31 | expect(res.status).toBe(200) 32 | const verifyUrl = await res.text() 33 | res = await app.request(verifyUrl) 34 | expect(res).not.toBeNull() 35 | expect(res.status).toBe(200) 36 | }) 37 | 38 | it('Should return 403 response - Invalid MAC', async () => { 39 | let res = await app.request('/generate/foo') 40 | expect(res).not.toBeNull() 41 | expect(res.status).toBe(200) 42 | const verifyUrl = await res.text() 43 | const url = new URL(verifyUrl) 44 | const expiry = url.searchParams.get('expiry') 45 | res = await app.request(`/verify/foo?mac=invalid&expiry=${expiry}`) 46 | expect(res).not.toBeNull() 47 | expect(res.status).toBe(403) 48 | expect(await res.text()).toBe('Invalid MAC') 49 | }) 50 | 51 | jest.useFakeTimers() 52 | 53 | it('Should return 403 response - URL expired', async () => { 54 | let res = await app.request('/generate/foo') 55 | expect(res).not.toBeNull() 56 | expect(res.status).toBe(200) 57 | const verifyUrl = await res.text() 58 | 59 | jest.advanceTimersByTime(1000 * expirationSec + 1) 60 | 61 | res = await app.request(verifyUrl) 62 | expect(res).not.toBeNull() 63 | expect(res.status).toBe(403) 64 | expect(await res.text()).toBe('URL expired') 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "lib": [ 10 | "esnext" 11 | ], 12 | "types": [ 13 | "jest", 14 | "@cloudflare/workers-types", 15 | "node" 16 | ], 17 | }, 18 | } --------------------------------------------------------------------------------