Skip to content
Open
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
33 changes: 21 additions & 12 deletions docs/site/guides/multiple-connections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<Note>
`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).
</Note>

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 <name>` to `login`. The name is arbitrary; pick what reads well.
Pass `--connection <name>` 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.
Expand All @@ -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

Expand All @@ -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
```

Expand Down
9 changes: 8 additions & 1 deletion src/authsome/auth/models/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 40 additions & 9 deletions src/authsome/cli/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand All @@ -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)
Expand Down Expand Up @@ -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,
)
Expand All @@ -155,28 +161,53 @@ 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,
"results": results,
}


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),
Expand All @@ -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
Expand Down Expand Up @@ -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",
)
Expand Down
7 changes: 7 additions & 0 deletions src/authsome/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
48 changes: 41 additions & 7 deletions src/authsome/server/credential_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,6 +40,7 @@
from authsome.errors import (
ConnectionNotFoundError,
CredentialMissingError,
InvalidConnectionNameError,
InvalidProviderSchemaError,
OperationNotAllowedError,
ProviderNotFoundError,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -384,23 +408,31 @@ 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,
provider: str,
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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions src/authsome/server/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading
Loading