Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/refactor.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Storage, Secrets, Identity, And Composition Refactor Plan

_Updated 2026-05-27. Reflects actual codebase state vs. original plan._
_Updated 2026-05-28. Reflects actual codebase state vs. original plan._

---

Expand All @@ -11,9 +11,9 @@ _Updated 2026-05-27. Reflects actual codebase state vs. original plan._
| 1 — Storage foundation | **Replaced** | `StoreDatabase` (SQLite/Postgres) for registries; `DiskStore + AesGcmEncryptionWrapper` for credentials. Two-store design is better than the single-KV-substrate plan. |
| 2 — Secret source chain | **Partial** | `ServerSecretResolver` (env → file → keyring → generate) covers server-owned keys (master key, UI session key). Client identity private keys still resolved in `identity/local.py`. |
| 3 — IdentityRepository | **Partial** | Server-side: `IdentityRegistry` in `server/store/repositories.py` (relational, done). Client-side: still raw `identity/local.py`, no structural server boundary. |
| 4 — CredentialRepository | **Not done** | `AuthService` still calls `build_store_key()` and `self._vault.get/put/delete` directly. This is the most significant remaining gap. |
| 5 — ProviderRepository | **Partial** | `ProviderDefinitionRepository` handles custom providers (done). Bundled provider loading still lives in `AuthService._load_bundled_providers()`. |
| 6 — Slim AuthService | **Partial** | Receives `ProviderDefinitionRepository` (done). Still owns raw vault key construction and all vault I/O. |
| 4 — CredentialRepository | **Done** | `server/credential_repository.py` owns vault key construction and credential persistence. |
| 5 — ProviderRepository | **Done** | `server/provider_repository.py` owns bundled + custom provider resolution. |
| 6 — Slim AuthService | **Partial** | Receives `CredentialRepository` and `ProviderRepository`; policy cleanup and audit logger injection remain separate follow-ups. |
| 7 — Server composition root | **Functional** | `ServerStore` + `app.state` + `dependencies.py` cover the spirit. No `ServerState` dataclass. `identity="server"` placeholder is gone. |
| 8 — Proxy server authority | **Done** | `proxy_catalog.py` + `/proxy/routes` endpoint. |
| 9 — Docs | **Partial** | CONTEXT.md and UBIQUITOUS_LANGUAGE.md updated. |
Expand All @@ -31,7 +31,7 @@ _Updated 2026-05-27. Reflects actual codebase state vs. original plan._

## Remaining work

Three gaps remain. They are listed in delivery order — each one unblocks the next.
The original CredentialRepository and ProviderRepository gaps are now closed. Remaining phases below are follow-up work and historical context for the broader refactor.

---

Expand Down
109 changes: 109 additions & 0 deletions src/authsome/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
from typing import TYPE_CHECKING
from urllib.parse import urlsplit, urlunsplit

from authsome.auth.input_provider import InputField
from authsome.auth.models.connection import ProviderClientRecord
from authsome.auth.models.enums import AuthType, FlowType
from authsome.auth.models.provider import ProviderDefinition
from authsome.errors import InvalidProviderSchemaError
from authsome.utils import is_filesystem_safe

if TYPE_CHECKING:
from authsome.auth.sessions import AuthSession

Expand Down Expand Up @@ -53,3 +60,105 @@ def normalize_base_url(base_url: str | None) -> str | None:
def export_name_part(value: str) -> str:
"""Convert a string into a component suitable for an environment variable name."""
return re.sub(r"[^A-Z0-9]+", "_", value.upper()).strip("_")


VALID_FLOWS: dict[AuthType, set[FlowType]] = {
AuthType.OAUTH2: {FlowType.PKCE, FlowType.DEVICE_CODE, FlowType.DCR_PKCE},
AuthType.API_KEY: {FlowType.API_KEY},
AuthType.BROWSER: {FlowType.BROWSER},
}


def validate_provider_definition(definition: ProviderDefinition) -> None:
if not is_filesystem_safe(definition.name):
raise InvalidProviderSchemaError(
f"Provider name '{definition.name}' is not filesystem-safe",
provider=definition.name,
)
valid_flows = VALID_FLOWS.get(definition.auth_type)
if valid_flows is None:
raise InvalidProviderSchemaError(
f"Unrecognized auth_type: {definition.auth_type}",
provider=definition.name,
)
if definition.flow not in valid_flows:
raise InvalidProviderSchemaError(
f"Flow '{definition.flow}' is not valid for auth_type '{definition.auth_type}'. "
f"Valid flows: {[flow.value for flow in valid_flows]}",
provider=definition.name,
)
if definition.auth_type == AuthType.OAUTH2 and definition.oauth is None:
raise InvalidProviderSchemaError(
"auth_type 'oauth2' requires an 'oauth' configuration section",
provider=definition.name,
)
if definition.auth_type == AuthType.API_KEY and definition.api_key is None:
raise InvalidProviderSchemaError(
"auth_type 'api_key' requires an 'api_key' configuration section",
provider=definition.name,
)
if definition.auth_type == AuthType.BROWSER and definition.browser is None:
raise InvalidProviderSchemaError(
"auth_type 'browser' requires a 'browser' configuration section",
provider=definition.name,
)


