Skip to content

Commit a652ffa

Browse files
kvzclaude
andauthored
utils: isomorphic Smart CDN URL signing and sha512 signParams (WebCrypto) (#477)
Move the Smart CDN URL assembly (encoding, sorted query, auth_key/exp, sig=sha256:hex) into src/smartCdn.ts so the synchronous Node signer and a new async WebCrypto getSignedSmartCdnUrl on the root export share it and cannot drift. Uppy's @uppy/transloadit-storage and the Console each carry a browser reimplementation today; this lets them depend on the package instead. signParams/verifyWebhookSignature also accept sha512, matching signParamsSync. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8e7eb59 commit a652ffa

6 files changed

Lines changed: 235 additions & 67 deletions

File tree

.changeset/isomorphic-smart-cdn.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@transloadit/utils': minor
3+
---
4+
5+
Add an isomorphic `getSignedSmartCdnUrl` to the root export. It signs with WebCrypto, so browsers,
6+
edge runtimes and Node produce byte-identical Smart CDN URLs to the synchronous signer in
7+
`@transloadit/utils/node`, which now shares the same URL-building code. `signParams` and
8+
`verifyWebhookSignature` additionally accept `sha512`, matching `signParamsSync`.

packages/utils/README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,25 @@ npm install @transloadit/utils
1010

1111
## Web / Edge usage
1212

13+
Everything in the root export runs on WebCrypto, so it works in browsers (secure origins only:
14+
`https://` or `localhost`), edge runtimes, and Node.
15+
1316
```ts
14-
import { signParams, verifyWebhookSignature } from '@transloadit/utils'
17+
import { getSignedSmartCdnUrl, signParams, verifyWebhookSignature } from '@transloadit/utils'
1518

1619
const signature = await signParams(paramsString, authSecret)
1720
const verified = await verifyWebhookSignature({
1821
rawBody,
1922
signatureHeader,
2023
authSecret,
2124
})
25+
const url = await getSignedSmartCdnUrl({
26+
workspace,
27+
template,
28+
input,
29+
authKey,
30+
authSecret,
31+
})
2232
```
2333

2434
## Node usage
@@ -55,9 +65,12 @@ for (const source of imageCandidates.sources) {
5565

5666
## API
5767

58-
- `signParams(paramsString, authSecret, algorithm?)`: WebCrypto-based HMAC signature for params.
68+
- `signParams(paramsString, authSecret, algorithm?)`: WebCrypto-based HMAC signature for params
69+
(`sha1`, `sha256`, `sha384`, `sha512`).
5970
- `verifyWebhookSignature({ rawBody, signatureHeader, authSecret })`: validates webhook signatures.
71+
- `getSignedSmartCdnUrl(options)`: async, WebCrypto-based Smart CDN URL signer. Byte-identical to
72+
the Node variant below.
6073
- `signParamsSync(paramsString, authSecret, algorithm?)`: Node-only sync signature helper.
61-
- `getSignedSmartCdnUrl(options)`: Node-only Smart CDN URL signer.
74+
- `getSignedSmartCdnUrl(options)` from `@transloadit/utils/node`: synchronous Smart CDN URL signer.
6275
- `getSignedSmartCdnImageCandidates(options)`: deterministic structured, signed AVIF and WebP
6376
candidates plus the original fallback URL.

packages/utils/src/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
1-
export type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha384'
1+
import type { SmartCdnUrlOptions } from './smartCdn.ts'
2+
3+
import { finishSmartCdnUrl, prepareSmartCdnUrl } from './smartCdn.ts'
4+
5+
export type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha384' | 'sha512'
6+
7+
export type { SmartCdnUrlOptions } from './smartCdn.ts'
28

39
export * from './assemblyInstructionsCompiler.ts'
410

511
const algorithmMap = {
612
sha1: 'SHA-1',
713
sha256: 'SHA-256',
814
sha384: 'SHA-384',
15+
sha512: 'SHA-512',
916
} as const
1017

1118
const isSignatureAlgorithm = (value: string): value is SignatureAlgorithm =>
12-
value === 'sha1' || value === 'sha256' || value === 'sha384'
19+
value === 'sha1' || value === 'sha256' || value === 'sha384' || value === 'sha512'
1320

1421
const getSubtle = (): SubtleCrypto => {
1522
const subtle = globalThis.crypto?.subtle
1623
if (!subtle) {
17-
throw new Error('Web Crypto is required to sign Transloadit payloads')
24+
// Browsers only expose crypto.subtle on secure origins (https:// or localhost).
25+
throw new Error(
26+
'Web Crypto is required to sign Transloadit payloads; browsers only provide crypto.subtle on secure origins (https:// or localhost)',
27+
)
1828
}
1929
return subtle
2030
}
@@ -89,3 +99,13 @@ export const verifyWebhookSignature = async (
8999
const expected = await hmacHex(normalized, options.authSecret, options.rawBody)
90100
return safeCompare(expected, signature)
91101
}
102+
103+
/**
104+
* Signs a Smart CDN URL with WebCrypto, so it works in browsers, edge runtimes and Node alike.
105+
* Produces the same URL as the synchronous `getSignedSmartCdnUrl` from `@transloadit/utils/node`.
106+
*/
107+
export const getSignedSmartCdnUrl = async (opts: SmartCdnUrlOptions): Promise<string> => {
108+
const prepared = prepareSmartCdnUrl(opts)
109+
const signature = await hmacHex('sha256', opts.authSecret, prepared.stringToSign)
110+
return finishSmartCdnUrl(prepared, signature)
111+
}

packages/utils/src/node.ts

Lines changed: 10 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import type { SignatureAlgorithm } from './index.ts'
2+
import type { SmartCdnUrlOptions } from './smartCdn.ts'
23

34
import { createHmac } from 'node:crypto'
45

6+
import { finishSmartCdnUrl, prepareSmartCdnUrl } from './smartCdn.ts'
7+
58
export type { SignatureAlgorithm } from './index.ts'
9+
export type { SmartCdnUrlOptions } from './smartCdn.ts'
610

711
export type SignatureAlgorithmInput = SignatureAlgorithm | (string & {})
812

@@ -48,38 +52,6 @@ export interface SmartCdnImageCandidatesOptions {
4852
workspace: string
4953
}
5054

51-
export type SmartCdnUrlOptions = {
52-
/**
53-
* Workspace slug.
54-
*/
55-
workspace: string
56-
/**
57-
* Template slug or template ID.
58-
*/
59-
template: string
60-
/**
61-
* Input value that is provided as `${fields.input}` in the template.
62-
*/
63-
input: string
64-
/**
65-
* Additional parameters for the URL query string.
66-
*/
67-
urlParams?: Record<string, boolean | number | string | (boolean | number | string)[]>
68-
/**
69-
* Expiration timestamp of the signature in milliseconds since UNIX epoch.
70-
* Defaults to 1 hour from now.
71-
*/
72-
expiresAt?: number
73-
/**
74-
* Transloadit auth key used to sign the URL.
75-
*/
76-
authKey: string
77-
/**
78-
* Transloadit auth secret used to sign the URL.
79-
*/
80-
authSecret: string
81-
}
82-
8355
const defaultSmartCdnImageFormats: Readonly<Partial<Record<SmartCdnImageFormat, number>>> = {
8456
avif: 45,
8557
webp: 75,
@@ -155,36 +127,13 @@ export const signParamsSync = (
155127
return `${algorithm}:${signature}`
156128
}
157129

130+
/** Synchronous Smart CDN URL signer (Node). The root export has an async WebCrypto twin. */
158131
export const getSignedSmartCdnUrl = (opts: SmartCdnUrlOptions): string => {
159-
if (opts.workspace == null || opts.workspace === '') throw new TypeError('workspace is required')
160-
if (opts.template == null || opts.template === '') throw new TypeError('template is required')
161-
if (opts.input == null) throw new TypeError('input is required')
162-
163-
const workspaceSlug = encodeURIComponent(opts.workspace)
164-
const templateSlug = encodeURIComponent(opts.template)
165-
const inputField = encodeURIComponent(opts.input)
166-
const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000
167-
168-
const queryParams = new URLSearchParams()
169-
for (const [key, value] of Object.entries(opts.urlParams || {})) {
170-
if (Array.isArray(value)) {
171-
for (const val of value) {
172-
queryParams.append(key, `${val}`)
173-
}
174-
} else {
175-
queryParams.append(key, `${value}`)
176-
}
177-
}
178-
179-
queryParams.set('auth_key', opts.authKey)
180-
queryParams.set('exp', `${expiresAt}`)
181-
queryParams.sort()
182-
183-
const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}`
184-
const signature = createHmac('sha256', opts.authSecret).update(stringToSign).digest('hex')
185-
186-
queryParams.set('sig', `sha256:${signature}`)
187-
return `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}`
132+
const prepared = prepareSmartCdnUrl(opts)
133+
const signature = createHmac('sha256', opts.authSecret)
134+
.update(prepared.stringToSign)
135+
.digest('hex')
136+
return finishSmartCdnUrl(prepared, signature)
188137
}
189138

190139
/**

packages/utils/src/smartCdn.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Smart CDN URL building shared by the synchronous Node signer (`@transloadit/utils/node`) and the
3+
* asynchronous WebCrypto signer (`@transloadit/utils`). Only the HMAC differs between the two, so
4+
* the string-to-sign and the final URL are assembled here and cannot drift apart.
5+
*/
6+
7+
export type SmartCdnUrlOptions = {
8+
/**
9+
* Workspace slug.
10+
*/
11+
workspace: string
12+
/**
13+
* Template slug or template ID.
14+
*/
15+
template: string
16+
/**
17+
* Input value that is provided as `${fields.input}` in the template.
18+
*/
19+
input: string
20+
/**
21+
* Additional parameters for the URL query string.
22+
*/
23+
urlParams?: Record<string, boolean | number | string | (boolean | number | string)[]>
24+
/**
25+
* Expiration timestamp of the signature in milliseconds since UNIX epoch.
26+
* Defaults to 1 hour from now.
27+
*/
28+
expiresAt?: number
29+
/**
30+
* Transloadit auth key used to sign the URL.
31+
*/
32+
authKey: string
33+
/**
34+
* Transloadit auth secret used to sign the URL.
35+
*/
36+
authSecret: string
37+
}
38+
39+
/** A Smart CDN URL with everything but its signature in place. */
40+
export interface PreparedSmartCdnUrl {
41+
/** `workspace/template/input?sortedQuery`, the message the auth secret signs with HMAC-SHA256. */
42+
stringToSign: string
43+
/** URL-encoded path segments and the sorted query (without `sig`). */
44+
parts: {
45+
workspaceSlug: string
46+
templateSlug: string
47+
inputField: string
48+
queryParams: URLSearchParams
49+
}
50+
}
51+
52+
/** Validates the options and assembles the string to sign; the caller supplies the HMAC. */
53+
export const prepareSmartCdnUrl = (opts: SmartCdnUrlOptions): PreparedSmartCdnUrl => {
54+
if (opts.workspace == null || opts.workspace === '') throw new TypeError('workspace is required')
55+
if (opts.template == null || opts.template === '') throw new TypeError('template is required')
56+
if (opts.input == null) throw new TypeError('input is required')
57+
58+
const workspaceSlug = encodeURIComponent(opts.workspace)
59+
const templateSlug = encodeURIComponent(opts.template)
60+
const inputField = encodeURIComponent(opts.input)
61+
const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000
62+
63+
const queryParams = new URLSearchParams()
64+
for (const [key, value] of Object.entries(opts.urlParams || {})) {
65+
if (Array.isArray(value)) {
66+
for (const val of value) {
67+
queryParams.append(key, `${val}`)
68+
}
69+
} else {
70+
queryParams.append(key, `${value}`)
71+
}
72+
}
73+
74+
queryParams.set('auth_key', opts.authKey)
75+
queryParams.set('exp', `${expiresAt}`)
76+
queryParams.sort()
77+
78+
return {
79+
stringToSign: `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}`,
80+
parts: { workspaceSlug, templateSlug, inputField, queryParams },
81+
}
82+
}
83+
84+
/** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */
85+
export const finishSmartCdnUrl = ({ parts }: PreparedSmartCdnUrl, signatureHex: string): string => {
86+
const { workspaceSlug, templateSlug, inputField, queryParams } = parts
87+
queryParams.set('sig', `sha256:${signatureHex}`)
88+
return `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}`
89+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createHmac } from 'node:crypto'
2+
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { getSignedSmartCdnUrl, signParams } from '../src/index.ts'
6+
import { getSignedSmartCdnUrl as getSignedSmartCdnUrlSync, signParamsSync } from '../src/node.ts'
7+
8+
const options = {
9+
authKey: 'test-key',
10+
authSecret: 'test-secret',
11+
expiresAt: 1_900_000_000_000,
12+
input: 'https://assets.example/image.jpg?version=1',
13+
template: 'builtin/serve-image@0.0.1',
14+
urlParams: { f: ['avif', 'webp'], fit: true, q: 75, w: 640 },
15+
workspace: 'test-workspace',
16+
}
17+
18+
// Computed once with the Node signer; guards both signers against drifting together.
19+
const knownAnswer =
20+
'https://test-workspace.tlcdn.com/builtin%2Fserve-image%400.0.1/' +
21+
'https%3A%2F%2Fassets.example%2Fimage.jpg%3Fversion%3D1' +
22+
'?auth_key=test-key&exp=1900000000000&f=avif&f=webp&fit=true&q=75&w=640' +
23+
'&sig=sha256%3A69e40bc3a447c121a08d059ad9093a39c1beaf5c107a82992d5e78e0d9686f6b'
24+
25+
afterEach(() => {
26+
vi.restoreAllMocks()
27+
vi.unstubAllGlobals()
28+
})
29+
30+
describe('getSignedSmartCdnUrl', () => {
31+
it('matches the known answer in both the WebCrypto and the Node signer', async () => {
32+
expect(getSignedSmartCdnUrlSync(options)).toBe(knownAnswer)
33+
await expect(getSignedSmartCdnUrl(options)).resolves.toBe(knownAnswer)
34+
})
35+
36+
it('signs the string to sign with HMAC-SHA256 over the sorted query', async () => {
37+
const stringToSign =
38+
'test-workspace/builtin%2Fserve-image%400.0.1/' +
39+
'https%3A%2F%2Fassets.example%2Fimage.jpg%3Fversion%3D1' +
40+
'?auth_key=test-key&exp=1900000000000&f=avif&f=webp&fit=true&q=75&w=640'
41+
const expected = createHmac('sha256', options.authSecret).update(stringToSign).digest('hex')
42+
43+
const url = new URL(await getSignedSmartCdnUrl(options))
44+
expect(url.searchParams.get('sig')).toBe(`sha256:${expected}`)
45+
})
46+
47+
it('defaults the expiry to one hour from now in both signers', async () => {
48+
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
49+
const { expiresAt: _expiresAt, ...withoutExpiry } = options
50+
51+
const syncUrl = getSignedSmartCdnUrlSync(withoutExpiry)
52+
const asyncUrl = await getSignedSmartCdnUrl(withoutExpiry)
53+
54+
expect(new URL(syncUrl).searchParams.get('exp')).toBe(`${1_700_000_000_000 + 60 * 60 * 1000}`)
55+
expect(asyncUrl).toBe(syncUrl)
56+
})
57+
58+
it('rejects incomplete options the same way in both signers', async () => {
59+
for (const [key, message] of [
60+
['workspace', 'workspace is required'],
61+
['template', 'template is required'],
62+
['input', 'input is required'],
63+
] as const) {
64+
const broken = { ...options, [key]: undefined } as unknown as typeof options
65+
expect(() => getSignedSmartCdnUrlSync(broken)).toThrow(new TypeError(message))
66+
await expect(getSignedSmartCdnUrl(broken)).rejects.toThrow(new TypeError(message))
67+
}
68+
})
69+
70+
it('explains that WebCrypto needs a secure origin when crypto.subtle is missing', async () => {
71+
vi.stubGlobal('crypto', undefined)
72+
73+
await expect(getSignedSmartCdnUrl(options)).rejects.toThrow(
74+
'Web Crypto is required to sign Transloadit payloads; browsers only provide crypto.subtle on secure origins (https:// or localhost)',
75+
)
76+
})
77+
})
78+
79+
describe('signParams', () => {
80+
it('produces the same signature as signParamsSync for every supported algorithm', async () => {
81+
const paramsString = JSON.stringify({ auth: { key: 'test-key' }, steps: {} })
82+
83+
for (const algorithm of ['sha1', 'sha256', 'sha384', 'sha512'] as const) {
84+
const expected = signParamsSync(paramsString, options.authSecret, algorithm)
85+
expect(expected.startsWith(`${algorithm}:`)).toBe(true)
86+
await expect(signParams(paramsString, options.authSecret, algorithm)).resolves.toBe(expected)
87+
}
88+
})
89+
})

0 commit comments

Comments
 (0)