import type { OAuthFlowExecutor } from "@codemation/core"; import { inject, injectable } from "@codemation/core"; import serialize from "serialize-javascript"; import { ApplicationTokens } from "../../../applicationTokens"; import { CredentialInstanceService, CredentialSecretCipher, type CredentialStore, } from "../../../domain/credentials/CredentialServices"; import { OAuth2RedirectUriResolver } from "../../../domain/credentials/OAuth2RedirectUriResolver"; import { HttpRequestJsonBodyReader } from "../HttpRequestJsonBodyReader"; import { ServerHttpErrorResponseFactory } from "../ServerHttpErrorResponseFactory"; type OAuthStartRequestBody = Readonly<{ typeId: string; instanceId: string; redirectUri: string; scopes?: ReadonlyArray; }>; @injectable() export class OAuth2HttpRouteHandler { constructor( @inject(OAuth2RedirectUriResolver) private readonly redirectUriResolver: OAuth2RedirectUriResolver, @inject(CredentialInstanceService) private readonly credentialInstanceService: CredentialInstanceService, @inject(ApplicationTokens.OAuthFlowExecutor) private readonly oauthFlowExecutor: OAuthFlowExecutor, @inject(ApplicationTokens.CredentialStore) private readonly credentialStore: CredentialStore, @inject(CredentialSecretCipher) private readonly credentialSecretCipher: CredentialSecretCipher, ) {} async getRedirectUri(request: Request): Promise { try { return Response.json({ redirectUri: this.redirectUriResolver.resolve(this.resolveRequestOrigin(request)), }); } catch (error) { return ServerHttpErrorResponseFactory.fromUnknown(error); } } async postDisconnect(request: Request): Promise { try { const url = new URL(request.url); const instanceId = url.searchParams.get("instanceId")?.trim(); if (!instanceId) { return Response.json({ error: "Missing instanceId query parameter." }, { status: 400 }); } return Response.json(await this.credentialInstanceService.disconnectOAuth2(instanceId)); } catch (error) { return ServerHttpErrorResponseFactory.fromUnknown(error); } } async postOAuthStart(request: Request): Promise { try { const body = await HttpRequestJsonBodyReader.readJsonBody(request); if (!body.typeId?.trim()) { return Response.json({ error: "Missing required field: typeId" }, { status: 400 }); } if (!body.instanceId?.trim()) { return Response.json({ error: "Missing required field: instanceId" }, { status: 400 }); } if (!body.redirectUri?.trim()) { return Response.json({ error: "Missing required field: redirectUri" }, { status: 400 }); } const result = await this.oauthFlowExecutor.start({ typeId: body.typeId.trim(), instanceId: body.instanceId.trim(), redirectUri: body.redirectUri.trim(), scopes: body.scopes ?? [], }); return Response.json({ consentUrl: result.consentUrl, stateToken: result.stateToken }); } catch (error) { return ServerHttpErrorResponseFactory.fromUnknown(error); } } async getOAuthCallback(request: Request): Promise { try { const url = new URL(request.url); const code = url.searchParams.get("code")?.trim(); const stateToken = url.searchParams.get("state")?.trim(); if (!code || !stateToken) { return new Response( this.createPopupHtml({ kind: "oauth2.error", message: "Missing code and state parameters." }), { status: 400, headers: { "content-type": "text/html; charset=utf-8" }, }, ); } const instanceId = this.oauthFlowExecutor.lookupInstanceId(stateToken); if (!instanceId) { return new Response( this.createPopupHtml({ kind: "oauth2.error", message: "OAuth state token not found or already used." }), { status: 400, headers: { "content-type": "text/html; charset=utf-8" } }, ); } const material = await this.oauthFlowExecutor.completeCallback({ stateToken, code }); const nowIso = new Date().toISOString(); const encryptedMaterial = this.credentialSecretCipher.encrypt({ accessToken: material.accessToken, refreshToken: material.refreshToken ?? null, expiresAt: material.expiresAt ?? null, grantedScopes: material.grantedScopes.join(" "), }); await this.credentialStore.saveOAuth2Material({ instanceId, encryptedJson: encryptedMaterial.encryptedJson, encryptionKeyId: encryptedMaterial.encryptionKeyId, schemaVersion: encryptedMaterial.schemaVersion, metadata: { providerId: "local", connectedAt: nowIso, scopes: [...material.grantedScopes], updatedAt: nowIso, }, }); await this.credentialInstanceService.markOAuth2Connected(instanceId, nowIso); return new Response(this.createPopupHtml({ kind: "oauth2.connected", instanceId }), { headers: { "content-type": "text/html; charset=utf-8" }, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(this.createPopupHtml({ kind: "oauth2.error", message }), { status: 400, headers: { "content-type": "text/html; charset=utf-8" }, }); } } private resolveRequestOrigin(request: Request): string { const forwardedProto = OAuth2HttpRouteHandler.firstCommaSeparatedValue(request.headers.get("x-forwarded-proto")); const forwardedHost = OAuth2HttpRouteHandler.firstCommaSeparatedValue(request.headers.get("x-forwarded-host")); if (forwardedProto && forwardedHost) { return `${forwardedProto}://${forwardedHost}`; } const host = OAuth2HttpRouteHandler.firstCommaSeparatedValue(request.headers.get("host")); if (host) { return `${new URL(request.url).protocol}//${host}`; } return new URL(request.url).origin; } private static firstCommaSeparatedValue(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) { return undefined; } return trimmed.split(",")[0]?.trim(); } private createPopupHtml(message: Readonly>): string { const safeLiteral = serialize(message, { isJSON: true }); return ` OAuth2 connection

You can close this window.

`; } }