def required_inputs(
*,
provider: ProviderDefinition,
flow_type: FlowType,
client_record: ProviderClientRecord | None,
scopes: list[str] | None = None,
base_url: str | None = None,
provider_config_only: bool = False,
) -> list[InputField]:
flow_base_url = base_url or (client_record.base_url if client_record else None)
flow_client_id = client_record.client_id if client_record else None
persisted_scopes = client_record.scopes if client_record else None
fields: list[InputField] = []

if provider.oauth and provider.oauth.base_url and (provider_config_only or not flow_base_url):
fields.append(
InputField(
name="base_url",
label="Base URL",
secret=False,
default=flow_base_url or provider.oauth.base_url,
)
)
fields.append(
InputField(
name="api_url",
label="API Host URL",
secret=False,
default=(
client_record.api_url
if client_record and client_record.api_url
else provider.primary_api_url() or ""
),
)
)

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=""))
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=""))

if flow_type in (FlowType.PKCE, FlowType.DEVICE_CODE, FlowType.DCR_PKCE):
if scopes is None and persisted_scopes is None:
default_scopes = ",".join(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:
api_key_field = InputField(name="api_key", label="API Key", secret=True)
if provider.api_key and provider.api_key.key_pattern:
api_key_field.pattern = provider.api_key.key_pattern
api_key_field.pattern_hint = provider.api_key.key_pattern_hint
fields.append(api_key_field)

return fields
4 changes: 0 additions & 4 deletions src/authsome/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
ClaimStatus,
IdentityClaimRecord,
PrincipalRecord,
PrincipalVaultBindingRecord,
VaultRecord,
)
from authsome.identity.proof import (
POP_AUTH_SCHEME,
Expand All @@ -51,13 +49,11 @@
"IdentityStatus",
"IdentityRegistration",
"PrincipalRecord",
"PrincipalVaultBindingRecord",
"POP_AUTH_SCHEME",
"ProofClaims",
"ProofValidationError",
"ReplayCache",
"RuntimeIdentity",
"VaultRecord",
"current_from_home",
"create_identity",
"create_proof_jwt",
Expand Down
23 changes: 1 addition & 22 deletions src/authsome/identity/principal.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Domain models for principals, vaults, and ownership bindings.
"""Domain models for principals and identity claims.

These are pure data models shared across server, cli, and identity modules.
Filesystem-backed registry implementations live in server/registries.py.
Expand Down Expand Up @@ -40,16 +40,6 @@ class PrincipalRecord(BaseModel):
updated_at: datetime = Field(default_factory=utc_now)


# TODO: This should be a server property. The principal module should not care about which vault is owned by who
class VaultRecord(BaseModel):
"""Vault record owned as a first-class resource."""

vault_id: str
handle: str = "default"
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)


class IdentityClaimRecord(BaseModel):
"""Binding from identity to principal with lifecycle state."""

Expand All @@ -58,14 +48,3 @@ class IdentityClaimRecord(BaseModel):
claim_status: ClaimStatus = ClaimStatus.PENDING
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)


# TODO: This should be a server property. The principal module should not care about which vault is owned by who
class PrincipalVaultBindingRecord(BaseModel):
"""Binding from principal to a vault."""

principal_id: str
vault_id: str
is_default: bool = False
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
3 changes: 2 additions & 1 deletion src/authsome/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
load_server_config,
load_ui_session_signing_secret,
)
from authsome.server.provider_repository import ProviderRepository
from authsome.server.routes.audit import router as audit_router
from authsome.server.routes.auth import router as auth_router
from authsome.server.routes.connections import router as connections_router
Expand Down Expand Up @@ -52,7 +53,7 @@ async def lifespan(app: FastAPI):
app.state.vault_registry = app.state.store.vaults
app.state.identity_claim_registry = app.state.store.identity_claims
app.state.principal_vault_binding_registry = app.state.store.principal_vault_bindings
app.state.provider_definition_repository = app.state.store.provider_definitions
app.state.provider_repository = ProviderRepository(app.state.store.provider_definitions)
app.state.hosted_account_service = create_hosted_account_service(app.state.store)
app.state.server_base_url = get_server_base_url()
init_posthog()
Expand Down
Loading
Loading