/**
 * OAuth 2.0 Authorization Server routes
 *
 * Makes Studiograph's `/mcp` server connectable by Claude Desktop's Chat/Cowork
 * connector pipeline (OAuth-only). Implements the minimal authorization-server
 * surface an MCP client drives:
 *
 *   GET  /.well-known/oauth-protected-resource[/mcp]  — resource metadata
 *   GET  /.well-known/oauth-authorization-server      — AS metadata
 *   POST /oauth/register                              — Dynamic Client Registration
 *   GET  /oauth/authorize                             — consent screen
 *   POST /oauth/authorize                             — consent submit → code
 *   POST /oauth/token                                 — code + refresh grants
 *   POST /oauth/revoke                                — revoke a refresh token
 *
 * Security posture (see plan reflective-sprouting-turtle):
 *   - Issuer + endpoint URLs are PINNED to a configured canonical base URL,
 *     never the inbound Host header (metadata-spoofing defense).
 *   - DCR is unauthenticated (the client has no user yet) but rate-limited,
 *     and redirect URIs are validated (https-only, no fragments/wildcards).
 *   - PKCE S256 is mandatory; authorization codes are single-use, ≤60s.
 *   - The consent form carries an HMAC-signed blob of the authorize params
 *     (incl. `state` + the logged-in userId) so the POST can't be forged.
 *   - All values rendered into the consent HTML are escaped (DCR is attacker-
 *     controlled). Token responses are `Cache-Control: no-store`.
 *   - Access tokens are minted with `aud = <canonical>/mcp`; the global auth
 *     hook scopes them to `/mcp` + `/oauth/*`.
 */
import { OAuthServerStore, OAuthRegistrationError } from '../../services/oauth-server-store.js';
import { log } from '../../utils/log.js';
/** v1 ships a single full-access scope (granular scopes deferred — TODOS.md). */
const OAUTH_SCOPE = 'mcp:full';
const CONSENT_TTL_MS = 10 * 60 * 1000;
/** httpOnly cookie carrying the signed consent blob between authorize GET, the
 *  consent screen's context fetch, and the Allow POST. Never exposed to JS. */
const CONSENT_COOKIE = '__sg_oauth_consent';
/** Thrown when no canonical URL is pinned in a proxied/production-like deployment. */
export class CanonicalUrlError extends Error {
    constructor() {
        super('OAuth issuer URL is not configured (set STUDIOGRAPH_PUBLIC_URL / server_url)');
        this.name = 'CanonicalUrlError';
    }
}
/**
 * True in a deployment where the server sits behind a proxy and the inbound
 * Host header cannot be trusted. In these modes the issuer MUST be pinned to a
 * configured value, or the OAuth-AS fails closed (no spoofable metadata).
 */
function mustPinIssuer() {
    if (process.env.NODE_ENV === 'production')
        return true;
    if (process.env.RAILWAY_ENVIRONMENT)
        return true;
    const managed = (process.env.STUDIOGRAPH_MANAGED_AUTH || '').toLowerCase();
    return managed === '1' || managed === 'true' || managed === 'yes';
}
/**
 * Resolve the canonical public base URL for this tenant, in priority order:
 *   1. STUDIOGRAPH_PUBLIC_URL env (explicit operator override)
 *   2. workspace config `server_url`
 *   3. RAILWAY_PUBLIC_DOMAIN env → https://<domain>
 *   4. request origin (LOCAL DEV ONLY fallback)
 * Trailing slash stripped. Pinning to a configured value (not the inbound
 * Host header) is what prevents discovery-metadata spoofing behind a proxy.
 *
 * **Fails closed:** in a proxied/production-like deployment (`mustPinIssuer`)
 * with no pinned value, throws `CanonicalUrlError` rather than trusting the
 * (spoofable) forwarded Host. The dev fallback to request origin only applies
 * locally, where there is no untrusted proxy in front.
 */
export function resolveCanonicalBaseUrl(workspaceConfig, req) {
    const fromEnv = process.env.STUDIOGRAPH_PUBLIC_URL?.trim();
    if (fromEnv)
        return stripSlash(fromEnv);
    const fromConfig = typeof workspaceConfig.server_url === 'string' ? workspaceConfig.server_url.trim() : '';
    if (fromConfig)
        return stripSlash(fromConfig);
    const railway = process.env.RAILWAY_PUBLIC_DOMAIN?.trim();
    if (railway)
        return `https://${stripSlash(railway)}`;
    // No pinned value. Behind a proxy this is unsafe (Host is attacker-influenced)
    // — fail closed. Locally, fall back to the request origin.
    if (mustPinIssuer())
        throw new CanonicalUrlError();
    const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
    const host = req.headers['x-forwarded-host'] || req.headers.host || req.hostname;
    return stripSlash(`${proto}://${host}`);
}
/** True if a stable canonical URL is configured (not relying on the dev fallback). */
export function hasCanonicalUrl(workspaceConfig) {
    return Boolean(process.env.STUDIOGRAPH_PUBLIC_URL?.trim() ||
        (typeof workspaceConfig.server_url === 'string' && workspaceConfig.server_url.trim()) ||
        process.env.RAILWAY_PUBLIC_DOMAIN?.trim());
}
function stripSlash(s) {
    return s.replace(/\/+$/, '');
}
/**
 * Same-origin guard for post-login `returnTo` paths. Returns the path only if
 * it is a safe root-relative path on this origin, else `/`.
 *
 * Must reject: absolute/protocol-relative URLs (`//evil`, `https://evil`) AND
 * backslash variants (`/\evil`, `/\/evil`) — browsers normalize `\` to `/` in a
 * `Location` header, so `/\evil.com` becomes `//evil.com` → an off-origin
 * redirect. Also reject control characters.
 */
export function safeReturnTo(raw) {
    if (!raw || typeof raw !== 'string')
        return '/';
    if (!raw.startsWith('/'))
        return '/'; // must be root-relative
    if (raw.startsWith('//'))
        return '/'; // protocol-relative -> off-origin
    if (raw.includes('\\'))
        return '/'; // backslash -> browser-normalized off-origin
    // eslint-disable-next-line no-control-regex
    if (/[\x00-\x1f\x7f]/.test(raw))
        return '/'; // control chars
    return raw;
}
function redirectError(reply, redirectUri, error, state, description) {
    const url = new URL(redirectUri);
    url.searchParams.set('error', error);
    if (description)
        url.searchParams.set('error_description', description);
    if (state)
        url.searchParams.set('state', state);
    reply.redirect(url.toString());
}
function noStore(reply) {
    reply.header('Cache-Control', 'no-store');
    reply.header('Pragma', 'no-cache');
}
export async function registerOAuthApiRoutes(fastify, authService, workspaceConfig) {
    const store = authService.oauthServer;
    const allowLoopback = process.env.NODE_ENV !== 'production';
    // Boot-time check: managed tenants must have a pinned canonical URL, else
    // discovery metadata would fall back to the spoofable request origin.
    if (!hasCanonicalUrl(workspaceConfig)) {
        const managed = (process.env.STUDIOGRAPH_MANAGED_AUTH || '').toLowerCase();
        const isManaged = managed === '1' || managed === 'true' || managed === 'yes';
        const msg = '[oauth-as] No canonical public URL configured (STUDIOGRAPH_PUBLIC_URL / server_url / RAILWAY_PUBLIC_DOMAIN). OAuth discovery metadata will use the request origin.';
        if (isManaged)
            log.warn(`${msg} This is unsafe on a managed/proxied tenant.`);
        else
            log.info(`${msg} OK for local dev.`);
    }
    const resourceUrl = (base) => `${base}/mcp`;
    // Resolve the pinned canonical base URL, or fail closed: in a proxied/
    // production-like deployment with no pinned URL we return null + send 503
    // rather than emit spoofable metadata built from the inbound Host header.
    const requireCanonical = (req, reply) => {
        try {
            return resolveCanonicalBaseUrl(workspaceConfig, req);
        }
        catch (err) {
            if (err instanceof CanonicalUrlError) {
                log.error('[oauth-as] refusing OAuth request: no canonical issuer URL configured');
                reply.status(503).send({ error: 'server_error', error_description: 'issuer not configured' });
                return null;
            }
            throw err;
        }
    };
    // ── Discovery: protected-resource metadata (RFC 9728) ──
    // Serve both the root and the `/mcp`-suffixed form — MCP clients differ on
    // which they fetch (the SDK's helper suffixes the resource path).
    const protectedResource = (req, reply) => {
        const base = requireCanonical(req, reply);
        if (!base)
            return;
        reply.send({
            resource: resourceUrl(base),
            authorization_servers: [base],
        });
    };
    fastify.get('/.well-known/oauth-protected-resource', protectedResource);
    fastify.get('/.well-known/oauth-protected-resource/mcp', protectedResource);
    // ── Discovery: authorization-server metadata (RFC 8414) ──
    fastify.get('/.well-known/oauth-authorization-server', (req, reply) => {
        const base = requireCanonical(req, reply);
        if (!base)
            return;
        reply.send({
            issuer: base,
            authorization_endpoint: `${base}/oauth/authorize`,
            token_endpoint: `${base}/oauth/token`,
            registration_endpoint: `${base}/oauth/register`,
            revocation_endpoint: `${base}/oauth/revoke`,
            response_types_supported: ['code'],
            grant_types_supported: ['authorization_code', 'refresh_token'],
            code_challenge_methods_supported: ['S256'],
            token_endpoint_auth_methods_supported: ['none'],
            scopes_supported: [OAUTH_SCOPE],
        });
    });
    // ── Dynamic Client Registration (RFC 7591) ──
    fastify.post('/oauth/register', { config: { rateLimit: { max: 10, timeWindow: '1 hour' } } }, (req, reply) => {
        const body = (req.body ?? {});
        try {
            const client = store.registerClient({ client_name: body.client_name ?? '', redirect_uris: body.redirect_uris ?? [] }, { allowLoopback });
            noStore(reply);
            reply.status(201).send({
                client_id: client.client_id,
                client_name: client.client_name,
                redirect_uris: client.redirect_uris,
                token_endpoint_auth_method: 'none',
                grant_types: ['authorization_code', 'refresh_token'],
                response_types: ['code'],
            });
        }
        catch (err) {
            if (err instanceof OAuthRegistrationError) {
                log.warn(`[oauth-as] registration rejected: ${err.code} ${err.message}`);
                return reply.status(400).send({ error: err.code, error_description: err.message });
            }
            throw err;
        }
    });
    // ── Authorization endpoint — consent screen (GET) ──
    // The global auth hook resolves the logged-in user (cookie) BEFORE this runs,
    // and bounces to /login?returnTo= when there's none. We re-assert that here
    // as defense-in-depth.
    fastify.get('/oauth/authorize', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, (req, reply) => {
        const user = req.user;
        const q = req.query;
        const base = requireCanonical(req, reply);
        if (!base)
            return;
        if (!user) {
            const returnTo = `/oauth/authorize${req.raw.url?.includes('?') ? req.raw.url.slice(req.raw.url.indexOf('?')) : ''}`;
            return reply.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(returnTo))}`);
        }
        const clientId = q.client_id ?? '';
        const redirectUri = q.redirect_uri ?? '';
        const client = store.getClient(clientId);
        // Client / redirect_uri problems must NOT redirect to the (untrusted)
        // client redirect_uri (open-redirect risk, RFC 6749 §4.1.2.1). Bounce to
        // our OWN branded consent page in its error state instead — same-origin,
        // so no open-redirect concern.
        if (!client || !redirectUri || !store.clientAllowsRedirect(clientId, redirectUri)) {
            log.warn(`[oauth-as] authorize rejected: bad client_id/redirect_uri (client=${clientId})`);
            return reply.redirect('/oauth/consent?error=invalid_request');
        }
        const state = q.state;
        // From here, errors can be safely redirected back to the (validated) URI.
        if (q.response_type !== 'code') {
            return redirectError(reply, redirectUri, 'unsupported_response_type', state);
        }
        if (q.code_challenge_method && q.code_challenge_method !== 'S256') {
            return redirectError(reply, redirectUri, 'invalid_request', state, 'code_challenge_method must be S256');
        }
        if (!q.code_challenge) {
            return redirectError(reply, redirectUri, 'invalid_request', state, 'code_challenge (PKCE) required');
        }
        if (q.resource && q.resource !== resourceUrl(base)) {
            return redirectError(reply, redirectUri, 'invalid_target', state, 'unknown resource');
        }
        // Bind the authorize params + the logged-in user into a signed blob the
        // POST handler will verify. Includes `state` so it can't be substituted.
        const consent = store.signConsent({
            client_id: clientId,
            redirect_uri: redirectUri,
            code_challenge: q.code_challenge,
            scope: OAUTH_SCOPE,
            resource: resourceUrl(base),
            userId: user.id,
            state: state ?? '',
        });
        // Hand off to the branded SvelteKit consent screen. The signed blob rides
        // in an httpOnly cookie (never the DOM): the consent page only fetches
        // display fields from /api/oauth/consent-context, and the Allow POST reads
        // the blob back from this cookie. Short TTL matches the signer's window.
        reply.setCookie(CONSENT_COOKIE, consent, {
            path: '/',
            httpOnly: true,
            sameSite: 'lax',
            secure: req.headers['x-forwarded-proto'] === 'https',
            maxAge: CONSENT_TTL_MS / 1000,
        });
        return reply.redirect('/oauth/consent');
    });
    // ── Consent display context (for the SvelteKit consent screen) ──
    // Session-authed (global hook sets req.user). Returns ONLY display fields
    // resolved from the httpOnly consent cookie — never the signed blob itself.
    fastify.get('/api/oauth/consent-context', (req, reply) => {
        noStore(reply);
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'login_required' });
        const raw = req.cookies?.[CONSENT_COOKIE];
        const payload = raw ? store.verifyConsent(raw) : null;
        if (!payload || payload.userId !== user.id) {
            return reply.status(410).send({ error: 'consent_expired' });
        }
        const client = store.getClient(String(payload.client_id));
        if (!client || !store.clientAllowsRedirect(client.client_id, String(payload.redirect_uri))) {
            return reply.status(410).send({ error: 'invalid_client' });
        }
        let redirectHost = String(payload.redirect_uri);
        try {
            redirectHost = new URL(String(payload.redirect_uri)).host;
        }
        catch { /* keep raw */ }
        return reply.send({
            client_name: client.client_name,
            redirect_host: redirectHost,
            scope: String(payload.scope ?? OAUTH_SCOPE),
            user_label: user.displayName || user.email,
        });
    });
    // ── Authorization endpoint — consent submit (POST) ──
    // The signed blob comes from the httpOnly consent cookie (set by the GET
    // handler), NOT a form field — the browser only submits `decision`. A
    // cross-site POST can't carry the Lax cookie, so this is CSRF-safe.
    fastify.post('/oauth/authorize', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, (req, reply) => {
        const user = req.user;
        const body = (req.body ?? {});
        if (!user)
            return reply.status(401).send({ error: 'login_required' });
        const rawConsent = req.cookies?.[CONSENT_COOKIE];
        const payload = rawConsent ? store.verifyConsent(rawConsent) : null;
        // Consume the cookie regardless of outcome (single-use).
        reply.clearCookie(CONSENT_COOKIE, { path: '/' });
        // Pre-consent failures bounce to the branded consent page's error state
        // (same-origin redirect — see the GET handler).
        if (!payload) {
            return reply.redirect('/oauth/consent?error=expired');
        }
        // The signed blob must belong to THIS logged-in user.
        if (payload.userId !== user.id) {
            log.warn('[oauth-as] consent userId mismatch — rejecting');
            return reply.redirect('/oauth/consent?error=expired');
        }
        const clientId = String(payload.client_id);
        const redirectUri = String(payload.redirect_uri);
        const state = String(payload.state ?? '');
        // Re-check the client + redirect_uri still valid (could have changed/been
        // deleted between the GET render and this POST).
        if (!store.clientAllowsRedirect(clientId, redirectUri)) {
            return reply.redirect('/oauth/consent?error=invalid_request');
        }
        if (body.decision !== 'allow') {
            return redirectError(reply, redirectUri, 'access_denied', state || undefined);
        }
        const code = store.saveCode({
            client_id: clientId,
            user_id: user.id,
            redirect_uri: redirectUri,
            code_challenge: String(payload.code_challenge),
            scope: String(payload.scope ?? OAUTH_SCOPE),
            resource: String(payload.resource),
        });
        const url = new URL(redirectUri);
        url.searchParams.set('code', code);
        if (state)
            url.searchParams.set('state', state);
        reply.redirect(url.toString());
    });
    // ── Token endpoint ──
    fastify.post('/oauth/token', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, (req, reply) => {
        noStore(reply);
        const body = (req.body ?? {});
        const base = requireCanonical(req, reply);
        if (!base)
            return;
        // Opportunistic GC on this low-frequency endpoint (never per-request).
        store.gcStale();
        const grant = body.grant_type;
        if (grant === 'authorization_code') {
            const { code, code_verifier, redirect_uri, client_id } = body;
            if (!code || !code_verifier || !redirect_uri || !client_id) {
                return reply.status(400).send({ error: 'invalid_request' });
            }
            const consumed = store.consumeCode(code);
            if (!consumed)
                return reply.status(400).send({ error: 'invalid_grant' });
            if (consumed.client_id !== client_id || consumed.redirect_uri !== redirect_uri) {
                return reply.status(400).send({ error: 'invalid_grant' });
            }
            if (!OAuthServerStore.verifyPkce(consumed.code_challenge, code_verifier)) {
                return reply.status(400).send({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
            }
            const user = authService.findUserById(consumed.user_id);
            if (!user)
                return reply.status(400).send({ error: 'invalid_grant' });
            const tokens = store.issueTokens(user, consumed.client_id, consumed.scope, consumed.resource);
            return reply.send(tokens);
        }
        if (grant === 'refresh_token') {
            const { refresh_token, client_id } = body;
            // Public clients MUST identify themselves (RFC 6749 §3.2.1). Require
            // client_id and validate it BEFORE the token is consumed, so a stolen
            // refresh token presented with the wrong client_id can't grief the
            // victim's grant into invalidation (rotation deletes the old token).
            if (!refresh_token || !client_id)
                return reply.status(400).send({ error: 'invalid_request' });
            const rotated = store.rotateRefreshToken(refresh_token, client_id);
            if (!rotated)
                return reply.status(400).send({ error: 'invalid_grant' });
            const access_token = store.createAccessToken(rotated.user, rotated.clientId, resourceUrl(base));
            return reply.send({
                access_token,
                refresh_token: rotated.refreshToken,
                token_type: 'Bearer',
                expires_in: 3600,
                scope: rotated.scope,
            });
        }
        return reply.status(400).send({ error: 'unsupported_grant_type' });
    });
    // ── Revocation (RFC 7009) — always 200, no token-scanning oracle ──
    fastify.post('/oauth/revoke', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, (req, reply) => {
        noStore(reply);
        const body = (req.body ?? {});
        if (body.token)
            store.revokeRefreshToken(body.token);
        reply.status(200).send({});
    });
}
//# sourceMappingURL=oauth-api.js.map