/**
 * OAuth 2.0 Authorization Server store
 *
 * Studiograph's `/mcp` server is an OAuth resource server AND authorization
 * server: Claude Desktop's Chat/Cowork connector pipeline is OAuth-only, so a
 * client registers (DCR), runs an authorization-code + PKCE flow, and exchanges
 * the code for a short-lived access token (a JWT accepted on `/mcp`) plus a
 * revocable refresh token.
 *
 * This class owns the three authorization-server tables in `auth.db` and all
 * code/token minting. It is deliberately separate from AuthService so the
 * internet-facing AS surface can be audited and unit-tested on its own. It does
 * NOT resolve users or enforce access control itself — it reuses the shared
 * identity primitives via the injected `resolveUser` callback and never
 * declares a local access helper (a past leak came from a route duplicating the
 * shared access check).
 *
 *   register ──▶ oauth_clients ──┐
 *   authorize ─▶ oauth_auth_codes │ (single-use, ≤60s)
 *   token ─────▶ consume code, mint access JWT (aud=/mcp) + refresh token
 *   refresh ───▶ rotate (atomic consume-old + insert-new)
 *   gc ────────▶ drop expired codes/refresh + orphan clients
 *
 * Token format:
 *   - access:  JWT signed with the workspace jwtSecret, ~1h, claims
 *              { userId, email, role, oauth:true, cid:<client_id>, aud:<resource> }.
 *              Accepted by the existing verifyToken path; the auth hook scopes
 *              it to `/mcp` + `/oauth/*` via the `oauth`/`aud` claims.
 *   - refresh: opaque `sgr_…`, only its sha256 hash is stored, revocable,
 *              with an absolute `expires_at`.
 */
import { randomBytes, createHash, timingSafeEqual } from 'crypto';
import jwt from 'jsonwebtoken';
import { deriveConsentSigningKey, createConsentSigner } from '../mcp/state-signer.js';
const ACCESS_TOKEN_TTL = '1h';
const CODE_TTL_MS = 60_000; // authorization codes: ≤60s
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // refresh tokens: 30d absolute
const CLIENT_GC_AGE_MS = 24 * 60 * 60 * 1000; // unused registered clients: 24h
const MAX_REDIRECT_URIS = 5;
const MAX_FIELD_LEN = 2048;
function sha256(s) {
    return createHash('sha256').update(s).digest('hex');
}
/**
 * Validate a redirect URI presented at registration. Public OAuth clients are
 * the riskiest open-redirect surface, so we reject anything that isn't a clean,
 * absolute, fragment-free URL: HTTPS only, except http loopback for local dev.
 */
