diff --git a/src/authsome/auth/bundled_providers/notion.json b/src/authsome/auth/bundled_providers/notion.json index 45a0fa19..59c8f0e6 100644 --- a/src/authsome/auth/bundled_providers/notion.json +++ b/src/authsome/auth/bundled_providers/notion.json @@ -14,6 +14,7 @@ "device_authorization_url": null, "scopes": [], "pkce": false, + "client_secret_handling": "basic", "supports_device_code": false, "supports_dcr": false }, diff --git a/src/authsome/auth/bundled_providers/x.json b/src/authsome/auth/bundled_providers/x.json index b4821ce8..9f437ab4 100644 --- a/src/authsome/auth/bundled_providers/x.json +++ b/src/authsome/auth/bundled_providers/x.json @@ -19,6 +19,7 @@ "offline.access" ], "pkce": true, + "client_secret_handling": "basic", "supports_device_code": false, "supports_dcr": false }, diff --git a/src/authsome/auth/flows/base.py b/src/authsome/auth/flows/base.py index aeb8c6f3..4520dbc3 100644 --- a/src/authsome/auth/flows/base.py +++ b/src/authsome/auth/flows/base.py @@ -88,17 +88,21 @@ async def revoke( if not revocation_url: return - def _do_revoke(token: str, token_type: str) -> None: - payload = {"token": token} - if client_id: - payload["client_id"] = client_id - if client_secret: - payload["client_secret"] = client_secret + use_basic = provider.oauth.client_secret_handling == "basic" if provider.oauth else False + def _do_revoke(token: str, token_type: str) -> None: + payload: dict[str, str] = {"token": token} + if not use_basic: + if client_id: + payload["client_id"] = client_id + if client_secret: + payload["client_secret"] = client_secret try: http_client.post( revocation_url, - data=payload, + data=None if use_basic else payload, + json=payload if use_basic else None, + auth=(client_id or "", client_secret or "") if use_basic else None, timeout=15, ) except Exception as exc: @@ -133,14 +137,19 @@ def refresh( payload: dict[str, str] = { "grant_type": "refresh_token", "refresh_token": record.refresh_token, - "client_id": client_id, } - if client_secret: - payload["client_secret"] = client_secret + + use_basic = provider.oauth.client_secret_handling == "basic" + if not use_basic: + payload["client_id"] = client_id + if client_secret: + payload["client_secret"] = client_secret resp = http_client.post( provider.oauth.token_url, - data=payload, + data=None if use_basic else payload, + json=payload if use_basic else None, + auth=(client_id, client_secret or "") if use_basic else None, headers={"Accept": "application/json"}, timeout=30, ) diff --git a/src/authsome/auth/flows/dcr_pkce.py b/src/authsome/auth/flows/dcr_pkce.py index 14531d4b..a4d4a9bd 100644 --- a/src/authsome/auth/flows/dcr_pkce.py +++ b/src/authsome/auth/flows/dcr_pkce.py @@ -232,20 +232,29 @@ async def _exchange_code( # noqa: PLR0913 code_verifier: str, ) -> dict[str, Any]: assert provider.oauth is not None - payload: dict[str, str] = { + body: dict[str, str] = { "grant_type": "authorization_code", "code": auth_code, "redirect_uri": redirect_uri, - "client_id": client_id, "code_verifier": code_verifier, } - if client_secret: - payload["client_secret"] = client_secret + + use_basic = provider.oauth.client_secret_handling == "basic" + if not use_basic: + body["client_id"] = client_id + if client_secret: + body["client_secret"] = client_secret + + resp = http_client.post( + provider.oauth.token_url, + data=None if use_basic else body, + json=body if use_basic else None, + auth=(client_id, client_secret or "") if use_basic else None, + headers={"Accept": "application/json"}, + timeout=30, + ) try: - resp = http_client.post( - provider.oauth.token_url, data=payload, headers={"Accept": "application/json"}, timeout=30 - ) resp.raise_for_status() data = resp.json() except http_client.RequestException as exc: diff --git a/src/authsome/auth/flows/device_code.py b/src/authsome/auth/flows/device_code.py index c1b57b90..392168e2 100644 --- a/src/authsome/auth/flows/device_code.py +++ b/src/authsome/auth/flows/device_code.py @@ -178,6 +178,7 @@ async def poll_for_token( # noqa: PLR0912, PLR0913 deadline = time.monotonic() + effective_expires_in use_json = provider.oauth.device_token_request == "json" + use_basic = provider.oauth.client_secret_handling == "basic" while time.monotonic() < deadline: await asyncio.sleep(poll_interval) @@ -195,12 +196,18 @@ async def poll_for_token( # noqa: PLR0912, PLR0913 "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, } - if client_id: - payload["client_id"] = client_id - if client_secret: - payload["client_secret"] = client_secret + if not use_basic: + if client_id: + payload["client_id"] = client_id + if client_secret: + payload["client_secret"] = client_secret resp = requests.post( - provider.oauth.token_url, data=payload, headers={"Accept": "application/json"}, timeout=30 + provider.oauth.token_url, + data=None if use_basic else payload, + json=payload if use_basic else None, + auth=(client_id or "", client_secret or "") if use_basic else None, + headers={"Accept": "application/json"}, + timeout=30, ) except requests.RequestException as exc: logger.warning("Token poll request failed: {}, retrying...", exc) diff --git a/src/authsome/auth/flows/pkce.py b/src/authsome/auth/flows/pkce.py index e07f475b..6350ca33 100644 --- a/src/authsome/auth/flows/pkce.py +++ b/src/authsome/auth/flows/pkce.py @@ -143,23 +143,29 @@ async def _exchange_code( # noqa: PLR0913 code_verifier: str, ) -> dict[str, Any]: assert provider.oauth is not None - payload: dict[str, str] = { + body: dict[str, str] = { "grant_type": "authorization_code", "code": auth_code, "redirect_uri": redirect_uri, - "client_id": client_id, "code_verifier": code_verifier, } - if client_secret: - payload["client_secret"] = client_secret + + use_basic = provider.oauth.client_secret_handling == "basic" + if not use_basic: + body["client_id"] = client_id + if client_secret: + body["client_secret"] = client_secret + + resp = http_client.post( + provider.oauth.token_url, + data=None if use_basic else body, + json=body if use_basic else None, + auth=(client_id, client_secret or "") if use_basic else None, + headers={"Accept": "application/json"}, + timeout=30, + ) try: - resp = http_client.post( - provider.oauth.token_url, - data=payload, - headers={"Accept": "application/json"}, - timeout=30, - ) resp.raise_for_status() except http_client.RequestException as exc: raise AuthenticationFailedError(f"Token exchange failed: {exc}", provider=provider.name) from exc diff --git a/src/authsome/auth/models/provider.py b/src/authsome/auth/models/provider.py index a8a626fb..64b0e456 100644 --- a/src/authsome/auth/models/provider.py +++ b/src/authsome/auth/models/provider.py @@ -18,6 +18,7 @@ class OAuthConfig(BaseModel): device_authorization_url: str | None = None #: ``json`` = poll token URL with ``POST`` JSON body ``{"device_code": "..."}`` (e.g. Postiz CLI auth). device_token_request: Literal["oauth2_form", "json"] = "oauth2_form" + client_secret_handling: Literal["post", "basic"] = "post" scopes: list[str] = Field(default_factory=list) pkce: bool = True supports_device_code: bool = False diff --git a/src/authsome/auth/utils.py b/src/authsome/auth/utils.py index 0cf51a75..6f875f3f 100644 --- a/src/authsome/auth/utils.py +++ b/src/authsome/auth/utils.py @@ -137,14 +137,34 @@ def required_inputs( # noqa: PLR0913 if flow_type == FlowType.PKCE and (provider_config_only or not flow_client_id): fields.append(InputField(name="client_id", label="Client ID", secret=False, default=flow_client_id or "")) - fields.append(InputField(name="client_secret", label="Client Secret", secret=True, default="")) + fields.append( + InputField( + name="client_secret", + label="Client Secret", + secret=True, + default=client_record.client_secret if provider_config_only and client_record else "", + ) + ) elif flow_type == FlowType.DEVICE_CODE and (provider_config_only or not flow_client_id): fields.append(InputField(name="client_id", label="Client ID", secret=False, default=flow_client_id or "")) - fields.append(InputField(name="client_secret", label="Client Secret (Optional)", secret=True, default="")) + fields.append( + InputField( + name="client_secret", + label="Client Secret (Optional)", + secret=True, + default=client_record.client_secret if provider_config_only and client_record else "", + ) + ) needs_scopes = flow_type in (FlowType.PKCE, FlowType.DEVICE_CODE, FlowType.DCR_PKCE) - if needs_scopes and scopes is None and persisted_scopes is None: - default_scopes = ",".join(provider.oauth.scopes) if provider.oauth and provider.oauth.scopes else "" + if needs_scopes and scopes is None and (provider_config_only or persisted_scopes is None): + default_scopes = ",".join( + persisted_scopes + if provider_config_only and persisted_scopes is not None + else provider.oauth.scopes + if provider.oauth and provider.oauth.scopes + else [] + ) fields.append(InputField(name="scopes", label="Scopes (comma-separated)", secret=False, default=default_scopes)) if flow_type == FlowType.API_KEY: diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 11909e2e..734508dd 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -226,6 +226,21 @@ async def get_connection( ) return record + async def list_connection_records(self, provider: str) -> list[ConnectionRecord]: + """Return stored connection records for a provider in this service's vault.""" + keys = await self._credentials.list_connection_keys() + records: list[ConnectionRecord] = [] + for key in keys: + parts = parse_store_key(key) + if parts.record_type != "connection" or not parts.provider or not parts.connection: + continue + if parts.provider != provider: + continue + record = await self._credentials.get_connection(parts.provider, parts.connection) + if record is not None: + records.append(record) + return records + async def resolve_connection_name(self, provider: str, connection: str | None = None) -> str: """Resolve an optional connection name to the provider default.""" if connection: @@ -354,7 +369,17 @@ async def get_required_inputs( client_record=client_record, scopes=scopes, base_url=base_url, - provider_config_only=bool(session.payload.get("provider_config_only")), + ) + + async def get_provider_configuration_inputs(self, provider: str) -> list[InputField]: + """Return provider-level configuration fields for admin management UI.""" + definition = await self.get_provider(provider) + client_record = await self._credentials.get_provider_client(provider) + return required_inputs( + provider=definition, + flow_type=definition.flow, + client_record=client_record, + provider_config_only=True, ) async def save_inputs(self, session: AuthSession, inputs: dict[str, str]) -> None: diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 92001a68..5e3f625c 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -95,14 +95,22 @@ async def get_auth_service( return _build_service(request, resolved) -async def require_auth_service( +async def require_auth_service( # noqa: PLR0913 request: Request, *, identity: str | None = None, principal_id: str | None = None, + acting_auth: CredentialService | None = None, + require_admin_for_other_principal: bool = False, status_code: int = 404, detail: str = "Authentication context not found", ) -> CredentialService: + if acting_auth is not None and principal_id is not None: + if principal_id == acting_auth.principal_id: + return acting_auth + if require_admin_for_other_principal and acting_auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") + auth = await get_auth_service(request, identity=identity, principal_id=principal_id) if auth is None: raise HTTPException(status_code=status_code, detail=detail) diff --git a/src/authsome/server/routes/auth.py b/src/authsome/server/routes/auth.py index 539e94f3..fb34dc9b 100644 --- a/src/authsome/server/routes/auth.py +++ b/src/authsome/server/routes/auth.py @@ -238,9 +238,6 @@ async def get_session_input( callback_url = None if definition.auth_type == AuthType.OAUTH2: callback_url = build_callback_url(server_base_url) - warning_message = None - if session.payload.get("provider_config_only") and session.payload.get("existing_provider_client"): - warning_message = "Changing these credentials will revoke existing connections for this provider." return { "session_id": session.session_id, @@ -249,7 +246,7 @@ async def get_session_input( "docs_url": definition.docs_url, "fields": fields, "callback_url": callback_url, - "warning": warning_message, + "warning": None, } @@ -365,19 +362,6 @@ async def _submit_session_input( # noqa: PLR0911 form = await request.form() inputs = {key: str(value) for key, value in form.items()} - if session.payload.get("provider_config_only"): - all_vaults = await request.app.state.store.vaults.list_all() - vault_ids = [vault.vault_id for vault in all_vaults] or ([auth.vault_id] if auth.vault_id else []) - await auth.update_provider_configuration(session.provider, inputs, vault_ids=vault_ids) - session.state = AuthSessionStatus.COMPLETED - session.status_message = "Provider configuration updated" - await sessions.save(session) - if return_url := session.payload.get("return_url"): - return RedirectResponse(str(return_url), status_code=status.HTTP_303_SEE_OTHER) - return RedirectResponse( - build_auth_success_url(server_base_url, session.session_id), status_code=status.HTTP_303_SEE_OTHER - ) - await auth.save_inputs(session, inputs) flow = FlowType(session.flow_type) diff --git a/src/authsome/server/routes/connections.py b/src/authsome/server/routes/connections.py index 587f8eec..a0675dbc 100644 --- a/src/authsome/server/routes/connections.py +++ b/src/authsome/server/routes/connections.py @@ -1,21 +1,62 @@ """Connection routes.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request, status from authsome.auth.models.enums import ExportFormat +from authsome.identity.principal import PrincipalRole from authsome.server.analytics import capture_event from authsome.server.credential_service import CredentialService from authsome.server.routes._deps import ( - get_admin_auth_service, get_daemon_or_browser_auth_service, get_protected_auth_service, get_vault_registry, + require_auth_service, ) +from authsome.server.schemas import ConnectionDetailResponse, ConnectionSecretsResponse from authsome.server.store.repositories import VaultRegistry router = APIRouter(tags=["connections"]) +def _actor(auth: CredentialService) -> str: + return auth.identity or auth.principal_id or "account-ui" + + +async def _connection_detail( + auth: CredentialService, + provider: str, + connection: str, + *, + can_set_default: bool, +) -> ConnectionDetailResponse: + definition = await auth.get_provider(provider) + record = await auth.get_connection(provider, connection) + return ConnectionDetailResponse( + provider=record.provider, + provider_display_name=definition.display_name, + connection_name=record.connection_name, + principal_id=record.principal_id, + identity=record.identity, + vault_id=record.vault_id, + status=record.status.value, + auth_type=record.auth_type.value, + base_url=record.base_url, + api_url=record.api_url, + scopes=list(record.scopes or []), + token_type=record.token_type, + obtained_at=record.obtained_at, + expires_at=record.expires_at, + account=record.account.model_dump(mode="json") if record.account else None, + secrets=ConnectionSecretsResponse( + access_token=record.access_token, + refresh_token=record.refresh_token, + api_key=record.api_key, + credentials=dict(record.credentials or {}), + ), + can_set_default=can_set_default, + ) + + @router.get("/connections") async def list_connections(auth: CredentialService = Depends(get_daemon_or_browser_auth_service)): by_source = await auth.list_providers_by_source() @@ -33,16 +74,53 @@ async def get_connection(provider: str, connection: str, auth: CredentialService return (await auth.get_connection(provider, connection)).model_dump(mode="json") +@router.get("/connections/{provider}/{connection}/detail", response_model=ConnectionDetailResponse) +async def get_connection_detail( + provider: str, + connection: str, + request: Request, + principal: str | None = None, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +): + target = await require_auth_service( + request, + principal_id=principal or auth.principal_id, + acting_auth=auth, + require_admin_for_other_principal=True, + detail="Principal not found", + ) + return await _connection_detail( + target, + provider, + connection, + can_set_default=target.principal_id == auth.principal_id, + ) + + @router.post("/connections/{provider}/{connection}/logout") -async def logout(provider: str, connection: str, auth: CredentialService = Depends(get_protected_auth_service)): - await auth.logout(provider, connection) +async def logout( + provider: str, + connection: str, + request: Request, + principal: str | None = None, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +): + target = await require_auth_service( + request, + principal_id=principal or auth.principal_id, + acting_auth=auth, + require_admin_for_other_principal=True, + detail="Principal not found", + ) + await target.logout(provider, connection) capture_event( - auth.require_identity(), + _actor(auth), "connection logout", { "provider": provider, "connection": connection, - "principal_id": auth.principal_id, + "principal_id": target.principal_id, + "requested_by_principal_id": auth.principal_id, }, ) return {"status": "ok"} @@ -51,14 +129,16 @@ async def logout(provider: str, connection: str, auth: CredentialService = Depen @router.post("/connections/{provider}/revoke") async def revoke( provider: str, - auth: CredentialService = Depends(get_admin_auth_service), + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), vault_registry: VaultRegistry = Depends(get_vault_registry), ): + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") all_vaults = await vault_registry.list_all() vault_ids: list[str] = [v.vault_id for v in all_vaults] or ([auth.vault_id] if auth.vault_id else []) await auth.revoke(provider, vault_ids=vault_ids) capture_event( - auth.require_identity(), + _actor(auth), "connection revoked", { "provider": provider, @@ -72,7 +152,7 @@ async def revoke( async def set_default_connection( provider: str, connection: str, - auth: CredentialService = Depends(get_protected_auth_service), + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), ): await auth.set_default_connection(provider, connection) return {"status": "ok", "provider": provider, "default_connection": connection} diff --git a/src/authsome/server/routes/providers.py b/src/authsome/server/routes/providers.py index 8b55fb75..b99f3f13 100644 --- a/src/authsome/server/routes/providers.py +++ b/src/authsome/server/routes/providers.py @@ -1,15 +1,105 @@ """Provider routes.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request, status +from authsome.auth.models.connection import ConnectionRecord, ProviderClientRecord from authsome.auth.models.provider import ProviderDefinition +from authsome.identity.principal import PrincipalRole from authsome.server.analytics import capture_event from authsome.server.credential_service import CredentialService -from authsome.server.routes._deps import get_admin_auth_service, get_protected_auth_service +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, +) +from authsome.server.schemas import ( + ConnectionSummaryResponse, + ProviderClientResponse, + ProviderConfigurationUpdateRequest, + ProviderConfigurationUpdateResponse, + ProviderDetailResponse, + ProviderPrincipalUsageResponse, +) +from authsome.server.urls import build_callback_url router = APIRouter(prefix="/providers", tags=["providers"]) +def _actor(auth: CredentialService) -> str: + return auth.identity or auth.principal_id or "account-ui" + + +def _connection_summary(record: ConnectionRecord, *, provider_display_name: str) -> ConnectionSummaryResponse: + account_label = record.account.label if record.account else None + return ConnectionSummaryResponse( + provider=record.provider, + provider_display_name=provider_display_name, + connection_name=record.connection_name, + status=record.status.value, + auth_type=record.auth_type.value, + account_label=account_label, + principal_id=record.principal_id, + ) + + +def _client_response(record: ProviderClientRecord | None) -> ProviderClientResponse | None: + if record is None: + return None + return ProviderClientResponse( + client_id=record.client_id, + client_secret=record.client_secret, + base_url=record.base_url, + api_url=record.api_url, + scopes=list(record.scopes or []), + ) + + +async def _current_connections( + auth: CredentialService, + provider: str, + display_name: str, +) -> list[ConnectionSummaryResponse]: + records = await auth.list_connection_records(provider) + return sorted( + [_connection_summary(record, provider_display_name=display_name) for record in records], + key=lambda row: row.connection_name, + ) + + +async def _admin_usage( + request: Request, + provider: str, + display_name: str, + role: PrincipalRole, +) -> list[ProviderPrincipalUsageResponse]: + if role != PrincipalRole.ADMIN: + return [] + groups: list[ProviderPrincipalUsageResponse] = [] + for principal in await request.app.state.store.principals.list_all(): + resolved = await request.app.state.ownership_resolver.resolve_for_principal(principal_id=principal.principal_id) + if resolved is None: + continue + principal_auth = build_auth_service( + request, + identity=None, + principal_id=principal.principal_id, + principal_role=principal.role, + vault_id=resolved.vault_id, + ) + connections = await _current_connections(principal_auth, provider, display_name) + if connections: + groups.append( + ProviderPrincipalUsageResponse( + principal_id=principal.principal_id, + email=principal.email, + connections=connections, + ) + ) + return groups + + @router.get("") async def list_providers(auth: CredentialService = Depends(get_protected_auth_service)): by_source = await auth.list_providers_by_source() @@ -23,6 +113,60 @@ async def get_provider(provider: str, auth: CredentialService = Depends(get_prot return (await auth.get_provider(provider)).model_dump(mode="json") +@router.get("/{provider}/detail", response_model=ProviderDetailResponse) +async def get_provider_detail( + provider: str, + request: Request, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), + server_base_url: str = Depends(get_server_base_url), +): + definition = await auth.get_provider(provider) + is_admin = auth.principal_role == PrincipalRole.ADMIN + client = await auth.get_provider_client(provider) if is_admin else None + fields = await auth.get_provider_configuration_inputs(provider) if is_admin else [] + connections = await _current_connections(auth, provider, definition.display_name) + return ProviderDetailResponse( + provider=definition.model_dump(mode="json"), + account={ + "principal_id": auth.principal_id, + "role": auth.principal_role.value, + "is_admin": is_admin, + }, + client=_client_response(client), + configuration_fields=[field.model_dump(mode="json", exclude_none=True) for field in fields], + configuration_warning="Changing these credentials will revoke existing connections for this provider." + if client + else None, + show_callback_helper=is_admin, + callback_url=build_callback_url(server_base_url) if is_admin else None, + connections=connections, + principal_usage=await _admin_usage(request, provider, definition.display_name, auth.principal_role), + ) + + +@router.put("/{provider}/configuration", response_model=ProviderConfigurationUpdateResponse) +async def update_provider_configuration( + provider: str, + body: ProviderConfigurationUpdateRequest, + request: Request, + 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") + payload = body.model_dump(exclude_none=True) + if isinstance(body.scopes, list): + payload["scopes"] = ",".join(body.scopes) + all_vaults = await request.app.state.store.vaults.list_all() + vault_ids = [vault.vault_id for vault in all_vaults] or ([auth.vault_id] if auth.vault_id else []) + changed = await auth.update_provider_configuration(provider, payload, vault_ids=vault_ids) + capture_event( + _actor(auth), + "provider configuration updated", + {"provider": provider, "changed": changed, "principal_id": auth.principal_id}, + ) + return ProviderConfigurationUpdateResponse(changed=changed, provider=provider) + + @router.post("") async def register_provider(body: dict, auth: CredentialService = Depends(get_admin_auth_service)): definition_payload = body.get("definition", body) diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py index 0a8301df..74bb15b0 100644 --- a/src/authsome/server/schemas.py +++ b/src/authsome/server/schemas.py @@ -124,3 +124,80 @@ class PrincipalVaultBindingRecord(BaseModel): is_default: bool = False created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) + + +class ConnectionSummaryResponse(BaseModel): + provider: str + provider_display_name: str + connection_name: str + status: str + auth_type: str + account_label: str | None = None + principal_id: str | None = None + + +class ProviderClientResponse(BaseModel): + client_id: str | None = None + client_secret: str | None = None + base_url: str | None = None + api_url: str | None = None + scopes: list[str] = Field(default_factory=list) + + +class ProviderPrincipalUsageResponse(BaseModel): + principal_id: str + email: str | None = None + connections: list[ConnectionSummaryResponse] = Field(default_factory=list) + + +class ProviderDetailResponse(BaseModel): + provider: dict[str, Any] + account: dict[str, Any] + client: ProviderClientResponse | None = None + configuration_fields: list[dict[str, Any]] = Field(default_factory=list) + configuration_warning: str | None = None + show_callback_helper: bool = False + callback_url: str | None = None + connections: list[ConnectionSummaryResponse] = Field(default_factory=list) + principal_usage: list[ProviderPrincipalUsageResponse] = Field(default_factory=list) + + +class ProviderConfigurationUpdateRequest(BaseModel): + client_id: str | None = None + client_secret: str | None = None + base_url: str | None = None + api_url: str | None = None + scopes: str | list[str] | None = None + + +class ProviderConfigurationUpdateResponse(BaseModel): + status: Literal["ok"] = "ok" + changed: bool + provider: str + + +class ConnectionSecretsResponse(BaseModel): + access_token: str | None = None + refresh_token: str | None = None + api_key: str | None = None + credentials: dict[str, str] = Field(default_factory=dict) + + +class ConnectionDetailResponse(BaseModel): + provider: str + provider_display_name: str + connection_name: str + principal_id: str | None = None + identity: str | None = None + vault_id: str | None = None + status: str + auth_type: str + base_url: str | None = None + api_url: str | None = None + scopes: list[str] = Field(default_factory=list) + token_type: str | None = None + obtained_at: datetime | None = None + expires_at: datetime | None = None + account: dict[str, Any] | None = None + secrets: ConnectionSecretsResponse = Field(default_factory=ConnectionSecretsResponse) + can_set_default: bool = False diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index ff54b6c0..d92e6a5a 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -370,6 +370,10 @@ async def get_by_email(self, email: str) -> PrincipalRecord | None: row = await self._db.fetch_one("SELECT * FROM principals WHERE email = ?", [normalized]) return self._record(row) if row else None + async def list_all(self) -> list[PrincipalRecord]: + rows = await self._db.fetch_all("SELECT * FROM principals ORDER BY created_at, principal_id") + return [self._record(row) for row in rows] + async def create_by_email(self, email: str, *, password_hash: str | None = None) -> PrincipalRecord: normalized = email.strip().lower() if await self.get_by_email(normalized) is not None: diff --git a/tests/server/test_connection_details.py b/tests/server/test_connection_details.py new file mode 100644 index 00000000..8d837ed0 --- /dev/null +++ b/tests/server/test_connection_details.py @@ -0,0 +1,151 @@ +import asyncio +from pathlib import Path + +from fastapi import status +from fastapi.testclient import TestClient + +from authsome.auth.models.connection import ConnectionRecord, ProviderMetadataRecord +from authsome.auth.models.enums import AuthType, ConnectionStatus +from authsome.cli.identity import RuntimeIdentity +from authsome.server.app import create_app +from authsome.server.credential_repository import build_store_key +from authsome.server.routes._deps import UI_SESSION_COOKIE_NAME +from tests.server.test_pop_auth import _auth_header + + +def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str) -> RuntimeIdentity: + identity = RuntimeIdentity.create(tmp_path, handle) + response = client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did}) + assert response.status_code == status.HTTP_200_OK + + async def claim() -> None: + principal = await client.app.state.store.principals.create_by_email(email) + vault = await client.app.state.store.vaults.create_default() + await client.app.state.store.principal_vault_bindings.bind_default(principal.principal_id, vault.vault_id) + await client.app.state.store.identity_claims.claim_identity(identity.handle, principal.principal_id) + await client.app.state.store.identity_claims.accept_claim(identity.handle) + + asyncio.run(claim()) + return identity + + +def _put_connection( + client: TestClient, + principal_id: str, + vault_id: str, + connection: str, + *, + auth_type: AuthType = AuthType.OAUTH2, +) -> None: + collection = f"vault:{vault_id}" + key = build_store_key(vault=vault_id, provider="github", connection=connection, record_type="connection") + record = ConnectionRecord( + provider="github", + principal_id=principal_id, + vault_id=vault_id, + connection_name=connection, + auth_type=auth_type, + status=ConnectionStatus.CONNECTED, + access_token=f"access-{connection}" if auth_type == AuthType.OAUTH2 else None, + refresh_token=f"refresh-{connection}" if auth_type == AuthType.OAUTH2 else None, + api_key=f"key-{connection}" if auth_type == AuthType.API_KEY else None, + token_type="bearer" if auth_type == AuthType.OAUTH2 else None, + scopes=["repo"] if auth_type == AuthType.OAUTH2 else None, + ) + metadata_key = build_store_key(vault=vault_id, provider="github", record_type="metadata") + metadata = ProviderMetadataRecord( + principal_id=principal_id, + vault_id=vault_id, + provider="github", + default_connection=connection, + connection_names=[connection], + last_used_connection=connection, + ) + asyncio.run(client.app.state.vault.put(key, record.model_dump_json(), collection=collection)) + asyncio.run(client.app.state.vault.put(metadata_key, metadata.model_dump_json(), collection=collection)) + + +def test_user_can_read_own_connection_detail(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=user.handle)) + _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "default") + response = client.get( + "/api/connections/github/default/detail", + headers=_auth_header(tmp_path, "GET", "/api/connections/github/default/detail", handle=user.handle), + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["connection_name"] == "default" + assert body["secrets"]["access_token"] == "access-default" + assert body["secrets"]["refresh_token"] == "refresh-default" + + +def test_non_admin_cannot_read_other_principal_connection(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity="admin-ready-boldly-0001")) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "admin-main") + path = f"/api/connections/github/admin-main/detail?principal={admin_ctx.principal_id}" + response = client.get(path, headers=_auth_header(tmp_path, "GET", path, handle=user.handle)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_admin_can_read_other_principal_connection(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity="steady-wisely-boldly-0042")) + _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "user-main") + path = f"/api/connections/github/user-main/detail?principal={user_ctx.principal_id}" + response = client.get(path, headers=_auth_header(tmp_path, "GET", path, handle=admin.handle)) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["principal_id"] == user_ctx.principal_id + assert response.json()["secrets"]["access_token"] == "access-user-main" + + +def test_admin_logout_targets_other_principal_connection(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity="steady-wisely-boldly-0042")) + _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "user-main", auth_type=AuthType.API_KEY) + path = f"/api/connections/github/user-main/logout?principal={user_ctx.principal_id}" + response = client.post(path, headers=_auth_header(tmp_path, "POST", path, handle=admin.handle)) + assert response.status_code == status.HTTP_200_OK + + detail_path = f"/api/connections/github/user-main/detail?principal={user_ctx.principal_id}" + missing = client.get(detail_path, headers=_auth_header(tmp_path, "GET", detail_path, handle=admin.handle)) + + assert missing.status_code == status.HTTP_404_NOT_FOUND + + +def test_admin_browser_session_can_revoke_provider(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=admin.handle)) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "default", auth_type=AuthType.API_KEY) + session = client.app.state.ui_sessions.create_browser_session( + principal_id=admin_ctx.principal_id, + email="admin@example.com", + ) + client.cookies.set(UI_SESSION_COOKIE_NAME, client.app.state.ui_sessions.build_cookie_value(session.token)) + + response = client.post("/api/connections/github/revoke", json={}) + assert response.status_code == status.HTTP_200_OK + + detail = client.get( + "/api/connections/github/default/detail", + headers=_auth_header(tmp_path, "GET", "/api/connections/github/default/detail", handle=admin.handle), + ) + + assert detail.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/server/test_provider_details.py b/tests/server/test_provider_details.py new file mode 100644 index 00000000..77509d5a --- /dev/null +++ b/tests/server/test_provider_details.py @@ -0,0 +1,161 @@ +import asyncio +from pathlib import Path + +from fastapi import status +from fastapi.testclient import TestClient + +from authsome.auth.models.connection import ConnectionRecord, ProviderClientRecord, ProviderMetadataRecord +from authsome.auth.models.enums import AuthType, ConnectionStatus +from authsome.cli.identity import RuntimeIdentity +from authsome.server.app import create_app +from authsome.server.credential_repository import build_store_key +from tests.server.test_pop_auth import _auth_header + + +def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str) -> RuntimeIdentity: + identity = RuntimeIdentity.create(tmp_path, handle) + response = client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did}) + assert response.status_code == status.HTTP_200_OK + + async def claim() -> None: + principal = await client.app.state.store.principals.create_by_email(email) + vault = await client.app.state.store.vaults.create_default() + await client.app.state.store.principal_vault_bindings.bind_default(principal.principal_id, vault.vault_id) + await client.app.state.store.identity_claims.claim_identity(identity.handle, principal.principal_id) + await client.app.state.store.identity_claims.accept_claim(identity.handle) + + asyncio.run(claim()) + return identity + + +def _put_provider_client(client: TestClient, provider: str = "github") -> None: + key = build_store_key(provider=provider, record_type="server") + record = ProviderClientRecord( + provider=provider, + client_id="client-123", + client_secret="secret-456", + scopes=["repo", "read:user"], + ) + asyncio.run(client.app.state.vault.put(key, record.model_dump_json(), collection="server")) + + +def _put_connection(client: TestClient, principal_id: str, vault_id: str, provider: str, connection: str) -> None: + collection = f"vault:{vault_id}" + key = build_store_key(vault=vault_id, provider=provider, connection=connection, record_type="connection") + record = ConnectionRecord( + provider=provider, + principal_id=principal_id, + vault_id=vault_id, + connection_name=connection, + auth_type=AuthType.OAUTH2, + status=ConnectionStatus.CONNECTED, + access_token=f"access-{connection}", + refresh_token=f"refresh-{connection}", + token_type="bearer", + scopes=["repo"], + ) + metadata_key = build_store_key(vault=vault_id, provider=provider, record_type="metadata") + metadata = ProviderMetadataRecord( + principal_id=principal_id, + vault_id=vault_id, + provider=provider, + default_connection=connection, + connection_names=[connection], + last_used_connection=connection, + ) + asyncio.run(client.app.state.vault.put(key, record.model_dump_json(), collection=collection)) + asyncio.run(client.app.state.vault.put(metadata_key, metadata.model_dump_json(), collection=collection)) + + +def test_non_admin_provider_detail_hides_config_and_shows_own_connections(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity="admin-ready-boldly-0001")) + user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=user.handle)) + _put_provider_client(client) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "github", "admin-main") + _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "github", "user-main") + + response = client.get( + "/api/providers/github/detail", + headers=_auth_header(tmp_path, "GET", "/api/providers/github/detail", handle=user.handle), + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["account"]["is_admin"] is False + assert body["client"] is None + assert body["show_callback_helper"] is False + assert [conn["connection_name"] for conn in body["connections"]] == ["user-main"] + assert body["principal_usage"] == [] + + +def test_admin_provider_detail_shows_config_and_principal_usage(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity=admin.handle)) + user_ctx = asyncio.run(client.app.state.ownership_resolver.resolve(identity="steady-wisely-boldly-0042")) + _put_provider_client(client) + _put_connection(client, admin_ctx.principal_id, admin_ctx.vault_id, "github", "admin-main") + _put_connection(client, user_ctx.principal_id, user_ctx.vault_id, "github", "user-main") + + response = client.get( + "/api/providers/github/detail", + headers=_auth_header(tmp_path, "GET", "/api/providers/github/detail", handle=admin.handle), + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["account"]["is_admin"] is True + assert body["client"]["client_id"] == "client-123" + assert body["client"]["client_secret"] == "secret-456" + assert body["configuration_warning"] == ( + "Changing these credentials will revoke existing connections for this provider." + ) + field_names = {field["name"] for field in body["configuration_fields"]} + assert {"client_id", "client_secret", "scopes"}.issubset(field_names) + assert body["show_callback_helper"] is True + usage = {group["principal_id"]: group["connections"] for group in body["principal_usage"]} + assert [conn["connection_name"] for conn in usage[admin_ctx.principal_id]] == ["admin-main"] + assert [conn["connection_name"] for conn in usage[user_ctx.principal_id]] == ["user-main"] + + +def test_admin_can_update_provider_configuration(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + admin = _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + body = b'{"client_id":"new-client","client_secret":"new-secret","scopes":"repo,read:user"}' + response = client.put( + "/api/providers/github/configuration", + content=body, + headers={ + **_auth_header(tmp_path, "PUT", "/api/providers/github/configuration", body=body, handle=admin.handle), + "Content-Type": "application/json", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"status": "ok", "changed": True, "provider": "github"} + + +def test_non_admin_provider_configuration_update_is_rejected(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + with TestClient(create_app()) as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + user = _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + body = b'{"client_id":"new-client"}' + response = client.put( + "/api/providers/github/configuration", + content=body, + headers={ + **_auth_header(tmp_path, "PUT", "/api/providers/github/configuration", body=body, handle=user.handle), + "Content-Type": "application/json", + }, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Admin role required" diff --git a/ui/src/app/connections/detail/page.tsx b/ui/src/app/connections/detail/page.tsx new file mode 100644 index 00000000..d8a37f07 --- /dev/null +++ b/ui/src/app/connections/detail/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react"; + +import { AuthsomeConnectionDetailRoute } from "@/components/authsome-dashboard"; + +export default function ConnectionDetailPage() { + return ( + + + + ); +} diff --git a/ui/src/app/providers/detail/page.tsx b/ui/src/app/providers/detail/page.tsx new file mode 100644 index 00000000..44704aeb --- /dev/null +++ b/ui/src/app/providers/detail/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react"; + +import { AuthsomeProviderDetailRoute } from "@/components/authsome-dashboard"; + +export default function ProviderDetailPage() { + return ( + + + + ); +} diff --git a/ui/src/components/authsome-dashboard.tsx b/ui/src/components/authsome-dashboard.tsx index 3c1bd764..d6ca0d0e 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -3,8 +3,10 @@ import { AppWindow, BookOpen, + Check, CheckCircle2, CircleAlert, + Clipboard, ClipboardList, Database, GitBranch, @@ -14,6 +16,7 @@ import { LogIn, LogOut, Plus, + Save, Search, Settings, UserRound, @@ -26,14 +29,21 @@ import useSWR from "swr"; import { ApiError, + ConnectionDetail, DashboardData, + ProviderDetail, ProviderView, SessionInputField, fetchAuthSessionStatus, fetchClaimStatus, + fetchConnectionDetail, fetchDashboard, + fetchProviderDetail, fetchSessionDevice, fetchSessionInput, + logoutConnection, + revokeProvider, + updateProviderConfiguration, } from "@/lib/authsome-api"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -156,6 +166,18 @@ function currentBrowserPath(fallback: string): string { return `${window.location.pathname}${window.location.search}`; } +function connectionDetailHref(provider: string, connection: string, principal?: string | null): string { + const params = new URLSearchParams({ provider, connection }); + if (principal) { + params.set("principal", principal); + } + return `/connections/detail?${params.toString()}`; +} + +function providerDetailHref(provider: string): string { + return `/providers/detail?${new URLSearchParams({ provider }).toString()}`; +} + export function AuthsomeLogin({ nextPath = NEXT_URL }: { nextPath?: string }) { const [safeNextPath] = useState(() => { if (typeof window === "undefined") { @@ -645,6 +667,40 @@ function ErrorState({ onRetry }: { onRetry: () => void }) { ); } +function DashboardDetailShell({ + activeView, + backHref, + children, + data, + title, +}: { + activeView: View; + backHref: string; + children: ReactNode; + data: DashboardData; + title: string; +}) { + return ( +
+
+ +
+ +
+
+ +

{title}

+
+ {children} +
+
+
+
+ ); +} + function Sidebar({ activeView, data, @@ -891,7 +947,7 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr
-
+
{provider.displayName} @@ -899,7 +955,7 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr {provider.source === "custom" ? "Custom provider" : "Bundled provider"}
-
+
@@ -1027,7 +1083,13 @@ function ConnectionsView({ {filteredConnections.map((row) => ( - {row.connectionName} + + + {row.connectionName} + + {row.providerDisplayName} {row.authTypeLabel} @@ -1209,6 +1271,482 @@ function SearchInput({ ); } +function detailProviderDisplayName(data: ProviderDetail): string { + return data.provider.display_name || data.provider.name; +} + +function detailProviderApiUrl(data: ProviderDetail): string { + const apiUrl = data.client?.api_url || data.provider.api_url || data.provider.oauth?.base_url || data.provider.name; + return Array.isArray(apiUrl) ? apiUrl.filter(Boolean).join(", ") : apiUrl; +} + +function detailAuthTypeLabel(authType?: string): string { + return authType === "oauth2" ? "OAuth 2.0" : authType === "api_key" ? "API Key" : authType || "Provider"; +} + +export function AuthsomeProviderDetailRoute() { + const searchParams = useSearchParams(); + const provider = searchParams.get("provider") || ""; + + if (!provider) { + return ( +
+ + + Provider not found + Open a provider from the dashboard to view its details. + + + + + +
+ ); + } + + return ; +} + +export function AuthsomeProviderDetail({ provider }: { provider: string }) { + const pathname = usePathname(); + const router = useRouter(); + const { data: dashboard, error: dashboardError, mutate: mutateDashboard } = useSWR("authsome-dashboard", fetchDashboard); + const { data, error, mutate } = useSWR(["authsome-provider-detail", provider], () => fetchProviderDetail(provider)); + + useEffect(() => { + if (isUnauthorized(dashboardError) || isUnauthorized(error)) { + router.replace(`/login?next=${encodeURIComponent(currentBrowserPath(pathname || providerDetailHref(provider)))}`); + } + }, [dashboardError, error, pathname, provider, router]); + + if (isUnauthorized(dashboardError) || isUnauthorized(error)) return ; + if (dashboardError || error) return { void mutateDashboard(); void mutate(); }} />; + if (!dashboard || !data) return ; + + return ( + + { void mutate(); void mutateDashboard(); }} /> + + ); +} + +function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail; onRefresh: () => void }) { + const displayName = detailProviderDisplayName(data); + const initial = (displayName[0] || "?").toUpperCase(); + const description = data.provider.description || data.provider.metadata?.description || ""; + const showsConfiguration = data.provider.auth_type !== "api_key"; + + return ( +
+
+ + +
+ +
+ {displayName} + {description || detailProviderApiUrl(data)} +
+
+
+ + + + + {data.provider.docs_url ? : null} + {data.show_callback_helper && data.callback_url ? ( + + ) : null} + +
+ +
+
+ {showsConfiguration ? ( + data.account.is_admin ? ( + `${field.name}:${field.default || ""}`).join("|")} + onRefresh={onRefresh} + /> + ) : ( + + + Configuration + Managed by the admin. + + + ) + ) : null} + + + Connect + Create a connection in the current vault. + + +
+ + + +
+
+
+
+
+ ); +} + +function ProviderConfigurationForm({ data, onRefresh }: { data: ProviderDetail; onRefresh: () => void }) { + const initialValues = useMemo(() => { + const values: Record = {}; + for (const field of data.configuration_fields) { + values[field.name] = field.default || ""; + } + return values; + }, [data.configuration_fields]); + const [values, setValues] = useState(initialValues); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + + async function save() { + setSaving(true); + setMessage(""); + try { + const result = await updateProviderConfiguration(data.provider.name, values); + setMessage(result.changed ? "Configuration updated." : "No changes to save."); + onRefresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Configuration could not be saved."); + } finally { + setSaving(false); + } + } + + return ( + + + Configuration + Provider-level inputs required before users can connect. + + + {data.configuration_warning ? ( +
+ {data.configuration_warning} +
+ ) : null} + {data.configuration_fields.map((field) => ( + + ))} + {message ?
{message}
: null} + + +
+
+ ); +} + +function RevokeProviderButton({ data, onRefresh }: { data: ProviderDetail; onRefresh: () => void }) { + const [open, setOpen] = useState(false); + const [working, setWorking] = useState(false); + const [message, setMessage] = useState(""); + + async function revoke() { + setWorking(true); + setMessage(""); + try { + await revokeProvider(data.provider.name); + setOpen(false); + onRefresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Provider could not be revoked."); + } finally { + setWorking(false); + } + } + + return ( + <> + + + + + Revoke app + All connections for this provider will be revoked. + + {message ?
{message}
: null} + + + + +
+
+ + ); +} + +function ProviderUsage({ data }: { data: ProviderDetail }) { + const groups = data.account.is_admin + ? data.principal_usage + : [{ principal_id: data.account.principal_id || "current", email: null, connections: data.connections }]; + + return ( + + + Connections + + + {groups.length ? ( + groups.map((group) => ( +
+
{group.email || group.principal_id}
+ {group.connections.map((connection) => ( + + {connection.connection_name} + + + ))} +
+ )) + ) : ( +
No connections found.
+ )} +
+
+ ); +} + +export function AuthsomeConnectionDetailRoute() { + const searchParams = useSearchParams(); + const provider = searchParams.get("provider") || ""; + const connection = searchParams.get("connection") || ""; + const principal = searchParams.get("principal") || undefined; + + if (!provider || !connection) { + return ( +
+ + + Connection not found + Open a connection from the dashboard to view its details. + + + + + +
+ ); + } + + return ; +} + +export function AuthsomeConnectionDetail({ + connection, + principal, + provider, +}: { + connection: string; + principal?: string; + provider: string; +}) { + const pathname = usePathname(); + const router = useRouter(); + const { data: dashboard, error: dashboardError, mutate: mutateDashboard } = useSWR("authsome-dashboard", fetchDashboard); + const { data, error, mutate } = useSWR(["authsome-connection-detail", provider, connection, principal], () => + fetchConnectionDetail(provider, connection, principal), + ); + + useEffect(() => { + if (isUnauthorized(dashboardError) || isUnauthorized(error)) { + router.replace(`/login?next=${encodeURIComponent(currentBrowserPath(pathname || connectionDetailHref(provider, connection, principal)))}`); + } + }, [connection, dashboardError, error, pathname, principal, provider, router]); + + if (isUnauthorized(dashboardError) || isUnauthorized(error)) return ; + if (dashboardError || error) return { void mutateDashboard(); void mutate(); }} />; + if (!dashboard || !data) return ; + + return ( + + { void mutate(); void mutateDashboard(); }} principal={principal} /> + + ); +} + +function SecretValue({ label, value }: { label: string; value: string | null }) { + const [copied, setCopied] = useState(false); + if (!value) return null; + + async function copy() { + await navigator.clipboard.writeText(value || ""); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } + + return ( +
+
{label}
+
+ {value} + +
+
+ ); +} + +function ConnectionDetailBody({ + data, + onRefresh, + principal, +}: { + data: ConnectionDetail; + onRefresh: () => void; + principal?: string; +}) { + return ( +
+ + + {data.provider_display_name} + + {data.provider} / {data.connection_name} + + + + + + + + + + + + + + + +
+ + + Secrets + + + + + + {Object.entries(data.secrets.credentials).map(([key, value]) => ( + + ))} + + + +
+
+ ); +} + +function ConnectionActions({ + data, + onRefresh, + principal, +}: { + data: ConnectionDetail; + onRefresh: () => void; + principal?: string; +}) { + const [open, setOpen] = useState(false); + const [working, setWorking] = useState(false); + + async function logout() { + setWorking(true); + try { + await logoutConnection(data.provider, data.connection_name, principal); + setOpen(false); + onRefresh(); + } finally { + setWorking(false); + } + } + + return ( + + + Actions + + + {data.can_set_default ? ( +
+ +
+ ) : null} + + +
+ + + + Logout connection + This removes the stored credentials for this connection. + + + + + + + +
+ ); +} + function ActiveView({ connectionFilter, data, view }: { connectionFilter?: string; data: DashboardData; diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts index 798c6b82..1367f271 100644 --- a/ui/src/lib/authsome-api.ts +++ b/ui/src/lib/authsome-api.ts @@ -97,7 +97,7 @@ type ConnectionSummary = { expires_at?: string | null; }; -type ProviderResponse = { +export type ProviderResponse = { name: string; display_name?: string; logo?: string | null; @@ -110,6 +110,81 @@ type ProviderResponse = { metadata?: { description?: string; }; + docs_url?: string | null; +}; + +export type ProviderClientDetail = { + client_id: string | null; + client_secret: string | null; + base_url: string | null; + api_url: string | null; + scopes: string[]; +}; + +export type ProviderConfigurationField = { + name: string; + label: string; + secret: boolean; + default?: string | null; + pattern?: string | null; + pattern_hint?: string | null; +}; + +export type ProviderConnectionSummary = { + provider: string; + provider_display_name: string; + connection_name: string; + status: string; + auth_type: string; + account_label: string | null; + principal_id: string | null; +}; + +export type ProviderPrincipalUsage = { + principal_id: string; + email: string | null; + connections: ProviderConnectionSummary[]; +}; + +export type ProviderDetail = { + provider: ProviderResponse; + account: { + principal_id: string | null; + role: string; + is_admin: boolean; + }; + client: ProviderClientDetail | null; + configuration_fields: ProviderConfigurationField[]; + configuration_warning: string | null; + show_callback_helper: boolean; + callback_url: string | null; + connections: ProviderConnectionSummary[]; + principal_usage: ProviderPrincipalUsage[]; +}; + +export type ConnectionDetail = { + provider: string; + provider_display_name: string; + connection_name: string; + principal_id: string | null; + identity: string | null; + vault_id: string | null; + status: string; + auth_type: string; + base_url: string | null; + api_url: string | null; + scopes: string[]; + token_type: string | null; + obtained_at: string | null; + expires_at: string | null; + account: Record | null; + secrets: { + access_token: string | null; + refresh_token: string | null; + api_key: string | null; + credentials: Record; + }; + can_set_default: boolean; }; type ConnectionsResponse = { @@ -199,6 +274,31 @@ async function requestJson(path: string): Promise { return response.json() as Promise; } +async function sendJson(path: string, init: RequestInit): Promise { + const response = await fetch(path, { + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...(init.headers || {}), + }, + ...init, + }); + + if (!response.ok) { + let message = response.statusText || "Request failed"; + try { + const payload = (await response.json()) as { detail?: string; message?: string }; + message = payload.detail || payload.message || message; + } catch { + // Status is sufficient for the UI's failure modes. + } + throw new ApiError(response.status, message); + } + + return response.json() as Promise; +} + function authTypeLabel(authType?: string): string { return authType === "oauth2" ? "OAuth 2.0" : authType === "api_key" ? "API Key" : authType || "Provider"; } @@ -404,3 +504,47 @@ export async function fetchSessionDevice(sessionId: string): Promise { return requestJson(`/api/auth/sessions/${encodeURIComponent(sessionId)}/status`); } + +export async function fetchProviderDetail(provider: string): Promise { + return requestJson(`/api/providers/${encodeURIComponent(provider)}/detail`); +} + +export async function updateProviderConfiguration( + provider: string, + payload: Record, +): Promise<{ status: "ok"; changed: boolean; provider: string }> { + return sendJson(`/api/providers/${encodeURIComponent(provider)}/configuration`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export async function fetchConnectionDetail( + provider: string, + connection: string, + principal?: string, +): Promise { + const query = principal ? `?principal=${encodeURIComponent(principal)}` : ""; + return requestJson( + `/api/connections/${encodeURIComponent(provider)}/${encodeURIComponent(connection)}/detail${query}`, + ); +} + +export async function logoutConnection( + provider: string, + connection: string, + principal?: string, +): Promise<{ status: string }> { + const query = principal ? `?principal=${encodeURIComponent(principal)}` : ""; + return sendJson(`/api/connections/${encodeURIComponent(provider)}/${encodeURIComponent(connection)}/logout${query}`, { + method: "POST", + body: "{}", + }); +} + +export async function revokeProvider(provider: string): Promise<{ status: string; provider?: string }> { + return sendJson(`/api/connections/${encodeURIComponent(provider)}/revoke`, { + method: "POST", + body: "{}", + }); +}