diff --git a/.changeset/auth-token-cli.md b/.changeset/auth-token-cli.md new file mode 100644 index 00000000..50a0b59d --- /dev/null +++ b/.changeset/auth-token-cli.md @@ -0,0 +1,6 @@ +--- +'@transloadit/node': patch +'transloadit': patch +--- + +feat(cli): add `transloadit auth token` to mint bearer tokens for hosted MCP (with HTTPS/redirect safety guards) diff --git a/docs/fingerprint/transloadit-baseline.json b/docs/fingerprint/transloadit-baseline.json index 016fb386..ccca0946 100644 --- a/docs/fingerprint/transloadit-baseline.json +++ b/docs/fingerprint/transloadit-baseline.json @@ -1,13 +1,13 @@ { "packageDir": "/home/kvz/code/node-sdk/packages/transloadit", "tarball": { - "filename": "transloadit-4.7.1.tgz", - "sizeBytes": 1239686, - "sha256": "e0e4926af34a37737feb90f3536be69f1111e38e4525b056e2c72ec4456d0a6b" + "filename": "transloadit-4.7.2.tgz", + "sizeBytes": 1244695, + "sha256": "42d5f0f20b27a3c3a761b43f68b61b4acf5b0c800f723928803f3949246ebfe9" }, "packageJson": { "name": "transloadit", - "version": "4.7.1", + "version": "4.7.2", "main": "./dist/Transloadit.js", "exports": { ".": "./dist/Transloadit.js", @@ -128,8 +128,8 @@ }, { "path": "dist/cli/commands/auth.js", - "sizeBytes": 10190, - "sha256": "882912a557268b70fba276804fecab686e259bb78c167c175c3e1e67cceea3b6" + "sizeBytes": 15785, + "sha256": "3fcef85fa789d41e4620eec82555eb94ca52ae70367d519272103fd6019a7661" }, { "path": "dist/alphalib/types/robots/azure-import.js", @@ -153,8 +153,8 @@ }, { "path": "dist/cli/commands/BaseCommand.js", - "sizeBytes": 1895, - "sha256": "1141a59a8ec2f47f6e5d4257b81e44fad8f50d693d1d22d6a0d6a2a08b5f8792" + "sizeBytes": 1883, + "sha256": "af4d62f394df456f5137627e8c4f241344fbe7c7796e427bc6ec493de5984762" }, { "path": "dist/alphalib/types/bill.js", @@ -328,8 +328,8 @@ }, { "path": "dist/cli/helpers.js", - "sizeBytes": 2277, - "sha256": "f503e1fa45e284955f3e10316ceebe417de30abba13d9cde3e5047be89b77bcc" + "sizeBytes": 2625, + "sha256": "8555420c31526195f54151c481bd00d2a1fcf67dacbad3f988a4c6538fa67f21" }, { "path": "dist/alphalib/types/robots/html-convert.js", @@ -388,8 +388,8 @@ }, { "path": "dist/cli/commands/index.js", - "sizeBytes": 2099, - "sha256": "26ffd3f1d1bf5f9e6ea350cebcb6593fa748bcbc814ee60dcfc7313a97fe54f0" + "sizeBytes": 2145, + "sha256": "b44764be9d6a803669bbc1a937f553566ce91993ed283c7f6d5ef65cbff6b263" }, { "path": "dist/inputFiles.js", @@ -684,7 +684,7 @@ { "path": "package.json", "sizeBytes": 2730, - "sha256": "061fbd4b1e3fbbc7ded678f2d27b9ac3e0dbcc903b5e70c31c61166a6a24764e" + "sha256": "818a4a0e1e9d5020e29d5cafcbc584a380fbb264508518835bab2e017f5fd101" }, { "path": "dist/alphalib/types/robots/_index.d.ts.map", @@ -898,13 +898,13 @@ }, { "path": "dist/cli/commands/auth.d.ts.map", - "sizeBytes": 749, - "sha256": "f31c0a86129f7fa44aed415e85e46a04935801f03140c8d73c57897d2ee609f7" + "sizeBytes": 996, + "sha256": "478ac46eb7ddbe624235a21c0a0a059cdefa91381281198ad51f7bd21c8f1b09" }, { "path": "dist/cli/commands/auth.js.map", - "sizeBytes": 9151, - "sha256": "92fb82422a8e746658deb008d95d656a2cb87e86a761ff5dc0776cbda96c7cca" + "sizeBytes": 14501, + "sha256": "891418720d6f855eea37a07ca197c55a9d8d010c905f6c7bdda9ee7f4f9d6332" }, { "path": "dist/alphalib/types/robots/azure-import.d.ts.map", @@ -948,13 +948,13 @@ }, { "path": "dist/cli/commands/BaseCommand.d.ts.map", - "sizeBytes": 853, - "sha256": "22f956bf0d909d109be49926c456031c783bafccc688056d1911b7440cdf77c3" + "sizeBytes": 854, + "sha256": "7795327eb88bff243fbadaa473286c7d6c4bb24e4d0b11d7d7ff314dc9460112" }, { "path": "dist/cli/commands/BaseCommand.js.map", - "sizeBytes": 1740, - "sha256": "14ec91bb62f94bc02328d085cf1d6b7541f3e17d10703c39f85df40969cd84eb" + "sizeBytes": 1771, + "sha256": "ea85d352f6620d0688afa1bb3c82f2c9c09b3bec6c06bfcc29d6d9f14f12578a" }, { "path": "dist/alphalib/types/bill.d.ts.map", @@ -1298,13 +1298,13 @@ }, { "path": "dist/cli/helpers.d.ts.map", - "sizeBytes": 1009, - "sha256": "82e3c44365f968ff76a7424812b2e21fc82a69205c50611242c9fb97990e90f3" + "sizeBytes": 1177, + "sha256": "0f65f407f35fe35bc23481feaf7146d051d3946557ae80008228844d563e41d5" }, { "path": "dist/cli/helpers.js.map", - "sizeBytes": 2826, - "sha256": "6729657286fe0467eab3544fda8f702a9e06a9da8cbedf0083276608ff805c52" + "sizeBytes": 3067, + "sha256": "d644704a139a2ac26a8c7a2ba27b7eee939aecd8506d437ed616cc764eabe8c2" }, { "path": "dist/alphalib/types/robots/html-convert.d.ts.map", @@ -1419,12 +1419,12 @@ { "path": "dist/cli/commands/index.d.ts.map", "sizeBytes": 198, - "sha256": "7f72c6762c95dac3b7e6cd256abab44c210c471e40a0558ad20020cf15fdd983" + "sha256": "3f955192e7d7832d6fd0c8ee0244b153e42c947686425750c7c8c58d6657f2a7" }, { "path": "dist/cli/commands/index.js.map", - "sizeBytes": 1889, - "sha256": "47f1867c39793eebbafb7229433aff4b573e408690ffdeb11457533a71dd54aa" + "sizeBytes": 1940, + "sha256": "1cad8333ee5fd6c34071a6d8528a7b55399be0626baf1754e28453d714836868" }, { "path": "dist/inputFiles.d.ts.map", @@ -2008,8 +2008,8 @@ }, { "path": "README.md", - "sizeBytes": 35551, - "sha256": "442743aa79f063ee5da4e50601debb28b492377b3359aa4c2596f4adefbd372a" + "sizeBytes": 35827, + "sha256": "4eeaaee318cb9336246280c4f3247c27de0df83d6738863123f5c9eac9429908" }, { "path": "dist/alphalib/types/robots/_index.d.ts", @@ -2223,13 +2223,13 @@ }, { "path": "dist/cli/commands/auth.d.ts", - "sizeBytes": 936, - "sha256": "20a1d35fb55fad8af33fb6decede3cbf2cd621007fe443d2866e9975bbe23b20" + "sizeBytes": 1406, + "sha256": "885d57814cc0ac4f6554576811748beee3902a8ef63b70746a76c22aff2b0acc" }, { "path": "src/cli/commands/auth.ts", - "sizeBytes": 10626, - "sha256": "b5f4d2404f455ad2e3354cd963a3468bb2d57a1808ce715f715cea1ad87b2245" + "sizeBytes": 16193, + "sha256": "42ebd9ee94e5a21001f456c47d9f722f73df6b7b06f5ce82fab857e93b77f70c" }, { "path": "dist/alphalib/types/robots/azure-import.d.ts", @@ -2278,8 +2278,8 @@ }, { "path": "src/cli/commands/BaseCommand.ts", - "sizeBytes": 2146, - "sha256": "d0cab4ebb72ce5d555be82bf3de4ba1f09dd223b71702bc53527928cf1c7ac91" + "sizeBytes": 2101, + "sha256": "8716f8a22898d35c025986a31a9234b43a8eaed09f7120b8f6424ff8d045fd50" }, { "path": "dist/alphalib/types/bill.d.ts", @@ -2623,13 +2623,13 @@ }, { "path": "dist/cli/helpers.d.ts", - "sizeBytes": 898, - "sha256": "d225c538d7cd4d73e88045729fc6f59b66de8af9304b039cdd496e1487860eb6" + "sizeBytes": 1073, + "sha256": "aeed9d1c1186c561cd846c905bac2d9738e88c80e5527c125f47ad6b332d10ee" }, { "path": "src/cli/helpers.ts", - "sizeBytes": 2799, - "sha256": "0d43593eb6e5d985287d67fec8e758d8fcc903fd1ce86cc2a9b8152b66998059" + "sizeBytes": 3340, + "sha256": "9741aa20b83f837889d248d5b095e6ec2336186bc5ab2b6caa23174950562919" }, { "path": "dist/alphalib/types/robots/html-convert.d.ts", @@ -2748,8 +2748,8 @@ }, { "path": "src/cli/commands/index.ts", - "sizeBytes": 2001, - "sha256": "762f6b157cbb43839b496850ec5918c4e0efb94e88be3c45e06171e3771a7e8f" + "sizeBytes": 2044, + "sha256": "b6752fa800c6a91e662b75a0c0973f0ba513f263d4a96d5e46a0d3e1f1a9f828" }, { "path": "dist/inputFiles.d.ts", diff --git a/docs/fingerprint/transloadit-baseline.package.json b/docs/fingerprint/transloadit-baseline.package.json index aec41f09..bfb6c1d0 100644 --- a/docs/fingerprint/transloadit-baseline.package.json +++ b/docs/fingerprint/transloadit-baseline.package.json @@ -1,6 +1,6 @@ { "name": "transloadit", - "version": "4.7.1", + "version": "4.7.2", "description": "Node.js SDK for Transloadit", "homepage": "https://github.com/transloadit/node-sdk/tree/main/packages/node", "bugs": { diff --git a/packages/node/README.md b/packages/node/README.md index 7abe6e00..1375310d 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -57,6 +57,16 @@ export TRANSLOADIT_SECRET="YOUR_TRANSLOADIT_SECRET" npx transloadit --help ``` +### Minting Bearer Tokens (Hosted MCP) + +If you want to connect an agent to the Transloadit-hosted MCP endpoint, mint a short-lived bearer +token via `POST /token`: + +```bash +# Prints JSON to stdout (stderr may include npx/npm noise) +npx -y transloadit auth token --aud mcp +``` + ### Processing Media Create Assemblies to process files using Assembly Instructions (steps) or Templates: diff --git a/packages/node/src/cli/commands/BaseCommand.ts b/packages/node/src/cli/commands/BaseCommand.ts index e652ec25..fee02d72 100644 --- a/packages/node/src/cli/commands/BaseCommand.ts +++ b/packages/node/src/cli/commands/BaseCommand.ts @@ -2,7 +2,7 @@ import 'dotenv/config' import process from 'node:process' import { Command, Option } from 'clipanion' import { Transloadit as TransloaditClient } from '../../Transloadit.ts' -import { getEnvCredentials } from '../helpers.ts' +import { requireEnvCredentials } from '../helpers.ts' import type { IOutputCtl } from '../OutputCtl.ts' import OutputCtl, { LOG_LEVEL_DEFAULT, LOG_LEVEL_NAMES, parseLogLevel } from '../OutputCtl.ts' @@ -32,17 +32,18 @@ abstract class BaseCommand extends Command { } protected setupClient(): boolean { - const creds = getEnvCredentials() - if (!creds) { - this.output.error( - 'Please provide API authentication in the environment variables TRANSLOADIT_KEY and TRANSLOADIT_SECRET', - ) + const credsResult = requireEnvCredentials() + if (!credsResult.ok) { + this.output.error(credsResult.error) return false } const endpoint = this.endpoint || process.env.TRANSLOADIT_ENDPOINT - this.client = new TransloaditClient({ ...creds, ...(endpoint && { endpoint }) }) + this.client = new TransloaditClient({ + ...credsResult.credentials, + ...(endpoint && { endpoint }), + }) return true } diff --git a/packages/node/src/cli/commands/auth.ts b/packages/node/src/cli/commands/auth.ts index aa5f4f35..50c42c5c 100644 --- a/packages/node/src/cli/commands/auth.ts +++ b/packages/node/src/cli/commands/auth.ts @@ -8,7 +8,7 @@ import { } from '../../alphalib/types/template.ts' import type { OptionalAuthParams } from '../../apiTypes.ts' import { Transloadit } from '../../Transloadit.ts' -import { getEnvCredentials, readCliInput } from '../helpers.ts' +import { readCliInput, requireEnvCredentials } from '../helpers.ts' import { UnauthenticatedCommand } from './BaseCommand.ts' type UrlParamPrimitive = string | number | boolean @@ -68,40 +68,51 @@ function normalizeUrlParams(params?: Record): NormalizedUrlPara return normalized } -const getCredentials = getEnvCredentials +type OutputResult = { ok: true; output: string } | { ok: false; error: string } -// Result type for signature operations -type SigResult = { ok: true; output: string } | { ok: false; error: string } +type Result = { ok: true; value: T } | { ok: false; error: string } + +function parseJsonObject( + input: string, + schema: TSchema, +): Result> { + let parsed: unknown + try { + parsed = JSON.parse(input) + } catch (error) { + return { ok: false, error: `Failed to parse JSON from stdin: ${(error as Error).message}` } + } + + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, error: 'Invalid params provided via stdin. Expected a JSON object.' } + } + + const parsedResult = schema.safeParse(parsed) + if (!parsedResult.success) { + return { ok: false, error: `Invalid params: ${formatIssues(parsedResult.error.issues)}` } + } + + return { ok: true, value: parsedResult.data } +} // Core logic for signature generation function generateSignature( input: string, credentials: { authKey: string; authSecret: string }, algorithm?: string, -): SigResult { +): OutputResult { const { authKey, authSecret } = credentials let params: CliSignatureParams if (input === '') { params = { auth: { key: authKey } } } else { - let parsed: unknown - try { - parsed = JSON.parse(input) - } catch (error) { - return { ok: false, error: `Failed to parse JSON from stdin: ${(error as Error).message}` } - } - - if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { ok: false, error: 'Invalid params provided via stdin. Expected a JSON object.' } + const parsedResult = parseJsonObject(input, cliSignatureParamsSchema) + if (!parsedResult.ok) { + return { ok: false, error: parsedResult.error } } - const parsedResult = cliSignatureParamsSchema.safeParse(parsed) - if (!parsedResult.success) { - return { ok: false, error: `Invalid params: ${formatIssues(parsedResult.error.issues)}` } - } - - const parsedParams = parsedResult.data + const parsedParams = parsedResult.value const existingAuth = parsedParams.auth ?? {} params = { @@ -126,7 +137,7 @@ function generateSignature( function generateSmartCdnUrl( input: string, credentials: { authKey: string; authSecret: string }, -): SigResult { +): OutputResult { const { authKey, authSecret } = credentials if (input === '') { @@ -137,23 +148,12 @@ function generateSmartCdnUrl( } } - let parsed: unknown - try { - parsed = JSON.parse(input) - } catch (error) { - return { ok: false, error: `Failed to parse JSON from stdin: ${(error as Error).message}` } - } - - if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { ok: false, error: 'Invalid params provided via stdin. Expected a JSON object.' } - } - - const parsedResult = smartCdnParamsSchema.safeParse(parsed) - if (!parsedResult.success) { - return { ok: false, error: `Invalid params: ${formatIssues(parsedResult.error.issues)}` } + const parsedResult = parseJsonObject(input, smartCdnParamsSchema) + if (!parsedResult.ok) { + return { ok: false, error: parsedResult.error } } - const { workspace, template, input: inputFieldRaw, url_params, expire_at_ms } = parsedResult.data + const { workspace, template, input: inputFieldRaw, url_params, expire_at_ms } = parsedResult.value const urlParams = normalizeUrlParams(url_params) let expiresAt: number | undefined @@ -194,15 +194,163 @@ export interface RunSmartSigOptions { providedInput?: string } +export interface RequestTokenOptions { + endpoint?: string + aud?: string +} + +const tokenErrorSchema = z + .object({ + error: z.string(), + message: z.string().optional(), + }) + .passthrough() + +const tokenSuccessSchema = z + .object({ + access_token: z.string().min(1), + }) + .passthrough() + +const buildBasicAuthHeaderValue = (credentials: { authKey: string; authSecret: string }): string => + `Basic ${Buffer.from(`${credentials.authKey}:${credentials.authSecret}`, 'utf8').toString('base64')}` + +const isLoopbackHost = (hostname: string): boolean => + hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') + +type TokenBaseResult = { ok: true; baseUrl: URL } | { ok: false; error: string } + +const normalizeTokenBaseEndpoint = (raw?: string): TokenBaseResult => { + const baseRaw = (raw || process.env.TRANSLOADIT_ENDPOINT || 'https://api2.transloadit.com').trim() + + let url: URL + try { + url = new URL(baseRaw) + } catch { + return { + ok: false, + error: + 'Invalid endpoint URL. Use --endpoint https://api2.transloadit.com (or set TRANSLOADIT_ENDPOINT).', + } + } + + if (url.username || url.password) { + return { ok: false, error: 'Endpoint must not include username/password.' } + } + if (url.search || url.hash) { + return { ok: false, error: 'Endpoint must not include query string or hash.' } + } + + if (url.protocol !== 'https:') { + if (url.protocol === 'http:' && isLoopbackHost(url.hostname)) { + // Allowed for local development only. + } else { + return { + ok: false, + error: + 'Refusing to send credentials to a non-HTTPS endpoint. Use https://... (or http://localhost for local development).', + } + } + } + + // If someone pasted the token URL, normalize it back to the API base to avoid /token/token. + const pathLower = url.pathname.toLowerCase() + if (pathLower === '/token' || pathLower === '/token/') { + url.pathname = '/' + } + + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/` + } + + return { ok: true, baseUrl: url } +} + +async function requestTokenWithCredentials( + credentials: { authKey: string; authSecret: string }, + options: RequestTokenOptions = {}, +): Promise { + const endpointResult = normalizeTokenBaseEndpoint(options.endpoint) + if (!endpointResult.ok) { + return { ok: false, error: endpointResult.error } + } + + const url = new URL('token', endpointResult.baseUrl).toString() + const aud = (options.aud ?? 'mcp').trim() || 'mcp' + + const body = new URLSearchParams({ grant_type: 'client_credentials', aud }).toString() + + let res: Response + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + try { + res = await fetch(url, { + method: 'POST', + // Never follow redirects with Basic Auth credentials. + redirect: 'error', + signal: controller.signal, + headers: { + Authorization: buildBasicAuthHeaderValue(credentials), + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + } catch (err) { + clearTimeout(timeout) + if (err instanceof Error && err.name === 'AbortError') { + return { ok: false, error: 'Failed to mint bearer token: request timed out after 15s.' } + } + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: `Failed to mint bearer token: ${message}` } + } finally { + clearTimeout(timeout) + } + + const text = await res.text() + const trimmed = text.trim() + let parsedJson: unknown = null + try { + parsedJson = trimmed ? JSON.parse(trimmed) : null + } catch { + parsedJson = null + } + + if (res.ok) { + if (parsedJson == null) { + return { ok: false, error: 'Token response was not valid JSON.' } + } + const parsed = tokenSuccessSchema.safeParse(parsedJson) + if (!parsed.success) { + return { ok: false, error: 'Token response did not include an access_token.' } + } + return { ok: true, output: trimmed } + } + + const parsedError = tokenErrorSchema.safeParse(parsedJson) + if (parsedError.success) { + return { + ok: false, + error: parsedError.data.message + ? `${parsedError.data.error}: ${parsedError.data.message}` + : parsedError.data.error, + } + } + + return { + ok: false, + error: `Token request failed (${res.status}): ${trimmed || res.statusText}`, + } +} + export async function runSig(options: RunSigOptions = {}): Promise { - const credentials = getCredentials() - if (credentials == null) { - console.error( - 'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.', - ) + const credentialsResult = requireEnvCredentials() + if (!credentialsResult.ok) { + console.error(credentialsResult.error) process.exitCode = 1 return } + const credentials = credentialsResult.credentials const { content } = await readCliInput({ providedInput: options.providedInput, @@ -220,14 +368,13 @@ export async function runSig(options: RunSigOptions = {}): Promise { } export async function runSmartSig(options: RunSmartSigOptions = {}): Promise { - const credentials = getCredentials() - if (credentials == null) { - console.error( - 'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.', - ) + const credentialsResult = requireEnvCredentials() + if (!credentialsResult.ok) { + console.error(credentialsResult.error) process.exitCode = 1 return } + const credentials = credentialsResult.credentials const { content } = await readCliInput({ providedInput: options.providedInput, @@ -274,13 +421,12 @@ export class SignatureCommand extends UnauthenticatedCommand { }) protected async run(): Promise { - const credentials = getCredentials() - if (credentials == null) { - this.output.error( - 'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.', - ) + const credentialsResult = requireEnvCredentials() + if (!credentialsResult.ok) { + this.output.error(credentialsResult.error) return 1 } + const credentials = credentialsResult.credentials const { content } = await readCliInput({ allowStdinWhenNoPath: true }) const rawInput = (content ?? '').trim() @@ -328,13 +474,12 @@ export class SmartCdnSignatureCommand extends UnauthenticatedCommand { }) protected async run(): Promise { - const credentials = getCredentials() - if (credentials == null) { - this.output.error( - 'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.', - ) + const credentialsResult = requireEnvCredentials() + if (!credentialsResult.ok) { + this.output.error(credentialsResult.error) return 1 } + const credentials = credentialsResult.credentials const { content } = await readCliInput({ allowStdinWhenNoPath: true }) const rawInput = (content ?? '').trim() @@ -349,3 +494,50 @@ export class SmartCdnSignatureCommand extends UnauthenticatedCommand { return 1 } } + +/** + * Mint a short-lived bearer token via POST /token (HTTP Basic Auth). + * + * This is intentionally stdout-clean JSON so it can be used by agents and scripts. + */ +export class TokenCommand extends UnauthenticatedCommand { + static override paths = [['auth', 'token']] + + static override usage = Command.Usage({ + category: 'Auth', + description: 'Mint a short-lived bearer token', + details: ` + Calls POST /token using HTTP Basic Auth (TRANSLOADIT_KEY + TRANSLOADIT_SECRET) and prints the + JSON response to stdout. + `, + examples: [ + ['Mint an MCP token (default aud)', 'transloadit auth token'], + ['Override audience', 'transloadit auth token --aud api2'], + ], + }) + + aud = Option.String('--aud', { + description: 'Token audience (default: mcp).', + }) + + protected override async run(): Promise { + const credentialsResult = requireEnvCredentials() + if (!credentialsResult.ok) { + this.output.error(credentialsResult.error) + return 1 + } + + const result = await requestTokenWithCredentials(credentialsResult.credentials, { + endpoint: this.endpoint, + aud: this.aud, + }) + + if (result.ok) { + process.stdout.write(`${result.output}\n`) + return undefined + } + + this.output.error(result.error) + return 1 + } +} diff --git a/packages/node/src/cli/commands/index.ts b/packages/node/src/cli/commands/index.ts index 5860d475..8f048784 100644 --- a/packages/node/src/cli/commands/index.ts +++ b/packages/node/src/cli/commands/index.ts @@ -11,7 +11,7 @@ import { AssembliesReplayCommand, } from './assemblies.ts' -import { SignatureCommand, SmartCdnSignatureCommand } from './auth.ts' +import { SignatureCommand, SmartCdnSignatureCommand, TokenCommand } from './auth.ts' import { BillsGetCommand } from './bills.ts' import { DocsRobotsGetCommand, DocsRobotsListCommand } from './docs.ts' @@ -40,6 +40,7 @@ export function createCli(): Cli { // Auth commands (signature generation) cli.register(SignatureCommand) cli.register(SmartCdnSignatureCommand) + cli.register(TokenCommand) // Assemblies commands cli.register(AssembliesCreateCommand) diff --git a/packages/node/src/cli/helpers.ts b/packages/node/src/cli/helpers.ts index c30586d3..b0eb40cb 100644 --- a/packages/node/src/cli/helpers.ts +++ b/packages/node/src/cli/helpers.ts @@ -3,7 +3,12 @@ import fsp from 'node:fs/promises' import type { Readable } from 'node:stream' import { isAPIError } from './types.ts' -export function getEnvCredentials(): { authKey: string; authSecret: string } | null { +const MISSING_CREDENTIALS_MESSAGE = + 'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.' + +type EnvCredentials = { authKey: string; authSecret: string } + +function getEnvCredentials(): { authKey: string; authSecret: string } | null { const authKey = process.env.TRANSLOADIT_KEY ?? process.env.TRANSLOADIT_AUTH_KEY const authSecret = process.env.TRANSLOADIT_SECRET ?? process.env.TRANSLOADIT_AUTH_SECRET @@ -12,6 +17,16 @@ export function getEnvCredentials(): { authKey: string; authSecret: string } | n return { authKey, authSecret } } +type RequireEnvCredentialsResult = + | { ok: true; credentials: EnvCredentials } + | { ok: false; error: string } + +export function requireEnvCredentials(): RequireEnvCredentialsResult { + const credentials = getEnvCredentials() + if (credentials == null) return { ok: false, error: MISSING_CREDENTIALS_MESSAGE } + return { ok: true, credentials } +} + export function createReadStream(file: string): Readable { if (file === '-') return process.stdin return fs.createReadStream(file) diff --git a/packages/node/test/unit/cli/auth-token.test.ts b/packages/node/test/unit/cli/auth-token.test.ts new file mode 100644 index 00000000..eb861c54 --- /dev/null +++ b/packages/node/test/unit/cli/auth-token.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { main } from '../../../src/cli.ts' + +const resetExitCode = () => { + process.exitCode = undefined +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + resetExitCode() +}) + +describe('cli auth token', () => { + const stubCreds = (): void => { + vi.stubEnv('TRANSLOADIT_KEY', 'key') + vi.stubEnv('TRANSLOADIT_SECRET', 'secret') + } + + const stubFetchJson = (payload: unknown, status = 200): ReturnType => { + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch) + return fetchSpy + } + + it('prints the token JSON to stdout and nothing else', async () => { + stubCreds() + + stubFetchJson({ + access_token: 'abc', + token_type: 'Bearer', + expires_in: 21600, + }) + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--aud', 'mcp', '--endpoint', 'https://api2.transloadit.com']) + + expect(stderrSpy).not.toHaveBeenCalled() + expect(stdoutSpy).toHaveBeenCalledTimes(1) + expect(`${stdoutSpy.mock.calls[0]?.[0]}`).toBe( + '{"access_token":"abc","token_type":"Bearer","expires_in":21600}\n', + ) + expect(process.exitCode).toBeUndefined() + }) + + it('defaults aud to mcp and sends form-encoded payload (no redirects)', async () => { + stubCreds() + + const fetchSpy = stubFetchJson({ access_token: 'abc', token_type: 'Bearer', expires_in: 1 }) + + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--endpoint', 'https://api2.transloadit.com']) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + + expect(url).toBe('https://api2.transloadit.com/token') + expect(init.method).toBe('POST') + expect(init.redirect).toBe('error') + expect(init.signal).toBeDefined() + expect((init.headers as Record)['Content-Type']).toBe( + 'application/x-www-form-urlencoded', + ) + expect((init.headers as Record).Accept).toBe('application/json') + + const auth = (init.headers as Record).Authorization + expect(auth).toMatch(/^Basic /) + + expect(init.body).toBe('grant_type=client_credentials&aud=mcp') + }) + + it('treats whitespace-only --aud as mcp', async () => { + stubCreds() + + const fetchSpy = stubFetchJson({ access_token: 'abc', token_type: 'Bearer', expires_in: 1 }) + + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--aud', ' ', '--endpoint', 'https://api2.transloadit.com']) + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(init.body).toBe('grant_type=client_credentials&aud=mcp') + }) + + it('normalizes endpoints that already include /token', async () => { + stubCreds() + + const fetchSpy = stubFetchJson({ access_token: 'abc', token_type: 'Bearer', expires_in: 1 }) + + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--endpoint', 'https://api2.transloadit.com/token']) + + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api2.transloadit.com/token') + }) + + it('writes errors to stderr and exits 1 on API errors', async () => { + stubCreds() + + stubFetchJson({ error: 'TOKEN_INVALID_AUDIENCE', message: 'Invalid audience' }, 400) + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--aud', 'nope', '--endpoint', 'https://api2.transloadit.com']) + + expect(stdoutSpy).not.toHaveBeenCalled() + expect(stderrSpy).toHaveBeenCalled() + const stderrText = stderrSpy.mock.calls.map((call) => call.map(String).join(' ')).join('\n') + expect(stderrText).toContain('TOKEN_INVALID_AUDIENCE') + expect(process.exitCode).toBe(1) + }) + + it('refuses to send credentials to non-https endpoints (except localhost)', async () => { + stubCreds() + + const fetchSpy = vi.fn(async () => new Response('ok', { status: 200 })) + vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch) + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--endpoint', 'http://example.com']) + + expect(fetchSpy).not.toHaveBeenCalled() + expect(stdoutSpy).not.toHaveBeenCalled() + expect(stderrSpy).toHaveBeenCalled() + const stderrText = stderrSpy.mock.calls.map((call) => call.map(String).join(' ')).join('\n') + expect(stderrText).toContain('Refusing to send credentials') + expect(process.exitCode).toBe(1) + }) + + it('allows http://localhost for local development', async () => { + stubCreds() + + const fetchSpy = stubFetchJson({ access_token: 'abc', token_type: 'Bearer', expires_in: 1 }) + + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--endpoint', 'http://localhost:3000']) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://localhost:3000/token') + expect(process.exitCode).toBeUndefined() + }) + + it('allows http://127.0.0.0/8 for local development', async () => { + stubCreds() + + const fetchSpy = stubFetchJson({ access_token: 'abc', token_type: 'Bearer', expires_in: 1 }) + + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--endpoint', 'http://127.0.0.2:3000']) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://127.0.0.2:3000/token') + expect(process.exitCode).toBeUndefined() + }) + + it('uses TRANSLOADIT_ENDPOINT when --endpoint is not provided', async () => { + stubCreds() + vi.stubEnv('TRANSLOADIT_ENDPOINT', 'https://api2.transloadit.com') + + const fetchSpy = stubFetchJson({ access_token: 'abc', token_type: 'Bearer', expires_in: 1 }) + + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token']) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api2.transloadit.com/token') + }) + + it('fails with a friendly error if endpoint is invalid', async () => { + stubCreds() + + const fetchSpy = vi.fn(async () => new Response('ok', { status: 200 })) + vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch) + + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--endpoint', 'not-a-url']) + + expect(fetchSpy).not.toHaveBeenCalled() + expect(stderrSpy).toHaveBeenCalled() + const stderrText = stderrSpy.mock.calls.map((call) => call.map(String).join(' ')).join('\n') + expect(stderrText).toContain('Invalid endpoint URL') + expect(process.exitCode).toBe(1) + }) + + it('exits 1 if the success response is missing access_token', async () => { + stubCreds() + + const fetchSpy = stubFetchJson({}, 200) + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token', '--endpoint', 'https://api2.transloadit.com']) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(stdoutSpy).not.toHaveBeenCalled() + const stderrText = stderrSpy.mock.calls.map((call) => call.map(String).join(' ')).join('\n') + expect(stderrText).toContain('access_token') + expect(process.exitCode).toBe(1) + }) + + it('fails when credentials are missing', async () => { + vi.stubEnv('TRANSLOADIT_KEY', '') + vi.stubEnv('TRANSLOADIT_SECRET', '') + vi.stubEnv('TRANSLOADIT_AUTH_KEY', '') + vi.stubEnv('TRANSLOADIT_AUTH_SECRET', '') + + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['auth', 'token']) + + expect(stderrSpy).toHaveBeenCalled() + const stderrText = stderrSpy.mock.calls.map((call) => call.map(String).join(' ')).join('\n') + expect(stderrText).toContain('Missing credentials') + expect(process.exitCode).toBe(1) + }) +})