├── .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 | }
--------------------------------------------------------------------------------