feat: implement custom provider management with CRUD support, registration schema updates, and a dedicated UI form.

This commit is contained in:
Ankit Ranjan
2026-06-16 14:06:11 +05:30
parent 87eed80d77
commit 8f4029ec7e
10 changed files with 1600 additions and 28 deletions
+101 -5
View File
@@ -5,6 +5,7 @@ Lives in server/ because it coordinates auth/ flows with vault/ storage and audi
"""
import json
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Self
@@ -40,6 +41,7 @@ from authsome.errors import (
CredentialMissingError,
InvalidProviderSchemaError,
OperationNotAllowedError,
ProviderNotFoundError,
RefreshFailedError,
TokenExpiredError,
UnsupportedFlowError,
@@ -172,6 +174,28 @@ class CredentialService:
)
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 @@ class CredentialService:
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 @@ class CredentialService:
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)
-8
View File
@@ -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"):
+34 -5
View File
@@ -9,7 +9,6 @@ from authsome.server.analytics import capture_event
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,
@@ -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
@@ -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 <ProvidersView providers={data.providers} />;
return (
<ProvidersView
isAdmin={data.account.isAdmin}
onRefresh={() => void mutate()}
providers={data.providers}
/>
);
}
+3 -1
View File
@@ -67,7 +67,9 @@ function ActiveView({
onRefresh: () => void;
view: View;
}) {
if (view === "providers") return <ProvidersView providers={data.providers} />;
if (view === "providers") {
return <ProvidersView isAdmin={data.account.isAdmin} onRefresh={onRefresh} providers={data.providers} />;
}
if (view === "connections") {
return (
<ConnectionsView
File diff suppressed because it is too large Load Diff
+147 -7
View File
@@ -1,10 +1,11 @@
"use client";
import { LogIn } from "lucide-react";
import { LogIn, Pencil, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useMemo, useState } from "react";
import { CustomProviderDialog } from "@/components/dashboard/custom-provider-form";
import {
INTERACTIVE_CARD_CLASS,
ProviderLogo,
@@ -27,7 +28,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ProviderView } from "@/lib/authsome-api";
import { ProviderView, deleteCustomProvider } from "@/lib/authsome-api";
import { cn } from "@/lib/utils";
export function ProviderSummary({ provider }: { provider: ProviderView }) {
@@ -48,9 +49,19 @@ export function ProviderSummary({ provider }: { provider: ProviderView }) {
);
}
export function ProvidersView({ providers }: { providers: ProviderView[] }) {
export function ProvidersView({
isAdmin = false,
onRefresh,
providers,
}: {
isAdmin?: boolean;
onRefresh?: () => void;
providers: ProviderView[];
}) {
const [query, setQuery] = useState("");
const [dialogProvider, setDialogProvider] = useState<NamedConnectionProvider | null>(null);
const [formState, setFormState] = useState<{ mode: "create" | "edit"; provider?: ProviderView } | null>(null);
const [deleteProvider, setDeleteProvider] = useState<ProviderView | null>(null);
const filteredProviders = useMemo(() => {
const normalized = query.trim().toLowerCase();
@@ -70,11 +81,26 @@ export function ProvidersView({ providers }: { providers: ProviderView[] }) {
return (
<div className="grid gap-5">
<SectionHeader description="Configure providers and start browser login flows." title="Providers" />
<div className="flex flex-wrap items-start justify-between gap-3">
<SectionHeader description="Configure providers and start browser login flows." title="Providers" />
{isAdmin ? (
<Button onClick={() => setFormState({ mode: "create" })} type="button">
<Plus />
New provider
</Button>
) : null}
</div>
<SearchInput onChange={setQuery} placeholder="Search providers..." value={query} />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredProviders.map((provider) => (
<ProviderCard key={provider.name} onNamedLogin={() => setDialogProvider(provider)} provider={provider} />
<ProviderCard
isAdmin={isAdmin}
key={provider.name}
onDelete={() => setDeleteProvider(provider)}
onEdit={() => setFormState({ mode: "edit", provider })}
onNamedLogin={() => setDialogProvider(provider)}
provider={provider}
/>
))}
</div>
{!filteredProviders.length ? (
@@ -89,6 +115,23 @@ export function ProvidersView({ providers }: { providers: ProviderView[] }) {
)
) : null}
<NamedConnectionDialog onOpenChange={setDialogProvider} provider={dialogProvider} />
<CustomProviderDialog
key={`${formState?.mode || "create"}:${formState?.provider?.name || "new"}`}
mode={formState?.mode || "create"}
onOpenChange={(open) => {
if (!open) setFormState(null);
}}
onSaved={() => onRefresh?.()}
open={Boolean(formState)}
provider={formState?.provider?.definition || null}
/>
<DeleteCustomProviderDialog
onDeleted={() => onRefresh?.()}
onOpenChange={(open) => {
if (!open) setDeleteProvider(null);
}}
provider={deleteProvider}
/>
</div>
);
}
@@ -97,8 +140,21 @@ function providerSortRank(provider: ProviderView): number {
return provider.status === "available" ? 1 : 0;
}
function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; provider: ProviderView }) {
function ProviderCard({
isAdmin,
onDelete,
onEdit,
onNamedLogin,
provider,
}: {
isAdmin: boolean;
onDelete: () => void;
onEdit: () => void;
onNamedLogin: () => void;
provider: ProviderView;
}) {
const router = useRouter();
const canManage = isAdmin && provider.source === "custom";
return (
<Card
@@ -108,7 +164,39 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<ProviderLogo className="size-10 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
<StatusBadge status={provider.status} />
<div className="flex items-center gap-1.5">
{canManage ? (
<>
<Button
aria-label={`Edit ${provider.displayName}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onEdit();
}}
size="icon-sm"
type="button"
variant="outline"
>
<Pencil />
</Button>
<Button
aria-label={`Delete ${provider.displayName}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onDelete();
}}
size="icon-sm"
type="button"
variant="outline"
>
<Trash2 />
</Button>
</>
) : null}
<StatusBadge status={provider.status} />
</div>
</div>
<div className="mt-1">
<CardTitle className="text-base leading-tight">{provider.displayName}</CardTitle>
@@ -156,6 +244,58 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr
);
}
function DeleteCustomProviderDialog({
onDeleted,
onOpenChange,
provider,
}: {
onDeleted: () => void;
onOpenChange: (open: boolean) => void;
provider: ProviderView | null;
}) {
const [working, setWorking] = useState(false);
const [message, setMessage] = useState("");
async function deleteProviderDefinition() {
if (!provider) return;
setWorking(true);
setMessage("");
try {
await deleteCustomProvider(provider.name);
onDeleted();
onOpenChange(false);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Provider could not be deleted.");
} finally {
setWorking(false);
}
}
return (
<Dialog onOpenChange={onOpenChange} open={Boolean(provider)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete custom provider</DialogTitle>
<DialogDescription>
{provider?.connectionCount
? `${provider.displayName} has ${provider.connectionCount} connection${provider.connectionCount === 1 ? "" : "s"}. Deleting it will revoke those credentials first.`
: `${provider?.displayName || "This provider"} will be removed from custom providers.`}
</DialogDescription>
</DialogHeader>
{message ? <div className="text-sm text-destructive">{message}</div> : null}
<DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
Cancel
</Button>
<Button disabled={working} onClick={() => void deleteProviderDefinition()} type="button" variant="destructive">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export type NamedConnectionProvider = Pick<ProviderView, "displayName" | "name">;
export function NamedConnectionDialog({
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Select({ className, ...props }: React.ComponentProps<"select">) {
return (
<select
data-slot="select"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-base transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30",
className,
)}
{...props}
/>
)
}
export { Select }
+84
View File
@@ -8,6 +8,7 @@ export type DashboardStats = {
export type ProviderView = {
name: string;
displayName: string;
definition: ProviderResponse;
authType: "oauth2" | "api_key" | string;
authTypeLabel: string;
apiUrl: string;
@@ -134,17 +135,73 @@ export type ProviderResponse = {
display_name?: string;
logo?: string | null;
description?: string | null;
type?: "app" | "llm" | "mcp" | "browser" | string | null;
auth_type?: string;
flow?: string;
api_url?: string | string[] | null;
oauth?: {
authorization_url?: string;
token_url?: string;
revocation_url?: string | null;
device_authorization_url?: string | null;
device_token_request?: "oauth2_form" | "json";
scopes?: string[];
authorization_params?: Record<string, string>;
pkce?: boolean;
supports_device_code?: boolean;
supports_dcr?: boolean;
base_url?: string | null;
authorization_method?: "body" | "basic";
} | null;
registration?: {
registration_endpoint?: string | null;
} | null;
api_key?: {
header_name?: string;
header_prefix?: string | null;
key_pattern?: string | null;
key_pattern_hint?: string | null;
} | null;
browser?: {
entry_url?: string;
domains?: string[];
auth_cookies?: string[];
validate_url?: string | null;
extra_headers?: Record<string, string>;
ttl_hours?: number;
ttl_from_cookie?: string | null;
extract?: Array<{
cookie: string;
header: string;
prefix?: string;
}>;
} | null;
export?: Record<string, string> | { env?: Record<string, string> } | null;
metadata?: {
description?: string;
};
docs_url?: string | null;
};
export type ProviderDefinitionPayload = {
schema_version?: number;
name: string;
display_name: string;
logo?: string | null;
description?: string | null;
type?: "app" | "llm" | "mcp" | "browser" | null;
auth_type: "oauth2" | "api_key" | "browser";
flow: "pkce" | "device_code" | "dcr_pkce" | "api_key" | "browser";
oauth?: NonNullable<ProviderResponse["oauth"]> | null;
registration?: NonNullable<ProviderResponse["registration"]> | null;
api_key?: NonNullable<ProviderResponse["api_key"]> | null;
browser?: NonNullable<ProviderResponse["browser"]> | null;
export?: ProviderResponse["export"];
docs_url?: string | null;
api_url?: string | string[] | null;
metadata?: Record<string, unknown>;
};
export type ProviderClientDetail = {
client_id: string | null;
client_secret: string | null;
@@ -378,6 +435,7 @@ function providerView(
return {
name: provider.name,
displayName,
definition: provider,
authType: provider.auth_type || "provider",
authTypeLabel: authTypeLabel(provider.auth_type),
apiUrl: providerApiUrl(provider),
@@ -626,6 +684,32 @@ export async function updateProviderConfiguration(
});
}
export async function createCustomProvider(
definition: ProviderDefinitionPayload,
): Promise<{ status: "ok"; provider: string }> {
return sendJson("/api/providers", {
method: "POST",
body: JSON.stringify({ definition }),
});
}
export async function updateCustomProvider(
provider: string,
definition: ProviderDefinitionPayload,
): Promise<{ status: "ok"; provider: string }> {
return sendJson(`/api/providers/${encodeURIComponent(provider)}`, {
method: "PUT",
body: JSON.stringify({ definition }),
});
}
export async function deleteCustomProvider(provider: string): Promise<{ status: "ok"; provider: string }> {
return sendJson(`/api/providers/${encodeURIComponent(provider)}`, {
method: "DELETE",
body: "{}",
});
}
export async function fetchConnectionDetail(
provider: string,
connection: string,