/**
 * OAuth state signer
 *
 * Signs the `state` parameter on outbound OAuth flows so the server can prove,
 * on callback/claim, that the userId + connector + origin it sees were minted
 * by this instance — not forged by an attacker controlling a callback URL.
 *
 * Without integrity protection on `state`, an attacker who controls a
 * `/api/oauth/callback/:name` or `/api/oauth/claim/:connector` URL can
 * forge `{c, u, o}` to bind their own OAuth tokens against a victim's
 * userId. This module closes that gap.
 *
 * Signing key is derived from the workspace master key via HKDF
 * (info="studiograph-oauth-state-v1") so it's distinct from the AEAD key
 * used for at-rest encryption — a leak of one doesn't compromise the other.
 *
 * The serialized state is a base64url-encoded JSON object:
 *   { c, u, o, t, s }
 * where `s` is the base64url HMAC-SHA256 over the canonical message
 * (a JSON array of [c, u, o, t]). The OAuth broker continues to decode
 * `c`, `u`, `o` from this JSON unchanged — adding `t` and `s` is forward-
 * compatible.
 */
import { hkdfSync, createHmac, timingSafeEqual } from 'crypto';
const HKDF_INFO = 'studiograph-oauth-state-v1';
const HKDF_KEY_LENGTH = 32;
const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
const FUTURE_SKEW_TOLERANCE_MS = 60_000;
/**
 * Derive a 32-byte signing key from the workspace master key. The `info`
 * binding ensures this key is domain-separated from the AEAD key used to
 * encrypt secrets at rest.
 */
export function deriveStateSigningKey(masterKey) {
    if (masterKey.length !== 32) {
        throw new Error('state-signer: master key must be 32 bytes');
    }
    const ikm = Buffer.from(masterKey);
    // hkdfSync returns ArrayBuffer; wrap in Uint8Array view.
    const derived = hkdfSync('sha256', ikm, Buffer.alloc(0), HKDF_INFO, HKDF_KEY_LENGTH);
    return new Uint8Array(derived);
}
export function createOAuthStateSigner(key) {
    if (key.length !== HKDF_KEY_LENGTH) {
        throw new Error(`state-signer: signing key must be ${HKDF_KEY_LENGTH} bytes`);
    }
    const keyBuf = Buffer.from(key);
    return {
        sign({ connectorName, userId, origin }) {
            if (typeof connectorName !== 'string' || connectorName.length === 0) {
                throw new Error('state-signer: connectorName is required');
            }
            if (!Number.isInteger(userId)) {
                throw new Error('state-signer: userId must be an integer');
            }
            if (typeof origin !== 'string' || origin.length === 0) {
                throw new Error('state-signer: origin is required');
            }
            const t = Date.now();
            const message = canonicalMessage({ c: connectorName, u: userId, o: origin, t });
            const sig = createHmac('sha256', keyBuf).update(message).digest();
            const blob = {
                c: connectorName,
                u: userId,
                o: origin,
                t,
                s: sig.toString('base64url'),
            };
            return Buffer.from(JSON.stringify(blob)).toString('base64url');
        },
        verify(state, opts) {
            try {
                const decoded = Buffer.from(state, 'base64url').toString('utf-8');
                const parsed = JSON.parse(decoded);
                if (!parsed ||
                    typeof parsed.c !== 'string' ||
                    typeof parsed.u !== 'number' ||
                    !Number.isInteger(parsed.u) ||
                    typeof parsed.o !== 'string' ||
                    typeof parsed.t !== 'number' ||
                    !Number.isFinite(parsed.t) ||
                    typeof parsed.s !== 'string') {
                    return null;
                }
                const expected = createHmac('sha256', keyBuf)
                    .update(canonicalMessage({ c: parsed.c, u: parsed.u, o: parsed.o, t: parsed.t }))
                    .digest();
                let provided;
                try {
                    provided = Buffer.from(parsed.s, 'base64url');
                }
                catch {
                    return null;
                }
                if (expected.length !== provided.length)
                    return null;
                if (!timingSafeEqual(expected, provided))
                    return null;
                const now = Date.now();
                const maxAge = opts?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
                if (now - parsed.t > maxAge)
                    return null;
                if (parsed.t - now > FUTURE_SKEW_TOLERANCE_MS)
                    return null;
                return {
                    connectorName: parsed.c,
                    userId: parsed.u,
                    origin: parsed.o,
                };
            }
            catch {
                return null;
            }
        },
    };
}
function canonicalMessage(p) {
    return JSON.stringify([p.c, p.u, p.o, p.t]);
}
// ── Consent-binding signer (OAuth authorization server) ──
//
// The authorization server's `/oauth/authorize` GET renders a consent screen
// whose hidden field carries the authorize parameters (client_id,
// redirect_uri, code_challenge, scope, resource, userId, state). The POST
// handler must trust those params came from a GET we rendered for *this*
// logged-in user — without a signature an attacker could forge the form and
// mint a code bound to a victim. We HMAC-sign an arbitrary JSON payload with a
// timestamp, distinct from the outbound state signer above (different HKDF
// `info`), and key it off the JWT secret so the whole AS depends only on a
// value that is always present (no master key required, unlike state signing).
const CONSENT_HKDF_INFO = 'studiograph-oauth-consent-v1';
const CONSENT_DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
/**
 * Derive the consent signing key from the JWT secret via HKDF. Domain-separated
 * from session JWTs (different mechanism entirely) and from the outbound state
 * signer (different `info`). Keying off the JWT secret — rather than the master
 * key — means the authorization server works on every boot, including
 * non-encrypted/test boots where no master key is wired.
 */
export function deriveConsentSigningKey(jwtSecret) {
    if (typeof jwtSecret !== 'string' || jwtSecret.length === 0) {
        throw new Error('consent-signer: jwtSecret is required');
    }
    const derived = hkdfSync('sha256', Buffer.from(jwtSecret, 'utf-8'), Buffer.alloc(0), CONSENT_HKDF_INFO, HKDF_KEY_LENGTH);
    return new Uint8Array(derived);
}
/**
 * Generic HMAC payload signer: serializes `{ p: <payload>, t: <ts> }`, signs
 * the canonical JSON with HMAC-SHA256, and base64url-encodes the blob. `verify`
 * recomputes the HMAC with `timingSafeEqual`, enforces a max age, and rejects
 * future-dated tokens. Returns the payload object on success, null otherwise.
 */
export function createConsentSigner(key) {
    if (key.length !== HKDF_KEY_LENGTH) {
        throw new Error(`consent-signer: signing key must be ${HKDF_KEY_LENGTH} bytes`);
    }
    const keyBuf = Buffer.from(key);
    return {
        sign(payload) {
            const t = Date.now();
            const message = JSON.stringify([payload, t]);
            const sig = createHmac('sha256', keyBuf).update(message).digest();
            const blob = { p: payload, t, s: sig.toString('base64url') };
            return Buffer.from(JSON.stringify(blob)).toString('base64url');
        },
        verify(token, opts) {
            try {
                const parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf-8'));
                if (!parsed ||
                    typeof parsed.p !== 'object' ||
                    parsed.p === null ||
                    typeof parsed.t !== 'number' ||
                    !Number.isFinite(parsed.t) ||
                    typeof parsed.s !== 'string') {
                    return null;
                }
                const expected = createHmac('sha256', keyBuf)
                    .update(JSON.stringify([parsed.p, parsed.t]))
                    .digest();
                let provided;
                try {
                    provided = Buffer.from(parsed.s, 'base64url');
                }
                catch {
                    return null;
                }
                if (expected.length !== provided.length)
                    return null;
                if (!timingSafeEqual(expected, provided))
                    return null;
                const now = Date.now();
                const maxAge = opts?.maxAgeMs ?? CONSENT_DEFAULT_MAX_AGE_MS;
                if (now - parsed.t > maxAge)
                    return null;
                if (parsed.t - now > FUTURE_SKEW_TOLERANCE_MS)
                    return null;
                return parsed.p;
            }
            catch {
                return null;
            }
        },
    };
}
//# sourceMappingURL=state-signer.js.map