From 8f4029ec7e96e9d107d1e4029ff00d8fb4911504 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Tue, 16 Jun 2026 14:06:11 +0530 Subject: [PATCH 1/2] feat: implement custom provider management with CRUD support, registration schema updates, and a dedicated UI form. --- src/authsome/server/credential_service.py | 106 +- src/authsome/server/routes/_deps.py | 8 - src/authsome/server/routes/providers.py | 39 +- .../server/test_provider_operation_policy.py | 116 ++ ui/src/app/(authenticated)/providers/page.tsx | 10 +- ui/src/components/authsome-dashboard.tsx | 4 +- .../dashboard/custom-provider-form.tsx | 1089 +++++++++++++++++ .../components/dashboard/provider-views.tsx | 154 ++- ui/src/components/ui/select.tsx | 18 + ui/src/lib/authsome-api.ts | 84 ++ 10 files changed, 1600 insertions(+), 28 deletions(-) create mode 100644 ui/src/components/dashboard/custom-provider-form.tsx create mode 100644 ui/src/components/ui/select.tsx diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 8a62667d..9f8f6363 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -5,6 +5,7 @@ """ import json +import re from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Self @@ -40,6 +41,7 @@ CredentialMissingError, InvalidProviderSchemaError, OperationNotAllowedError, + ProviderNotFoundError, RefreshFailedError, TokenExpiredError, UnsupportedFlowError, @@ -172,6 +174,28 @@ async def register_provider(self, definition: ProviderDefinition, *, force: bool ) logger.info("Registered provider: {}", definition.name) + async def update_provider(self, provider: str, definition: ProviderDefinition) -> None: + """Update an existing custom provider definition.""" + self._require_admin("update", "update requires an admin principal", provider) + if provider != definition.name: + raise InvalidProviderSchemaError( + f"Provider name '{definition.name}' must match route provider '{provider}'", + provider=provider, + ) + if not await self.is_custom_provider(provider): + raise ProviderNotFoundError(provider) + self._validate_provider(definition) + await self._providers.save_custom(definition, force=True) + audit.emit_event( + "provider.updated", + provider=definition.name, + identity=self._identity, + principal_id=self._principal_id, + status="success", + auth_type=definition.auth_type.value if definition.auth_type else None, + ) + logger.info("Updated provider: {}", definition.name) + def _require_admin(self, operation: str, message: str, provider: str) -> None: """Allow an operation only for admin principals.""" if self._principal_role == PrincipalRole.ADMIN: @@ -180,20 +204,85 @@ def _require_admin(self, operation: str, message: str, provider: str) -> None: def _validate_provider(self, definition: ProviderDefinition) -> None: validate_provider_definition(definition) + self._validate_api_targets(definition.api_urls(), "api_url", definition.name) + self._validate_optional_url(definition.docs_url, "docs_url", definition.name) if definition.oauth: - for field_name in ("authorization_url", "token_url"): + for field_name in ( + "authorization_url", + "token_url", + "revocation_url", + "device_authorization_url", + "base_url", + ): url = getattr(definition.oauth, field_name, None) if url: - self._validate_url(url, field_name, definition.name) + self._validate_optional_url(url, field_name, definition.name, allow_base_url_template=True) + if definition.registration: + self._validate_optional_url( + definition.registration.registration_endpoint, + "registration.registration_endpoint", + definition.name, + ) + if definition.browser: + self._validate_optional_url(definition.browser.entry_url, "browser.entry_url", definition.name) + self._validate_optional_url(definition.browser.validate_url, "browser.validate_url", definition.name) @staticmethod - def _validate_url(url: str, field_name: str, provider_name: str) -> None: - if "{base_url}" in url: + def _validate_optional_url( + url: str | None, + field_name: str, + provider_name: str, + *, + allow_base_url_template: bool = False, + ) -> None: + if not url: return + CredentialService._validate_url(url, field_name, provider_name, allow_base_url_template=allow_base_url_template) + + @staticmethod + def _validate_url( + url: str, + field_name: str, + provider_name: str, + *, + allow_base_url_template: bool = False, + ) -> None: + if allow_base_url_template and "{base_url}" in url: + return + if "{base_url}" in url: + raise InvalidProviderSchemaError( + f"Invalid URL for '{field_name}': {url}", + provider=provider_name, + ) parsed = urlparse(url) - if not parsed.scheme or not parsed.netloc: + if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise InvalidProviderSchemaError(f"Invalid URL for '{field_name}': {url}", provider=provider_name) + @staticmethod + def _validate_api_targets(targets: tuple[str, ...], field_name: str, provider_name: str) -> None: + for target in targets: + cleaned = target.strip() + if not cleaned or any(char.isspace() for char in cleaned): + raise InvalidProviderSchemaError( + f"Invalid API target for '{field_name}': {target}", + provider=provider_name, + ) + if cleaned.startswith("regex:"): + try: + re.compile(cleaned.removeprefix("regex:")) + except re.error as exc: + raise InvalidProviderSchemaError( + f"Invalid API target for '{field_name}': {target}", + provider=provider_name, + ) from exc + continue + parsed = urlparse(cleaned if "://" in cleaned else f"https://{cleaned}") + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise InvalidProviderSchemaError( + f"Invalid API target for '{field_name}': {target}", + provider=provider_name, + ) + # ── Connection operations ───────────────────────────────────────────── async def list_connections(self) -> list[dict[str, Any]]: @@ -904,6 +993,13 @@ async def remove(self, provider: str) -> None: await self.revoke(provider) if await self.is_custom_provider(provider): await self._providers.delete_custom(provider) + audit.emit_event( + "provider.deleted", + provider=provider, + identity=self._identity, + principal_id=self._principal_id, + status="success", + ) logger.info("Removed local provider definition: {}", provider) else: logger.info("Revoked bundled provider: {} (definition kept)", provider) diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index e4352e42..1fff0247 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -179,14 +179,6 @@ async def get_protected_auth_service( return _build_service(request, ownership) -async def get_admin_auth_service( - auth: CredentialService = Depends(get_protected_auth_service), -) -> CredentialService: - if auth.principal_role != PrincipalRole.ADMIN: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") - return auth - - async def get_daemon_or_browser_auth_service(request: Request) -> CredentialService: """Resolve auth from PoP headers or an existing browser dashboard session.""" if request.headers.get("Authorization"): diff --git a/src/authsome/server/routes/providers.py b/src/authsome/server/routes/providers.py index b99f3f13..1b70dc1d 100644 --- a/src/authsome/server/routes/providers.py +++ b/src/authsome/server/routes/providers.py @@ -9,7 +9,6 @@ from authsome.server.credential_service import CredentialService from authsome.server.routes._deps import ( build_auth_service, - get_admin_auth_service, get_daemon_or_browser_auth_service, get_protected_auth_service, get_server_base_url, @@ -168,12 +167,14 @@ async def update_provider_configuration( @router.post("") -async def register_provider(body: dict, auth: CredentialService = Depends(get_admin_auth_service)): +async def register_provider(body: dict, auth: CredentialService = Depends(get_daemon_or_browser_auth_service)): + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") definition_payload = body.get("definition", body) definition = ProviderDefinition.model_validate(definition_payload) await auth.register_provider(definition, force=bool(body.get("force", False))) capture_event( - auth.require_identity(), + _actor(auth), "provider registered", { "provider": definition.name, @@ -184,11 +185,39 @@ async def register_provider(body: dict, auth: CredentialService = Depends(get_ad return {"status": "ok", "provider": definition.name} +@router.put("/{provider}") +async def update_provider( + provider: str, + body: dict, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +): + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") + definition_payload = body.get("definition", body) + definition = ProviderDefinition.model_validate(definition_payload) + await auth.update_provider(provider, definition) + capture_event( + _actor(auth), + "provider updated", + { + "provider": definition.name, + "auth_type": definition.auth_type.value if definition.auth_type else None, + "principal_id": auth.principal_id, + }, + ) + return {"status": "ok", "provider": definition.name} + + @router.delete("/{provider}") -async def delete_provider(provider: str, auth: CredentialService = Depends(get_admin_auth_service)): +async def delete_provider( + provider: str, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +): + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") await auth.remove(provider) capture_event( - auth.require_identity(), + _actor(auth), "provider deleted", { "provider": provider, diff --git a/tests/server/test_provider_operation_policy.py b/tests/server/test_provider_operation_policy.py index 55d5ed6a..d39cc1ea 100644 --- a/tests/server/test_provider_operation_policy.py +++ b/tests/server/test_provider_operation_policy.py @@ -117,3 +117,119 @@ def test_first_principal_admin_can_register_provider(monkeypatch, tmp_path: Path assert response.status_code == status.HTTP_200_OK assert response.json()["status"] == "ok" + + +def test_browser_session_admin_can_register_provider(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + payload = { + "definition": { + "name": "custom-api", + "display_name": "Custom API", + "auth_type": "api_key", + "flow": "api_key", + "api_key": {"header_name": "Authorization"}, + } + } + + with create_server_test_client() as client: + registered = client.post( + "/api/auth/register", + data={"email": "admin@example.com", "password": "password-1", "next": "/providers"}, + follow_redirects=False, + ) + response = client.post("/api/providers", json=payload) + + assert registered.status_code == status.HTTP_303_SEE_OTHER + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"status": "ok", "provider": "custom-api"} + + +def test_admin_can_update_custom_provider(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + create_payload = { + "definition": { + "name": "custom-api", + "display_name": "Custom API", + "auth_type": "api_key", + "flow": "api_key", + "api_url": "api.example.com", + "api_key": {"header_name": "Authorization", "header_prefix": "Bearer"}, + } + } + update_payload = { + "definition": { + "name": "custom-api", + "display_name": "Updated API", + "auth_type": "api_key", + "flow": "api_key", + "api_url": "https://api.example.com/v2", + "api_key": {"header_name": "x-api-key", "header_prefix": ""}, + } + } + create_body = json.dumps(create_payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + update_body = json.dumps(update_payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + + with create_server_test_client() as client: + _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + created = client.post( + "/api/providers", + content=create_body, + headers={ + **_auth_header(tmp_path, "POST", "/api/providers", body=create_body), + "Content-Type": "application/json", + }, + ) + response = client.put( + "/api/providers/custom-api", + content=update_body, + headers={ + **_auth_header(tmp_path, "PUT", "/api/providers/custom-api", body=update_body), + "Content-Type": "application/json", + }, + ) + fetched = client.get( + "/api/providers/custom-api", + headers=_auth_header(tmp_path, "GET", "/api/providers/custom-api"), + ) + + assert created.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"status": "ok", "provider": "custom-api"} + assert fetched.json()["display_name"] == "Updated API" + assert fetched.json()["api_key"]["header_name"] == "x-api-key" + + +def test_provider_registration_rejects_invalid_url_fields(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + payload = { + "definition": { + "name": "custom-api", + "display_name": "Custom API", + "auth_type": "api_key", + "flow": "api_key", + "api_url": "https://api.example.com", + "docs_url": "not a url", + "api_key": {"header_name": "Authorization"}, + } + } + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + + with create_server_test_client() as client: + _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + response = client.post( + "/api/providers", + content=body, + headers={ + **_auth_header(tmp_path, "POST", "/api/providers", body=body), + "Content-Type": "application/json", + }, + ) + fetched = client.get( + "/api/providers/custom-api", + headers=_auth_header(tmp_path, "GET", "/api/providers/custom-api"), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["error"] == "InvalidProviderSchemaError" + assert "docs_url" in response.json()["message"] + assert fetched.status_code == status.HTTP_404_NOT_FOUND diff --git a/ui/src/app/(authenticated)/providers/page.tsx b/ui/src/app/(authenticated)/providers/page.tsx index 11e40067..91df1e02 100644 --- a/ui/src/app/(authenticated)/providers/page.tsx +++ b/ui/src/app/(authenticated)/providers/page.tsx @@ -6,7 +6,13 @@ import { ProvidersView } from "@/components/dashboard/provider-views"; import { fetchDashboard } from "@/lib/authsome-api"; export default function ProvidersPage() { - const { data } = useSWR("authsome-dashboard", fetchDashboard); + const { data, mutate } = useSWR("authsome-dashboard", fetchDashboard); if (!data) return null; - return ; + return ( + void mutate()} + providers={data.providers} + /> + ); } diff --git a/ui/src/components/authsome-dashboard.tsx b/ui/src/components/authsome-dashboard.tsx index b0609b8e..f9ebcd33 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -67,7 +67,9 @@ function ActiveView({ onRefresh: () => void; view: View; }) { - if (view === "providers") return ; + if (view === "providers") { + return ; + } if (view === "connections") { return ( >; + +type ValidationResult = { + field: FieldKey; + message: string; +}; + +type ProviderFormState = { + name: string; + displayName: string; + logo: string; + description: string; + providerType: ProviderType; + authType: AuthType; + flow: FlowType; + apiTargets: string[]; + docsUrl: string; + oauth: { + authorizationUrl: string; + tokenUrl: string; + revocationUrl: string; + deviceAuthorizationUrl: string; + deviceTokenRequest: "oauth2_form" | "json"; + scopes: string[]; + authorizationParams: KeyValueRow[]; + pkce: boolean; + supportsDeviceCode: boolean; + supportsDcr: boolean; + baseUrl: string; + authorizationMethod: "body" | "basic"; + }; + registrationEndpoint: string; + apiKey: { + headerName: string; + headerPrefixMode: "Bearer" | "Basic" | "Token" | "empty" | "none" | "custom"; + headerPrefixCustom: string; + keyPattern: string; + keyPatternHint: string; + }; + browser: { + entryUrl: string; + domains: string[]; + authCookies: string[]; + validateUrl: string; + ttlHours: string; + ttlFromCookie: string; + extraHeaders: KeyValueRow[]; + extract: ExtractRow[]; + }; + exportRows: KeyValueRow[]; +}; + +const FLOW_OPTIONS: Record> = { + oauth2: [ + { value: "pkce", label: "PKCE" }, + { value: "device_code", label: "Device code" }, + { value: "dcr_pkce", label: "DCR + PKCE" }, + ], + api_key: [{ value: "api_key", label: "API key" }], + browser: [{ value: "browser", label: "Browser session" }], +}; + +const AUTH_TYPES: Array<{ value: AuthType; label: string }> = [ + { value: "oauth2", label: "OAuth 2.0" }, + { value: "api_key", label: "API key" }, + { value: "browser", label: "Browser session" }, +]; + +const PROVIDER_TYPES: Array<{ value: ProviderType; label: string }> = [ + { value: "app", label: "App" }, + { value: "llm", label: "LLM" }, + { value: "mcp", label: "MCP" }, + { value: "browser", label: "Browser" }, +]; + +function rowId(): string { + return Math.random().toString(36).slice(2); +} + +function kvRows(record: Record | undefined): KeyValueRow[] { + return Object.entries(record || {}).map(([key, value]) => ({ id: rowId(), key, value })); +} + +function splitList(values: string[] | undefined): string[] { + return values?.length ? values : [""]; +} + +function headerPrefixState(value: string | null | undefined): ProviderFormState["apiKey"] { + const base = { + headerName: "Authorization", + headerPrefixMode: "Bearer" as const, + headerPrefixCustom: "", + keyPattern: "", + keyPatternHint: "", + }; + if (value === null) return { ...base, headerPrefixMode: "none" }; + if (value === "") return { ...base, headerPrefixMode: "empty" }; + if (value === "Bearer" || value === "Basic" || value === "Token") { + return { ...base, headerPrefixMode: value }; + } + if (value) return { ...base, headerPrefixMode: "custom", headerPrefixCustom: value }; + return base; +} + +function initialState(provider?: ProviderResponse | null): ProviderFormState { + const authType = (provider?.auth_type as AuthType | undefined) || "oauth2"; + const defaultFlow = FLOW_OPTIONS[authType][0].value; + const apiKeyState = headerPrefixState(provider?.api_key?.header_prefix); + const exportRecord = provider?.export && "env" in provider.export ? provider.export.env : provider?.export; + return { + name: provider?.name || "", + displayName: provider?.display_name || "", + logo: provider?.logo || "", + description: provider?.description || provider?.metadata?.description || "", + providerType: (provider?.type as ProviderType | undefined) || "app", + authType, + flow: (provider?.flow as FlowType | undefined) || defaultFlow, + apiTargets: splitList( + Array.isArray(provider?.api_url) + ? provider?.api_url + : provider?.api_url + ? [provider.api_url] + : undefined, + ), + docsUrl: provider?.docs_url || "", + oauth: { + authorizationUrl: provider?.oauth?.authorization_url || "", + tokenUrl: provider?.oauth?.token_url || "", + revocationUrl: provider?.oauth?.revocation_url || "", + deviceAuthorizationUrl: provider?.oauth?.device_authorization_url || "", + deviceTokenRequest: provider?.oauth?.device_token_request || "oauth2_form", + scopes: splitList(provider?.oauth?.scopes), + authorizationParams: kvRows(provider?.oauth?.authorization_params), + pkce: provider?.oauth?.pkce ?? true, + supportsDeviceCode: provider?.oauth?.supports_device_code ?? false, + supportsDcr: provider?.oauth?.supports_dcr ?? false, + baseUrl: provider?.oauth?.base_url || "", + authorizationMethod: provider?.oauth?.authorization_method || "body", + }, + registrationEndpoint: provider?.registration?.registration_endpoint || "", + apiKey: { + ...apiKeyState, + headerName: provider?.api_key?.header_name || "Authorization", + keyPattern: provider?.api_key?.key_pattern || "", + keyPatternHint: provider?.api_key?.key_pattern_hint || "", + }, + browser: { + entryUrl: provider?.browser?.entry_url || "", + domains: splitList(provider?.browser?.domains), + authCookies: splitList(provider?.browser?.auth_cookies), + validateUrl: provider?.browser?.validate_url || "", + ttlHours: String(provider?.browser?.ttl_hours || 24), + ttlFromCookie: provider?.browser?.ttl_from_cookie || "", + extraHeaders: kvRows(provider?.browser?.extra_headers), + extract: (provider?.browser?.extract || []).map((row) => ({ + id: rowId(), + cookie: row.cookie, + header: row.header, + prefix: row.prefix || "", + })), + }, + exportRows: kvRows(exportRecord && !Array.isArray(exportRecord) ? exportRecord as Record : {}), + }; +} + +function cleanList(values: string[]): string[] { + return values.map((value) => value.trim()).filter(Boolean); +} + +function cleanRecord(rows: KeyValueRow[]): Record { + return Object.fromEntries( + rows + .map((row) => [row.key.trim(), row.value.trim()] as const) + .filter(([key]) => Boolean(key)), + ); +} + +function isHttpUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function isTemplateUrl(value: string, baseUrl: string): boolean { + return value.includes("{base_url}") && Boolean(baseUrl.trim()) && isHttpUrl(baseUrl.trim()); +} + +function isApiTarget(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || /\s/.test(trimmed)) return false; + if (trimmed.startsWith("regex:")) { + try { + new RegExp(trimmed.slice("regex:".length)); + return true; + } catch { + return false; + } + } + return isHttpUrl(trimmed) || isHttpUrl(`https://${trimmed}`); +} + +function optionalUrlError(value: string, label: string, allowTemplate = false, baseUrl = ""): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (allowTemplate && isTemplateUrl(trimmed, baseUrl)) return null; + return isHttpUrl(trimmed) ? null : `${label} must be a valid http(s) URL.`; +} + +function validation(field: FieldKey, message: string): ValidationResult { + return { field, message }; +} + +function validateState(state: ProviderFormState): ValidationResult | null { + if (!state.name.trim()) return validation("name", "Provider name is required."); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.name.trim()) || state.name.includes("..")) { + return validation("name", "Provider name must be filesystem-safe."); + } + if (!state.displayName.trim()) return validation("displayName", "Display name is required."); + for (const target of cleanList(state.apiTargets)) { + if (!isApiTarget(target)) { + return validation("apiTargets", "API targets must be http(s) URLs, bare hosts, or regex: patterns."); + } + } + const docsError = optionalUrlError(state.docsUrl, "Docs URL"); + if (docsError) return validation("docsUrl", docsError); + if (state.authType === "oauth2") { + if (!state.oauth.authorizationUrl.trim()) { + return validation("oauthAuthorizationUrl", "Authorization URL is required for OAuth providers."); + } + if (!state.oauth.tokenUrl.trim()) return validation("oauthTokenUrl", "Token URL is required for OAuth providers."); + for (const [value, label, field] of [ + [state.oauth.baseUrl, "Base URL", "oauthBaseUrl"], + [state.oauth.authorizationUrl, "Authorization URL", "oauthAuthorizationUrl"], + [state.oauth.tokenUrl, "Token URL", "oauthTokenUrl"], + [state.oauth.revocationUrl, "Revocation URL", "oauthRevocationUrl"], + [state.oauth.deviceAuthorizationUrl, "Device authorization URL", "oauthDeviceAuthorizationUrl"], + ] as const) { + const error = optionalUrlError(value, label, label !== "Base URL", state.oauth.baseUrl); + if (error) return validation(field, error); + } + if (state.flow === "device_code" && !state.oauth.deviceAuthorizationUrl.trim()) { + return validation("oauthDeviceAuthorizationUrl", "Device authorization URL is required for device-code providers."); + } + const registrationError = optionalUrlError(state.registrationEndpoint, "Registration endpoint"); + if (registrationError) return validation("registrationEndpoint", registrationError); + } + if (state.authType === "browser") { + const entryError = optionalUrlError(state.browser.entryUrl, "Browser entry URL"); + if (entryError) return validation("browserEntryUrl", entryError); + const validateError = optionalUrlError(state.browser.validateUrl, "Browser validate URL"); + if (validateError) return validation("browserValidateUrl", validateError); + if (Number.parseInt(state.browser.ttlHours, 10) <= 0) { + return validation("browserTtlHours", "TTL hours must be greater than zero."); + } + } + return null; +} + +function fieldFromServerMessage(message: string): FieldKey | null { + const normalized = message.toLowerCase(); + const entries: Array<[string, FieldKey]> = [ + ["docs_url", "docsUrl"], + ["api_url", "apiTargets"], + ["authorization_url", "oauthAuthorizationUrl"], + ["token_url", "oauthTokenUrl"], + ["revocation_url", "oauthRevocationUrl"], + ["device_authorization_url", "oauthDeviceAuthorizationUrl"], + ["base_url", "oauthBaseUrl"], + ["registration.registration_endpoint", "registrationEndpoint"], + ["registration_endpoint", "registrationEndpoint"], + ["browser.entry_url", "browserEntryUrl"], + ["browser.validate_url", "browserValidateUrl"], + ["entry_url", "browserEntryUrl"], + ["validate_url", "browserValidateUrl"], + ]; + return entries.find(([needle]) => normalized.includes(needle))?.[1] || null; +} + +function headerPrefixValue(apiKey: ProviderFormState["apiKey"]): string | null { + if (apiKey.headerPrefixMode === "none") return null; + if (apiKey.headerPrefixMode === "empty") return ""; + if (apiKey.headerPrefixMode === "custom") return apiKey.headerPrefixCustom.trim(); + return apiKey.headerPrefixMode; +} + +function buildPayload(state: ProviderFormState): ProviderDefinitionPayload { + const apiTargets = cleanList(state.apiTargets); + const payload: ProviderDefinitionPayload = { + schema_version: 1, + name: state.name.trim(), + display_name: state.displayName.trim(), + auth_type: state.authType, + flow: state.flow, + type: state.providerType, + }; + if (state.logo.trim()) payload.logo = state.logo.trim(); + if (state.description.trim()) payload.description = state.description.trim(); + if (state.docsUrl.trim()) payload.docs_url = state.docsUrl.trim(); + if (apiTargets.length === 1) payload.api_url = apiTargets[0]; + if (apiTargets.length > 1) payload.api_url = apiTargets; + const exportRows = cleanRecord(state.exportRows); + if (Object.keys(exportRows).length) payload.export = exportRows; + + if (state.authType === "oauth2") { + payload.oauth = { + authorization_url: state.oauth.authorizationUrl.trim(), + token_url: state.oauth.tokenUrl.trim(), + scopes: cleanList(state.oauth.scopes), + authorization_params: cleanRecord(state.oauth.authorizationParams), + pkce: state.oauth.pkce, + supports_device_code: state.oauth.supportsDeviceCode, + supports_dcr: state.oauth.supportsDcr, + device_token_request: state.oauth.deviceTokenRequest, + authorization_method: state.oauth.authorizationMethod, + }; + if (state.oauth.baseUrl.trim()) payload.oauth.base_url = state.oauth.baseUrl.trim(); + if (state.oauth.revocationUrl.trim()) payload.oauth.revocation_url = state.oauth.revocationUrl.trim(); + if (state.oauth.deviceAuthorizationUrl.trim()) { + payload.oauth.device_authorization_url = state.oauth.deviceAuthorizationUrl.trim(); + } + if (state.registrationEndpoint.trim()) { + payload.registration = { registration_endpoint: state.registrationEndpoint.trim() }; + } + } + + if (state.authType === "api_key") { + payload.api_key = { + header_name: state.apiKey.headerName.trim() || "Authorization", + header_prefix: headerPrefixValue(state.apiKey), + }; + if (state.apiKey.keyPattern.trim()) payload.api_key.key_pattern = state.apiKey.keyPattern.trim(); + if (state.apiKey.keyPatternHint.trim()) payload.api_key.key_pattern_hint = state.apiKey.keyPatternHint.trim(); + } + + if (state.authType === "browser") { + payload.browser = { + entry_url: state.browser.entryUrl.trim(), + domains: cleanList(state.browser.domains), + auth_cookies: cleanList(state.browser.authCookies), + ttl_hours: Number.parseInt(state.browser.ttlHours, 10) || 24, + extra_headers: cleanRecord(state.browser.extraHeaders), + extract: state.browser.extract + .map((row) => ({ + cookie: row.cookie.trim(), + header: row.header.trim(), + prefix: row.prefix.trim(), + })) + .filter((row) => row.cookie && row.header), + }; + if (state.browser.validateUrl.trim()) payload.browser.validate_url = state.browser.validateUrl.trim(); + if (state.browser.ttlFromCookie.trim()) payload.browser.ttl_from_cookie = state.browser.ttlFromCookie.trim(); + } + + return payload; +} + +function Field({ + children, + className, + error, + label, +}: { + children: React.ReactNode; + className?: string; + error?: string; + label: string; +}) { + return ( + + ); +} + +function TextArea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +