117 lines
3.2 KiB
TypeScript
117 lines
3.2 KiB
TypeScript
/**
|
|
* JWT token generator for Ghost Admin API
|
|
* Implements HS256 signing compatible with Ghost's authentication
|
|
*/
|
|
|
|
/**
|
|
* Base64 URL encode (without padding)
|
|
*/
|
|
function base64UrlEncode(data: Uint8Array): string {
|
|
const base64 = typeof Buffer !== 'undefined'
|
|
? Buffer.from(data).toString('base64')
|
|
: btoa(String.fromCharCode(...data));
|
|
|
|
return base64
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '');
|
|
}
|
|
|
|
/**
|
|
* Convert hex string to Uint8Array
|
|
*/
|
|
function hexToBytes(hex: string): Uint8Array {
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/**
|
|
* Generate JWT token for Ghost Admin API
|
|
* @param key - Admin API key in format {id}:{secret}
|
|
* @param audience - Token audience (API prefix like '/admin/')
|
|
* @returns JWT token string
|
|
*/
|
|
export async function generateToken(key: string, audience: string): Promise<string> {
|
|
// Parse the admin key
|
|
const [keyId, secret] = key.split(':');
|
|
|
|
if (!keyId || !secret) {
|
|
throw new Error('Invalid admin API key format. Expected {id}:{secret}');
|
|
}
|
|
|
|
if (keyId.length !== 24 || secret.length !== 64) {
|
|
throw new Error('Invalid admin API key format. Expected 24 hex chars for id and 64 for secret');
|
|
}
|
|
|
|
// Create JWT header
|
|
const header = {
|
|
alg: 'HS256',
|
|
typ: 'JWT',
|
|
kid: keyId
|
|
};
|
|
|
|
// Create JWT payload
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = {
|
|
iat: now,
|
|
exp: now + 300, // 5 minutes
|
|
aud: audience
|
|
};
|
|
|
|
// Encode header and payload
|
|
const headerEncoded = base64UrlEncode(
|
|
new TextEncoder().encode(JSON.stringify(header))
|
|
);
|
|
const payloadEncoded = base64UrlEncode(
|
|
new TextEncoder().encode(JSON.stringify(payload))
|
|
);
|
|
|
|
// Create signature data
|
|
const signatureData = `${headerEncoded}.${payloadEncoded}`;
|
|
|
|
// Convert secret from hex to bytes
|
|
const secretBytes = hexToBytes(secret);
|
|
|
|
// Import key for HMAC
|
|
let cryptoKey: CryptoKey;
|
|
|
|
// Try to use Web Crypto API (works in Node 15+ and all modern browsers)
|
|
try {
|
|
const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
|
|
// Convert to proper BufferSource type
|
|
const secretBuffer = secretBytes.buffer.slice(secretBytes.byteOffset, secretBytes.byteOffset + secretBytes.byteLength) as ArrayBuffer;
|
|
cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
secretBuffer,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
// Sign the data
|
|
const signature = await crypto.subtle.sign(
|
|
'HMAC',
|
|
cryptoKey,
|
|
new TextEncoder().encode(signatureData)
|
|
);
|
|
|
|
// Encode signature
|
|
const signatureEncoded = base64UrlEncode(new Uint8Array(signature));
|
|
|
|
// Return complete JWT
|
|
return `${signatureData}.${signatureEncoded}`;
|
|
} catch (error) {
|
|
// Fallback for older Node versions using crypto module directly
|
|
const crypto = await import('crypto');
|
|
const hmac = crypto.createHmac('sha256', secretBytes);
|
|
hmac.update(signatureData);
|
|
const signature = hmac.digest();
|
|
const signatureEncoded = base64UrlEncode(signature);
|
|
|
|
return `${signatureData}.${signatureEncoded}`;
|
|
}
|
|
}
|