export function isValidRedirectUri(uri, opts) {
    if (typeof uri !== 'string' || uri.length === 0 || uri.length > MAX_FIELD_LEN)
        return false;
    let url;
    try {
        url = new URL(uri);
    }
    catch {
        return false;
    }
    if (url.hash)
        return false; // no fragments (RFC 6749 §3.1.2)
    if (url.username || url.password)
        return false;
    if (url.href.includes('*'))
        return false; // no wildcards
    if (url.protocol === 'https:')
        return true;
    if (url.protocol === 'http:' && opts?.allowLoopback) {
        return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
    }
    return false;
}
export class OAuthServerStore {
    db;
    jwtSecret;
    resolveUser;
    consentSigner;
    constructor(opts) {
        this.db = opts.db;
        this.jwtSecret = opts.jwtSecret;
        this.resolveUser = opts.resolveUser;
        this.consentSigner = createConsentSigner(deriveConsentSigningKey(opts.jwtSecret));
        this.initSchema();
    }
    initSchema() {
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS oauth_clients (
        client_id TEXT PRIMARY KEY,
        client_name TEXT NOT NULL,
        redirect_uris TEXT NOT NULL,
        client_secret_hash TEXT,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        last_token_at TEXT
      );
      CREATE TABLE IF NOT EXISTS oauth_auth_codes (
        code_hash TEXT PRIMARY KEY,
        client_id TEXT NOT NULL,
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        redirect_uri TEXT NOT NULL,
        code_challenge TEXT NOT NULL,
        scope TEXT NOT NULL,
        resource TEXT NOT NULL,
        expires_at INTEGER NOT NULL,
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
      );
      CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
        token_hash TEXT PRIMARY KEY,
        client_id TEXT NOT NULL,
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        scope TEXT NOT NULL,
        expires_at INTEGER NOT NULL,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        last_used_at TEXT
      );
      CREATE INDEX IF NOT EXISTS idx_oauth_refresh_user ON oauth_refresh_tokens(user_id);
    `);
    }
    // ── Consent binding (signed blob carried through the consent screen) ──
    signConsent(payload) {
        return this.consentSigner.sign(payload);
    }
    verifyConsent(token) {
        return this.consentSigner.verify(token);
    }
    // ── Dynamic client registration ──
    registerClient(input, opts) {
        const name = (input.client_name || '').trim();
        if (!name || name.length > MAX_FIELD_LEN) {
            throw new OAuthRegistrationError('invalid_client_metadata', 'client_name is required');
        }
        const uris = Array.isArray(input.redirect_uris) ? input.redirect_uris : [];
        if (uris.length === 0 || uris.length > MAX_REDIRECT_URIS) {
            throw new OAuthRegistrationError('invalid_redirect_uri', `redirect_uris must be 1..${MAX_REDIRECT_URIS} entries`);
        }
        for (const uri of uris) {
            if (!isValidRedirectUri(uri, opts)) {
                throw new OAuthRegistrationError('invalid_redirect_uri', `invalid redirect_uri: ${uri}`);
            }
        }
        const client_id = `oc_${randomBytes(16).toString('hex')}`;
        this.db.prepare('INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?, ?, ?)').run(client_id, name, JSON.stringify(uris));
        return { client_id, client_name: name, redirect_uris: uris, created_at: new Date().toISOString() };
    }
    getClient(clientId) {
        if (!clientId)
            return null;
        const row = this.db.prepare('SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id = ?').get(clientId);
        if (!row)
            return null;
        let uris = [];
        try {
            uris = JSON.parse(row.redirect_uris);
        }
        catch { /* corrupt row → empty */ }
        return { client_id: row.client_id, client_name: row.client_name, redirect_uris: uris, created_at: row.created_at };
    }
    /** Exact-match redirect_uri against the client's registered set (RFC 8252). */
    clientAllowsRedirect(clientId, redirectUri) {
        const client = this.getClient(clientId);
        if (!client)
            return false;
        return client.redirect_uris.includes(redirectUri);
    }
    // ── Authorization codes ──
    saveCode(input) {
        const code = `oac_${randomBytes(32).toString('hex')}`;
        this.db.prepare(`INSERT INTO oauth_auth_codes
         (code_hash, client_id, user_id, redirect_uri, code_challenge, scope, resource, expires_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256(code), input.client_id, input.user_id, input.redirect_uri, input.code_challenge, input.scope, input.resource, Date.now() + CODE_TTL_MS);
        return code;
    }
    /**
     * Single-use exchange: atomically read the row and delete it. Returns null if
     * the code is unknown, already consumed, or expired. The delete-inside-the-
     * transaction is what makes a replayed/parallel exchange fail.
     */
    consumeCode(rawCode) {
        if (!rawCode)
            return null;
        const hash = sha256(rawCode);
        const tx = this.db.transaction((h) => {
            const row = this.db.prepare(`SELECT client_id, user_id, redirect_uri, code_challenge, scope, resource, expires_at
           FROM oauth_auth_codes WHERE code_hash = ?`).get(h);
            if (!row)
                return null;
            this.db.prepare('DELETE FROM oauth_auth_codes WHERE code_hash = ?').run(h);
            if (row.expires_at < Date.now())
                return null;
            return {
                client_id: row.client_id, user_id: row.user_id, redirect_uri: row.redirect_uri,
                code_challenge: row.code_challenge, scope: row.scope, resource: row.resource,
            };
        });
        return tx(hash);
    }
    // ── PKCE (S256 only) ──
    static verifyPkce(codeChallenge, codeVerifier) {
        if (!codeChallenge || !codeVerifier)
            return false;
        const computed = createHash('sha256').update(codeVerifier).digest('base64url');
        const a = Buffer.from(computed);
        const b = Buffer.from(codeChallenge);
        if (a.length !== b.length)
            return false;
        return timingSafeEqual(a, b);
    }
    // ── Refresh tokens ──
    insertRefresh(userId, clientId, scope) {
        const token = `sgr_${randomBytes(32).toString('hex')}`;
        this.db.prepare(`INSERT INTO oauth_refresh_tokens (token_hash, client_id, user_id, scope, expires_at)
       VALUES (?, ?, ?, ?, ?)`).run(sha256(token), clientId, userId, scope, Date.now() + REFRESH_TTL_MS);
        return token;
    }
    createRefreshToken(userId, clientId, scope) {
        return this.insertRefresh(userId, clientId, scope);
    }
    /** Resolve a raw refresh token to its grant, bumping last_used_at. */
    verifyRefreshToken(rawToken) {
        if (!rawToken)
            return null;
        const hash = sha256(rawToken);
        const row = this.db.prepare('SELECT client_id, user_id, scope, expires_at FROM oauth_refresh_tokens WHERE token_hash = ?').get(hash);
        if (!row)
            return null;
        if (row.expires_at < Date.now())
            return null;
        const user = this.resolveUser(row.user_id);
        if (!user)
            return null;
        this.db.prepare("UPDATE oauth_refresh_tokens SET last_used_at = datetime('now') WHERE token_hash = ?").run(hash);
        return { user, clientId: row.client_id, scope: row.scope };
    }
    /**
     * Rotate: atomically consume the presented refresh token and issue a new one.
     * Returns the new raw refresh token + grant, or null if the presented token
     * is unknown/expired (replay of an already-rotated token therefore fails).
     *
     * `expectedClientId` is validated INSIDE the transaction, BEFORE the row is
     * deleted: a token presented with the wrong client_id is rejected without
     * consuming the victim's still-valid grant (no griefing-by-invalidation).
     */
    rotateRefreshToken(rawToken, expectedClientId) {
        if (!rawToken)
            return null;
        const hash = sha256(rawToken);
        const tx = this.db.transaction((h) => {
            const row = this.db.prepare('SELECT client_id, user_id, scope, expires_at FROM oauth_refresh_tokens WHERE token_hash = ?').get(h);
            if (!row)
                return null;
            // Validate client binding before deleting — a mismatch must NOT consume
            // the token (it may belong to a victim and still be valid for them).
            if (expectedClientId !== undefined && row.client_id !== expectedClientId)
                return null;
            this.db.prepare('DELETE FROM oauth_refresh_tokens WHERE token_hash = ?').run(h);
            if (row.expires_at < Date.now())
                return null;
            const user = this.resolveUser(row.user_id);
            if (!user)
                return null;
            const refreshToken = this.insertRefresh(row.user_id, row.client_id, row.scope);
            return { refreshToken, user, clientId: row.client_id, scope: row.scope };
        });
        return tx(hash);
    }
    revokeRefreshToken(rawToken) {
        if (!rawToken)
            return false;
        const res = this.db.prepare('DELETE FROM oauth_refresh_tokens WHERE token_hash = ?').run(sha256(rawToken));
        return res.changes > 0;
    }
    /** Grants for a user — backs the deferred Settings "connected apps" view. */
    listGrantsForUser(userId) {
        return this.db.prepare(`SELECT r.client_id AS client_id, c.client_name AS client_name, r.scope AS scope,
              r.created_at AS created_at, r.last_used_at AS last_used_at
         FROM oauth_refresh_tokens r
         LEFT JOIN oauth_clients c ON c.client_id = r.client_id
        WHERE r.user_id = ? AND r.expires_at >= ?
        ORDER BY r.created_at DESC`).all(userId, Date.now());
    }
    // ── Access token (JWT) ──
    /**
     * Mint a ~1h access JWT. `cid` is the stable client_id (not the mutable
     * display name); `aud` binds the token to the canonical `/mcp` resource so
     * the auth hook can refuse it on non-MCP routes.
     */
    createAccessToken(user, clientId, resource) {
        return jwt.sign({ userId: user.id, email: user.email, role: user.role, oauth: true, cid: clientId, aud: resource }, this.jwtSecret, { expiresIn: ACCESS_TOKEN_TTL });
    }
    /** Issue the full token set after a successful code exchange. */
    issueTokens(user, clientId, scope, resource) {
        const access_token = this.createAccessToken(user, clientId, resource);
        const refresh_token = this.createRefreshToken(user.id, clientId, scope);
        this.db.prepare("UPDATE oauth_clients SET last_token_at = datetime('now') WHERE client_id = ?").run(clientId);
        return { access_token, refresh_token, token_type: 'Bearer', expires_in: 3600, scope };
    }
    // ── Garbage collection (called opportunistically from /oauth/token) ──
    gcStale() {
        const now = Date.now();
        try {
            this.db.prepare('DELETE FROM oauth_auth_codes WHERE expires_at < ?').run(now);
            this.db.prepare('DELETE FROM oauth_refresh_tokens WHERE expires_at < ?').run(now);
            // Orphan clients: registered >24h ago and never used to mint a token.
            this.db.prepare(`DELETE FROM oauth_clients
          WHERE last_token_at IS NULL
            AND created_at < datetime('now', ?)`).run(`-${Math.floor(CLIENT_GC_AGE_MS / 1000)} seconds`);
        }
        catch {
            // GC must never block token issuance.
        }
    }
}
export class OAuthRegistrationError extends Error {
    code;
    constructor(code, message) {
        super(message);
        this.code = code;
        this.name = 'OAuthRegistrationError';
    }
}
//# sourceMappingURL=oauth-server-store.js.map