diff --git a/docs/site/guides/multiple-connections.mdx b/docs/site/guides/multiple-connections.mdx
index 1a0f233f..bdf19228 100644
--- a/docs/site/guides/multiple-connections.mdx
+++ b/docs/site/guides/multiple-connections.mdx
@@ -6,20 +6,25 @@ icon: "users"
keywords: ["authsome multiple accounts", "authsome connection flag", "multiple github authsome", "two openai keys authsome"]
---
-A connection is a named credential record within one provider. The default connection is called `default`. You can have as many additional connections as you want, each scoped by name.
+A connection is a named credential record within one provider. **Every connection has a name that you choose — including the first one.** You can have as many connections as you want, each scoped by name. The provider's *default* connection is a pointer to one of your named connections, not a connection literally called `default`.
+
+
+`default` is a reserved name. You cannot create a connection called `default`; it survives only as a read-time alias for the provider's default connection (and for vaults created before named-first connections existed).
+
This is the right pattern when you want two accounts on the same provider both reachable from the same context. If you want credential sets that never see each other, use multiple Vaults or Principals instead. The distinction is covered in [Principal, Vault, and Identity](/concepts/principal-vault-identity).
-## Create a second connection
+## Name your connections
-Pass `--connection ` to `login`. The name is arbitrary; pick what reads well.
+Pass `--connection ` to `login`. The name is arbitrary; pick what reads well. If you omit `--connection`, `authsome login` prompts you for a name (and fails with a usage error in non-interactive contexts such as `--quiet` or a non-TTY, so scripts must pass it explicitly).
```bash
-authsome login github # default connection
-authsome login github --connection work
-authsome login github --connection personal
+authsome login github --connection work # first connection: becomes the default
+authsome login github --connection personal # second connection
```
+The first connection you create automatically becomes the provider's default. Subsequent connections leave the default unchanged unless you switch it.
+
For an OAuth2 provider, each `login` runs its own browser flow. The connections share the OAuth `client_id`/`client_secret` you configured for the provider, but each receives an independent access token bound to its own scopes.
For an API-key provider, each `login` opens a fresh local form so you paste a different key per connection.
@@ -34,7 +39,7 @@ authsome get github --connection personal --field status
authsome export github --connection work --format env
```
-Without `--connection`, the command uses the default.
+Without `--connection`, the command resolves the provider's default connection from metadata.
## List connections
@@ -47,22 +52,26 @@ authsome inspect github # shows every connection on the provider
## Switch the default
-There is no `set-default` command in v1. To change which connection authsome treats as default, log in to it with `--force`:
+The default is a pointer stored in provider metadata. Point it at any existing connection with `connections set-default`:
```bash
-authsome login github --connection work --force
+authsome connections set-default github work
```
-The `--force` flag overwrites the existing `default` slot. Programmatic default switching is on the roadmap.
+Use `--force` on `login` to re-authenticate an existing connection in place:
+
+```bash
+authsome login github --connection work --force
+```
## In the proxy
`authsome run` injects credentials from each provider's **default** connection. Per-request connection selection is future work.
-To run an agent against a non-default connection, set the active default first:
+To run an agent against a non-default connection, point the default at it first:
```bash
-authsome login github --connection work --force
+authsome connections set-default github work
authsome run -- python my_agent.py
```
diff --git a/src/authsome/auth/models/connection.py b/src/authsome/auth/models/connection.py
index 09e0ce15..d35f77c1 100644
--- a/src/authsome/auth/models/connection.py
+++ b/src/authsome/auth/models/connection.py
@@ -16,6 +16,11 @@
# TODO: Auth module shouldn't worry about storage. Remove all key references
# TODO: Remvoe hardcoded schema versions everywhere
+# Reserved connection name. New connections may never be stored under this name;
+# it survives only as a read-time alias for vaults created before named-first
+# connections existed (it resolves to the provider's ``default_connection``).
+DEFAULT_CONNECTION_NAME = "default"
+
# TODO: Pydantic as secretstr, why not just use that?
class Sensitive:
@@ -83,7 +88,9 @@ class ProviderMetadataRecord(BaseModel):
principal_id: str | None = None
vault_id: str | None = None
provider: str
- default_connection: str = "default"
+ # None until the first connection is saved, at which point it is set to that
+ # connection's name. The literal "default" only appears in legacy vaults.
+ default_connection: str | None = None
connection_names: list[str] = Field(default_factory=list)
last_used_connection: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
diff --git a/src/authsome/cli/commands/core.py b/src/authsome/cli/commands/core.py
index 6fa5cd2d..c7b5f83b 100644
--- a/src/authsome/cli/commands/core.py
+++ b/src/authsome/cli/commands/core.py
@@ -10,6 +10,7 @@
from authsome import FlowType
from authsome.auth.flows.browser import BrowserFlow
+from authsome.auth.models.connection import DEFAULT_CONNECTION_NAME
from authsome.auth.models.enums import AuthType
from authsome.auth.models.provider import ProviderDefinition
from authsome.cli.config import ClientConfig
@@ -52,11 +53,15 @@ def _build_login_json_payload(session_info: dict[str, Any], provider: str, conne
async def _run_credential_scan( # noqa: PLR0912, PLR0915
actx: ContextObj,
*,
- connection: str,
auto_import: bool,
event_name: str,
) -> dict[str, Any]:
- """Scan env sources for API keys and optionally import them into the vault."""
+ """Scan env sources for API keys and optionally import them into the vault.
+
+ Imported keys are stored under a connection named after the provider (e.g.
+ ``openai``) rather than the reserved ``default`` name; that connection
+ automatically becomes the provider's default.
+ """
scanned_env = _scan_env_sources()
provider_defs: list[ProviderDefinition] = []
@@ -72,6 +77,7 @@ async def _run_credential_scan( # noqa: PLR0912, PLR0915
if definition.auth_type != AuthType.API_KEY:
continue
+ connection = definition.name
existing_record: dict[str, Any] | None = None
try:
existing_record = await actx.runtime_client.get_connection(definition.name, connection)
@@ -142,7 +148,7 @@ async def _run_credential_scan( # noqa: PLR0912, PLR0915
session_info = await actx.runtime_client.start_login(
provider=provider_name,
- connection=connection,
+ connection=provider_name,
flow=FlowType.API_KEY.value,
force=True,
)
@@ -155,18 +161,24 @@ async def _run_credential_scan( # noqa: PLR0912, PLR0915
)
imported += 1
- results.append({"provider": provider_name, "status": "imported", "env_var": item["env_var"]})
+ results.append(
+ {
+ "provider": provider_name,
+ "status": "imported",
+ "env_var": item["env_var"],
+ "connection": provider_name,
+ }
+ )
logger.info(
"client_event event={} provider={} connection={} source={} source_env={} status=success",
event_name,
provider_name,
- connection,
+ provider_name,
item["source"],
item["env_var"],
)
return {
- "connection": connection,
"import": should_import,
"configured_count": len(configured),
"imported_count": imported,
@@ -174,9 +186,28 @@ async def _run_credential_scan( # noqa: PLR0912, PLR0915
}
+def _resolve_login_connection_name(ctx_obj: ContextObj, provider: str, connection: str | None) -> str:
+ """Resolve the connection name for `login`, prompting interactively when omitted.
+
+ Logins always require a user-chosen name. When `--connection` is omitted we
+ prompt on stderr (keeping stdout JSON clean) if attached to a TTY; otherwise
+ we raise a usage error so automation fails fast instead of hanging.
+ """
+ name = (connection or "").strip()
+ if not name:
+ if ctx_obj.quiet or not sys.stdin.isatty():
+ raise click.UsageError("Provide a connection name with --connection NAME.")
+ name = click.prompt("Connection name", default=provider, err=True).strip()
+ if not name:
+ raise click.UsageError("Connection name must not be empty.")
+ if name == DEFAULT_CONNECTION_NAME:
+ raise click.UsageError(f"'{DEFAULT_CONNECTION_NAME}' is a reserved connection name; choose another name.")
+ return name
+
+
@click.command()
@click.argument("provider")
-@click.option("--connection", default="default", metavar="NAME", help="Connection name.")
+@click.option("--connection", default=None, metavar="NAME", help="Connection name (prompted when omitted).")
@click.option(
"--flow",
type=click.Choice([e.value for e in FlowType], case_sensitive=False),
@@ -190,13 +221,14 @@ async def _run_credential_scan( # noqa: PLR0912, PLR0915
async def login( # noqa: PLR0913
ctx_obj: ContextObj,
provider: str,
- connection: str,
+ connection: str | None,
flow: str | None,
scopes: str | None,
base_url: str | None,
force: bool,
) -> None:
"""Authenticate with PROVIDER using the configured flow."""
+ connection = _resolve_login_connection_name(ctx_obj, provider, connection)
actx = await ctx_obj.initialize()
flow_value = FlowType(flow).value if flow else None
scope_list = [s.strip() for s in scopes.split(",")] if scopes else None
@@ -275,7 +307,6 @@ async def onboard(ctx_obj: ContextObj, base_url: str | None, scan_only: bool) ->
scan_result = await _run_credential_scan(
actx,
- connection="default",
auto_import=not scan_only,
event_name="onboard",
)
diff --git a/src/authsome/errors.py b/src/authsome/errors.py
index 0b70fc35..2865b888 100644
--- a/src/authsome/errors.py
+++ b/src/authsome/errors.py
@@ -132,6 +132,13 @@ def __init__(self, reason: str, *, provider: str | None = None) -> None:
super().__init__(f"Authentication failed: {reason}", provider=provider)
+class InvalidConnectionNameError(AuthsomeError):
+ """Raised when a login is attempted without a valid, user-chosen connection name."""
+
+ def __init__(self, reason: str, *, provider: str | None = None) -> None:
+ super().__init__(reason, provider=provider, operation="login")
+
+
class DiscoveryError(AuthsomeError):
"""Raised when OAuth discovery (.well-known) fails."""
diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py
index 9f8f6363..36364110 100644
--- a/src/authsome/server/credential_service.py
+++ b/src/authsome/server/credential_service.py
@@ -21,6 +21,7 @@
from authsome.auth.flows.pkce import PkceFlow
from authsome.auth.input_provider import InputField
from authsome.auth.models.connection import (
+ DEFAULT_CONNECTION_NAME,
ConnectionRecord,
ProviderClientRecord,
ProviderMetadataRecord,
@@ -39,6 +40,7 @@
from authsome.errors import (
ConnectionNotFoundError,
CredentialMissingError,
+ InvalidConnectionNameError,
InvalidProviderSchemaError,
OperationNotAllowedError,
ProviderNotFoundError,
@@ -64,6 +66,24 @@
}
+def validate_login_connection_name(connection: str | None, *, provider: str | None = None) -> str:
+ """Return a normalized, loginable connection name or raise.
+
+ Logins must always target a user-chosen name. Empty names and the reserved
+ ``"default"`` alias are rejected so that new credentials are never written
+ under the legacy ``"default"`` key.
+ """
+ name = (connection or "").strip()
+ if not name:
+ raise InvalidConnectionNameError("A connection name is required to log in.", provider=provider)
+ if name == DEFAULT_CONNECTION_NAME:
+ raise InvalidConnectionNameError(
+ f"'{DEFAULT_CONNECTION_NAME}' is a reserved connection name; choose another name.",
+ provider=provider,
+ )
+ return name
+
+
@dataclass(slots=True)
class EffectiveConnection:
record: ConnectionRecord
@@ -297,7 +317,11 @@ async def list_connections(self) -> list[dict[str, Any]]:
connection_name = parts.connection
if provider_name not in defaults:
metadata = await self._credentials.get_provider_metadata(provider_name)
- defaults[provider_name] = metadata.default_connection if metadata else "default"
+ defaults[provider_name] = (
+ metadata.default_connection
+ if metadata and metadata.default_connection
+ else DEFAULT_CONNECTION_NAME
+ )
record = await self._credentials.get_connection(provider_name, connection_name)
if record is None:
continue
@@ -384,11 +408,19 @@ async def list_global_connection_summaries(self) -> list[GlobalConnectionSummary
return sorted(summaries, key=lambda row: (row.provider_display_name.lower(), row.connection_name))
async def resolve_connection_name(self, provider: str, connection: str | None = None) -> str:
- """Resolve an optional connection name to the provider default."""
- if connection:
+ """Resolve a connection name to the provider's default.
+
+ An explicit name (other than the reserved ``"default"`` alias) is returned
+ as-is. ``None`` or ``"default"`` resolve through ``default_connection``
+ metadata, falling back to the literal ``"default"`` only for legacy vaults
+ whose metadata predates named-first connections.
+ """
+ if connection and connection != DEFAULT_CONNECTION_NAME:
return connection
metadata = await self._credentials.get_provider_metadata(provider)
- return metadata.default_connection if metadata else "default"
+ if metadata and metadata.default_connection:
+ return metadata.default_connection
+ return DEFAULT_CONNECTION_NAME
async def resolve_effective_connection(
self,
@@ -396,11 +428,11 @@ async def resolve_effective_connection(
connection: str | None = None,
) -> EffectiveConnection:
"""Resolve the connection used for credential access, including global fallback."""
- if connection is not None and connection != "default":
+ if connection is not None and connection != DEFAULT_CONNECTION_NAME:
record = await self.get_connection(provider, connection)
return EffectiveConnection(record=record, credentials=self._credentials, source="local")
- local_default = "default" if connection == "default" else await self.resolve_connection_name(provider, None)
+ local_default = await self.resolve_connection_name(provider, None)
record = await self._credentials.get_connection(provider, local_default)
if record is not None:
return EffectiveConnection(record=record, credentials=self._credentials, source="local")
@@ -699,7 +731,7 @@ async def begin_login_flow(
base_url: str | None = None,
) -> None:
provider = session.provider
- connection_name = session.connection_name
+ connection_name = validate_login_connection_name(session.connection_name, provider=provider)
definition = await self.get_provider(provider)
flow_type = flow_override or FlowType(session.flow_type)
@@ -1113,6 +1145,8 @@ async def _update_provider_metadata(self, provider: str, connection_name: str) -
)
if connection_name not in metadata.connection_names:
metadata.connection_names.append(connection_name)
+ if not metadata.default_connection:
+ metadata.default_connection = connection_name
metadata.last_used_connection = connection_name
await self._credentials.save_provider_metadata(metadata)
diff --git a/src/authsome/server/routes/auth.py b/src/authsome/server/routes/auth.py
index ffdea8be..31673152 100644
--- a/src/authsome/server/routes/auth.py
+++ b/src/authsome/server/routes/auth.py
@@ -9,7 +9,7 @@
from authsome.auth.models.enums import AuthType, FlowType
from authsome.auth.sessions import AuthSession, AuthSessionRepository, AuthSessionStatus
from authsome.server.analytics import capture_event
-from authsome.server.credential_service import CredentialService
+from authsome.server.credential_service import CredentialService, validate_login_connection_name
from authsome.server.routes._deps import (
get_auth_sessions,
get_protected_auth_service,
@@ -88,13 +88,14 @@ async def start_session(
sessions: AuthSessionRepository = Depends(get_auth_sessions),
server_base_url: str = Depends(get_server_base_url),
) -> AuthSessionResponse:
+ connection_name = validate_login_connection_name(body.connection, provider=body.provider)
definition = await auth.get_provider(body.provider)
flow = FlowType(body.flow) if body.flow else definition.flow
session = await sessions.create(
provider=body.provider,
identity=auth.identity,
principal_id=auth.principal_id,
- connection_name=body.connection,
+ connection_name=connection_name,
flow_type=flow.value,
)
session.payload["force"] = body.force
@@ -106,7 +107,7 @@ async def start_session(
if not body.force:
try:
- existing = await auth.get_connection(body.provider, body.connection)
+ existing = await auth.get_connection(body.provider, connection_name)
if auth.has_usable_connection(existing, scopes=body.scopes, base_url=body.base_url):
session.state = AuthSessionStatus.COMPLETED
session.status_message = "Already connected"
diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py
index 4730626b..ccae3a15 100644
--- a/src/authsome/server/routes/ui.py
+++ b/src/authsome/server/routes/ui.py
@@ -11,8 +11,9 @@
from authsome import audit
from authsome.auth.models.enums import FlowType
from authsome.auth.sessions import AuthSessionRepository
+from authsome.errors import InvalidConnectionNameError
from authsome.server.analytics import capture_event
-from authsome.server.credential_service import CredentialService
+from authsome.server.credential_service import CredentialService, validate_login_connection_name
from authsome.server.routes._deps import (
UI_SESSION_COOKIE_NAME,
get_auth_service,
@@ -118,10 +119,15 @@ async def connect_provider( # noqa: PLR0913
) -> Response:
"""Start a provider connection from the static dashboard."""
form = await request.form()
- connection_name = str(form.get("connection") or form.get("connection_name") or "default")
+ raw_connection = str(form.get("connection") or form.get("connection_name") or "")
force = str(form.get("force", "false")).lower() in {"1", "true", "on", "yes"}
return_path = _account_auth_next_url(form.get("return_url") or "/")
+ try:
+ connection_name = validate_login_connection_name(raw_connection, provider=provider_name)
+ except InvalidConnectionNameError as exc:
+ return _redirect(request, _append_query(return_path, {"connect_error": str(exc.args[0])}))
+
definition = await auth.get_provider(provider_name)
flow = definition.flow
session = await sessions.create(
diff --git a/tests/auth/test_models.py b/tests/auth/test_models.py
index 76d8e316..cf0d9179 100644
--- a/tests/auth/test_models.py
+++ b/tests/auth/test_models.py
@@ -275,7 +275,7 @@ class TestProviderMetadataRecord:
def test_defaults(self) -> None:
meta = ProviderMetadataRecord(identity="default", provider="github")
- assert meta.default_connection == "default"
+ assert meta.default_connection is None
assert meta.connection_names == []
def test_connection_tracking(self) -> None:
diff --git a/tests/auth/test_service.py b/tests/auth/test_service.py
index f44f8eb8..20d1f384 100644
--- a/tests/auth/test_service.py
+++ b/tests/auth/test_service.py
@@ -10,10 +10,15 @@
from authsome.auth.models.connection import ConnectionRecord, ProviderMetadataRecord
from authsome.auth.models.enums import AuthType, ConnectionStatus, FlowType
from authsome.auth.models.provider import OAuthConfig, ProviderDefinition
-from authsome.errors import ConnectionNotFoundError, OperationNotAllowedError, RefreshFailedError
+from authsome.errors import (
+ ConnectionNotFoundError,
+ InvalidConnectionNameError,
+ OperationNotAllowedError,
+ RefreshFailedError,
+)
from authsome.identity.principal import PrincipalRole
from authsome.server.credential_repository import CredentialRepository
-from authsome.server.credential_service import CredentialService
+from authsome.server.credential_service import CredentialService, validate_login_connection_name
from authsome.server.dependencies import create_vault
from authsome.server.schemas import CredentialResolutionResponse, GlobalProviderConnectionRecord
from authsome.server.store import create_server_store
@@ -882,3 +887,104 @@ async def test_logout_of_other_local_connection_keeps_pointer(tmp_path) -> None:
assert global_connections.pointer is not None
assert global_connections.pointer.connection_name == "default"
+
+
+def _named_first_service(vault) -> CredentialService: # noqa: ANN001
+ return CredentialService(
+ credentials=_credentials(vault, identity="agent-a", principal_id="principal_1", vault_id="vault_user"),
+ providers=StaticProviders(),
+ global_connections=MemoryGlobalConnections(),
+ identity="agent-a",
+ principal_id="principal_1",
+ vault_id="vault_user",
+ )
+
+
+async def _save_named_connection(service: CredentialService, name: str) -> None:
+ await service._credentials.save_connection(
+ ConnectionRecord(
+ provider="github",
+ identity="agent-a",
+ principal_id="principal_1",
+ vault_id="vault_user",
+ connection_name=name,
+ auth_type=AuthType.OAUTH2,
+ status=ConnectionStatus.CONNECTED,
+ access_token=f"token-{name}",
+ )
+ )
+ await service._update_provider_metadata("github", name)
+
+
+@pytest.mark.asyncio
+async def test_first_connection_becomes_default(tmp_path) -> None: # noqa: ANN001
+ vault = await create_vault(tmp_path)
+ service = _named_first_service(vault)
+
+ await _save_named_connection(service, "work")
+
+ metadata = await service._credentials.get_provider_metadata("github")
+ assert metadata is not None
+ assert metadata.default_connection == "work"
+
+
+@pytest.mark.asyncio
+async def test_second_connection_leaves_default_unchanged(tmp_path) -> None: # noqa: ANN001
+ vault = await create_vault(tmp_path)
+ service = _named_first_service(vault)
+
+ await _save_named_connection(service, "work")
+ await _save_named_connection(service, "personal")
+
+ metadata = await service._credentials.get_provider_metadata("github")
+ assert metadata is not None
+ assert metadata.default_connection == "work"
+ assert set(metadata.connection_names) == {"work", "personal"}
+
+
+@pytest.mark.asyncio
+async def test_resolve_connection_name_uses_metadata_default(tmp_path) -> None: # noqa: ANN001
+ vault = await create_vault(tmp_path)
+ service = _named_first_service(vault)
+ await _save_named_connection(service, "work")
+
+ # Both an omitted name and the reserved "default" alias resolve to the
+ # provider's metadata default — no record literally named "default" exists.
+ assert await service.resolve_connection_name("github", None) == "work"
+ assert await service.resolve_connection_name("github", "default") == "work"
+ assert await service.resolve_connection_name("github", "personal") == "personal"
+
+
+@pytest.mark.asyncio
+async def test_omitted_connection_resolves_to_default_record(tmp_path) -> None: # noqa: ANN001
+ vault = await create_vault(tmp_path)
+ service = _named_first_service(vault)
+ await _save_named_connection(service, "work")
+
+ record = await service.get_connection("github")
+ assert record.connection_name == "work"
+
+
+@pytest.mark.asyncio
+async def test_legacy_default_record_still_resolves(tmp_path) -> None: # noqa: ANN001
+ vault = await create_vault(tmp_path)
+ service = _named_first_service(vault)
+
+ # Simulate a pre-existing vault whose metadata predates named-first
+ # connections: a literal "default" record plus matching metadata.
+ await _save_named_connection(service, "default")
+
+ metadata = await service._credentials.get_provider_metadata("github")
+ assert metadata is not None and metadata.default_connection == "default"
+ record = await service.get_connection("github")
+ assert record.connection_name == "default"
+
+
+def test_validate_login_connection_name_rejects_reserved_and_empty() -> None:
+ assert validate_login_connection_name(" work ", provider="github") == "work"
+ with pytest.raises(InvalidConnectionNameError):
+ validate_login_connection_name("", provider="github")
+ with pytest.raises(InvalidConnectionNameError):
+ validate_login_connection_name(" ", provider="github")
+ with pytest.raises(InvalidConnectionNameError):
+ validate_login_connection_name("default", provider="github")
diff --git a/tests/auth/test_service_provider_clients.py b/tests/auth/test_service_provider_clients.py
index d2d1dc48..2fd7171a 100644
--- a/tests/auth/test_service_provider_clients.py
+++ b/tests/auth/test_service_provider_clients.py
@@ -70,12 +70,12 @@ def _make_provider(*, flow: FlowType = FlowType.PKCE) -> ProviderDefinition:
)
-def _make_session(*, flow_type: FlowType) -> AuthSession:
+def _make_session(*, flow_type: FlowType, connection_name: str = "default") -> AuthSession:
return AuthSession(
session_id="sess_123",
provider="github",
identity="steady-wisely-boldly-0042",
- connection_name="default",
+ connection_name=connection_name,
flow_type=flow_type.value,
)
@@ -259,7 +259,7 @@ async def test_begin_login_flow_reuses_server_scopes() -> None:
scopes=["repo", "read:user"],
).model_dump_json()
service = _service(vault, identity="second-identity")
- session = _make_session(flow_type=FlowType.PKCE)
+ session = _make_session(flow_type=FlowType.PKCE, connection_name="work")
handler = mock.AsyncMock()
handlers = {FlowType.PKCE: mock.Mock(return_value=handler)}
diff --git a/tests/cli/test_login.py b/tests/cli/test_login.py
index 08dfd1b9..3179aabe 100644
--- a/tests/cli/test_login.py
+++ b/tests/cli/test_login.py
@@ -31,7 +31,7 @@ class TestLoginCommand:
def test_started_flow_returns_json(self, runner, mock_client) -> None:
mock_client.start_login.return_value = _started_session()
- result = runner.invoke(cli, ["--log-file", "", "login", "github"])
+ result = runner.invoke(cli, ["--log-file", "", "login", "github", "--connection", "work"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["provider"] == "github"
@@ -39,7 +39,7 @@ def test_started_flow_returns_json(self, runner, mock_client) -> None:
def test_completed_flow_returns_json(self, runner, mock_client) -> None:
mock_client.start_login.return_value = _completed_session()
- result = runner.invoke(cli, ["--log-file", "", "login", "openai"])
+ result = runner.invoke(cli, ["--log-file", "", "login", "openai", "--connection", "work"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["provider"] == "openai"
@@ -47,21 +47,21 @@ def test_completed_flow_returns_json(self, runner, mock_client) -> None:
def test_force_flag_still_returns_json(self, runner, mock_client) -> None:
mock_client.start_login.return_value = _started_session()
- result = runner.invoke(cli, ["--log-file", "", "login", "github", "--force"])
+ result = runner.invoke(cli, ["--log-file", "", "login", "github", "--connection", "work", "--force"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["status"] == "started"
def test_force_flag_quiet_returns_json(self, runner, mock_client) -> None:
mock_client.start_login.return_value = _started_session()
- result = runner.invoke(cli, ["--log-file", "", "login", "github", "--force", "--quiet"])
+ result = runner.invoke(cli, ["--log-file", "", "login", "github", "--connection", "work", "--force", "--quiet"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["status"] == "started"
def test_start_login_called_with_provider(self, runner, mock_client) -> None:
mock_client.start_login.return_value = _started_session()
- runner.invoke(cli, ["--log-file", "", "login", "github"])
+ runner.invoke(cli, ["--log-file", "", "login", "github", "--connection", "work"])
mock_client.start_login.assert_called_once()
kwargs = mock_client.start_login.call_args.kwargs
assert kwargs["provider"] == "github"
@@ -74,15 +74,25 @@ def test_connection_option_passed_through(self, runner, mock_client) -> None:
def test_scopes_option_parsed_as_list(self, runner, mock_client) -> None:
mock_client.start_login.return_value = _started_session()
- runner.invoke(cli, ["--log-file", "", "login", "github", "--scopes", "repo,read:user"])
+ runner.invoke(cli, ["--log-file", "", "login", "github", "--connection", "work", "--scopes", "repo,read:user"])
kwargs = mock_client.start_login.call_args.kwargs
assert kwargs["scopes"] == ["repo", "read:user"]
+ def test_login_requires_connection_in_non_interactive_context(self, runner, mock_client) -> None:
+ result = runner.invoke(cli, ["--log-file", "", "login", "github"])
+ assert result.exit_code != 0
+ mock_client.start_login.assert_not_called()
+
+ def test_login_rejects_reserved_default_connection(self, runner, mock_client) -> None:
+ result = runner.invoke(cli, ["--log-file", "", "login", "github", "--connection", "default"])
+ assert result.exit_code != 0
+ mock_client.start_login.assert_not_called()
+
def test_login_failure_exits_4(self, runner, mock_client) -> None:
from authsome.errors import ProviderNotFoundError
mock_client.start_login.side_effect = ProviderNotFoundError("nope")
- result = runner.invoke(cli, ["--log-file", "", "login", "nope"])
+ result = runner.invoke(cli, ["--log-file", "", "login", "nope", "--connection", "work"])
assert result.exit_code == 4
data = json.loads(result.output)
assert data["error"] == "ProviderNotFoundError"
diff --git a/tests/cli/test_onboard.py b/tests/cli/test_onboard.py
index 2de1817a..e54b0c93 100644
--- a/tests/cli/test_onboard.py
+++ b/tests/cli/test_onboard.py
@@ -59,7 +59,7 @@ def test_onboard_creates_identity_and_imports_key_from_dotenv(
mock_client.ensure_identity_ready.assert_called_once()
mock_client.whoami.assert_called_once()
mock_client.start_login.assert_called_once_with(
- provider="brevo", connection="default", flow="api_key", force=True
+ provider="brevo", connection="brevo", flow="api_key", force=True
)
mock_client.resume_login_session.assert_called_once_with("sess-1", api_key="test123")
data = json.loads(result.output)
diff --git a/tests/proxy/test_proxy.py b/tests/proxy/test_proxy.py
index 22410bd6..8f76727e 100644
--- a/tests/proxy/test_proxy.py
+++ b/tests/proxy/test_proxy.py
@@ -74,13 +74,15 @@ async def test_rejects_plain_http_provider_host(self, tmp_path: Path) -> None:
assert await _route(auth, "http", "api.openai.com", 80, "/v1/responses") is None
@pytest.mark.asyncio
- async def test_ignores_named_connection_without_default(self, tmp_path: Path) -> None:
+ async def test_routes_lone_named_connection_as_default(self, tmp_path: Path) -> None:
+ # The first connection saved for a provider becomes its default, so a
+ # single named connection (no literal "default" record) still routes.
auth = await _make_auth(tmp_path)
await _save_connection_record(auth, "openai", "sk-work-padded-for-regex-12", "work")
match = await _route(auth, "https", "api.openai.com", 443, "/v1/responses")
- assert match is None
+ assert match == RouteMatch(provider="openai", connection="work")
@pytest.mark.asyncio
async def test_routes_default_when_named_connection_also_exists(self, tmp_path: Path) -> None:
diff --git a/ui/src/components/dashboard/provider-detail-view.tsx b/ui/src/components/dashboard/provider-detail-view.tsx
index 695a92f8..47ca3ac8 100644
--- a/ui/src/components/dashboard/provider-detail-view.tsx
+++ b/ui/src/components/dashboard/provider-detail-view.tsx
@@ -107,7 +107,6 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
const description = data.provider.description || data.provider.metadata?.description || "";
const showsConfiguration = data.provider.auth_type !== "api_key";
const [dialogProvider, setDialogProvider] = useState(null);
- const hasDefaultConnection = data.connections.some((connection) => connection.connection_name === "default");
const dialogData = { displayName, name: data.provider.name };
const hasConnections = data.connections.length > 0 || data.principal_usage.some((g) => g.connections.length > 0);
const providerStatus = hasConnections ? "connected" : "available";
@@ -127,21 +126,10 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
- {hasDefaultConnection ? (
-
- ) : (
-
- )}
+
diff --git a/ui/src/components/dashboard/provider-views.tsx b/ui/src/components/dashboard/provider-views.tsx
index 52da4acf..6034b36e 100644
--- a/ui/src/components/dashboard/provider-views.tsx
+++ b/ui/src/components/dashboard/provider-views.tsx
@@ -337,28 +337,17 @@ function ProviderCard({
{provider.connectionCount} connection{provider.connectionCount !== 1 ? "s" : ""}
- {provider.requiresNamedLogin ? (
-
- ) : (
-
- )}
+
- ) : provider.requiresNamedLogin ? (
+ ) : (
- ) : (
-
)}
@@ -446,9 +426,11 @@ export function NamedConnectionDialog({
provider: NamedConnectionProvider | null;
}) {
const [connectionName, setConnectionName] = useState("");
+ const trimmed = connectionName.trim();
+ const isReserved = trimmed.toLowerCase() === "default";
function handleSubmit(event: FormEvent) {
- if (!connectionName.trim()) {
+ if (!trimmed || isReserved) {
event.preventDefault();
}
}
@@ -466,7 +448,9 @@ export function NamedConnectionDialog({
Connection name
- {provider?.displayName} already has a default connection.
+
+ Name this {provider?.displayName} connection so you can tell it apart from others.
+
+ {isReserved ? (
+ "default" is reserved. Choose another name.
+ ) : null}
}>Cancel
-
+
diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts
index 973c28eb..14454c7f 100644
--- a/ui/src/lib/authsome-api.ts
+++ b/ui/src/lib/authsome-api.ts
@@ -21,7 +21,6 @@ export type ProviderView = {
scopeCount: number;
connectionCount: number;
globalConnectionCount: number;
- requiresNamedLogin: boolean;
};
export type ConnectionRow = {
@@ -464,7 +463,6 @@ function providerView(
scopeCount: connections[0]?.scopes?.length || 0,
connectionCount,
globalConnectionCount: globalConnections.length,
- requiresNamedLogin: connections.some((connection) => connection.connection_name === "default"),
};
}
diff --git a/uv.lock b/uv.lock
index 9fd9bc9e..bf9b600e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -162,7 +162,7 @@ wheels = [
[[package]]
name = "authsome"
-version = "0.7.1"
+version = "0.7.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },