Appearance
plexctl login
Synopsis
plexctl login authenticates an operator against a plexsphere control plane using the RFC 8628 OAuth 2.0 Device Authorisation Grant. The CLI posts a device-code request to the resolved server, prints a verification URL plus a short user code, and polls the device-token endpoint until the operator completes approval in a browser. On success the issued bearer token is persisted to the plexctl config file at mode 0600 under the named profile (defaults to default), so subsequent CLI invocations resolve credentials without flags .
The command is the only path that writes to the plexctl config file. The persisted shape — an ~/.config/plexctl/config.json document carrying a map of named profiles plus a default pointer — is the canonical credential surface for every other subcommand .
Invocation
text
plexctl login --domain-id <uuid> [--idp-binding-id <uuid> | --idp-binding <alias>] [--profile-name <name>] [--force] [--min-remaining <duration>] [persistent flags]
plexctl login --platform [--idp-binding-id <uuid> | --idp-binding <alias>] [--profile-name <name>] [--force] [--min-remaining <duration>] [persistent flags]The command takes no positional arguments. Exactly one of --domain-id (a tenant login) or --platform (a Platform Operator login, no tenant Domain) is required; supplying both, or neither, is an input-validation error (exit 2). In tenant mode, when neither --idp-binding-id nor --idp-binding is given the server resolves the Domain's primary binding, falling back to its single active binding. Pin a specific binding either by UUID (--idp-binding-id) or by its human-friendly alias (--idp-binding, e.g. github) — the two are mutually exclusive. Pinning is required only when the Domain has two or more active bindings and no primary, which the server cannot otherwise disambiguate.
Resolution operates over the Domain's effective bindings — its own bindings plus any platform-scoped (shared) bindings usable by any Domain. A Domain that owns no binding can therefore resolve a shared binding created via platform-idp on a Domain-only login; the signed-in user joins the login Domain, not the binding's. A Domain-owned primary or sole binding still wins over a shared one.
In platform mode the device-code request carries no domain_id and must resolve a platform-shared binding. Because device-code initiation is anonymous, the server accepts the platform mode only on deployments that explicitly opted in with PLEXSPHERE_AUTH_PLATFORM_DEVICE_LOGIN=true; on an un-opted deployment initiation fails 400 Bad Request (exit 1). With an explicit --idp-binding-id or --idp-binding pin the request is sent as-is (a platform binding alias is globally unique among active platform bindings, so no Domain is needed to resolve it). With no pin the CLI discovers the candidates via the pre-auth chooser (GET /v1/auth/idp-bindings?scope=platform): a sole active binding is auto-selected; zero bindings is an actionable error; two or more are listed — label, id, and alias — before the command aborts, so a script never guesses which IdP it authenticates against. The browser approval page states that a platform-scoped authorization is being approved, and the approving browser session must itself be platform-scoped (a tenant session is refused 403 device-scope_mismatch). The minted token resolves to a Domain-independent platform principal — verify with plexctl whoami (SCOPE column platform, no Domain).
Flags
| Flag | Type | Required | Default | Description |
|---|---|---|---|---|
--domain-id | UUID | one of --domain-id/--platform | — | Domain UUID for the device-code session. When neither binding flag is set, the server resolves the Domain's primary binding, then its single active binding. Mutually exclusive with --platform. |
--platform | bool | one of --domain-id/--platform | false | Authenticate as a Platform Operator without selecting a tenant Domain. The device-code request omits domain_id and must resolve a platform-shared binding; with no binding pin the CLI discovers the candidates via GET /v1/auth/idp-bindings?scope=platform (a sole binding is auto-selected, several abort with a list to pin from). Mutually exclusive with --domain-id. |
--idp-binding-id | UUID | no | resolved from Domain / chooser | Explicit IdP binding UUID, identifying the upstream OIDC provider whose authorisation endpoint the verification URL points at. Mutually exclusive with --idp-binding. In tenant mode, a binding that belongs to a different Domain than --domain-id surfaces as 400 Bad Request; in platform mode it must name a platform-shared binding. |
--idp-binding | string | no | resolved from Domain / chooser | IdP binding alias (e.g. github), the human-friendly alternative to --idp-binding-id. Mutually exclusive with --idp-binding-id. An alias no active binding carries surfaces as 404 alias-not-found. In platform mode the alias is resolved over the platform-shared bindings (globally unique among active platform rows). |
--profile-name | string | no | default | Name to save the resolved profile under in the plexctl config file. The first profile created also becomes the file-level default pointer (subsequent invocations without --profile resolve it transparently). |
--force | bool | no | false | Run the device-code grant even when the stored session would match. Skips the pre-flight described under Session reuse. |
--min-remaining | duration | no | 5m | Minimum remaining lifetime the stored session needs to be reused, e.g. 30m. 0 accepts any session whose expiry has not passed; a negative value is an input-validation error (exit 2). With --force the pre-flight does not run, so the flag is inert. |
Persistent flags inherited from root
plexctl login reads --server from the persistent flag bag to resolve the API URL it posts the device-code request against. The --token-file flag is inert here (login is the surface that produces the token). See plexctl.md for the canonical list of persistent flags (--server, --profile, --token-file, --output, --yes, --reveal-secrets).
Session reuse
Unless --force is set, the command inspects the profile slot it is about to overwrite (--profile-name, default when unset) before it requests a device code. It keeps the stored session and exits 0 without a browser round trip when all five conditions hold:
- The slot holds an entry that carries a token. A token supplied via
PLEXSPHERE_TOKENor--token-fileis never considered; login does not overwrite those sources. - The recorded
serverequals the resolved server (a trailing/is ignored on both sides). - The recorded binding pin matches the requested one. An
--idp-binding-idpin must equal the recordedidp_binding_id, an--idp-bindingpin must equal the recordedidp_binding_alias, so an alias pin never matches a session recorded by id and vice versa. A request with no pin accepts whatever binding the entry records. In platform mode the binding chooser runs first (see below), so the pin compared here is the one the chooser resolved. - The recorded
expires_atlies at least--min-remainingahead of now. An entry withoutexpires_atnever qualifies;--min-remaining 0accepts any session whose expiry has not passed. - One
GET /v1/auth/whoamiprobe with the stored token answers200with the requested scope:--domain-id <uuid>needsprincipal_scopedomainand an equaldomain_id,--platformneedsprincipal_scopeplatform.
Anything the pre-flight cannot confirm runs the grant exactly as it does without a stored session: a transport failure, a 401, any other non-200 answer, an unparseable body, an absent principal_scope, a missing config file, a missing profile entry. The one exception is a config file whose mode is wider than 0600; that stays the hard exit 3 the file-mode contract pins, and no grant follows it.
--platform without --idp-binding-id / --idp-binding resolves the platform-shared binding through GET /v1/auth/idp-bindings?scope=platform before the pre-flight runs, so a deployment with two or more active platform bindings still aborts and asks for a pin instead of silently keeping a session minted against one of them. Tenant mode never reaches that surface and so pays nothing for the ordering.
On the reuse path stdout reports Kept the existing session for profile "default" (subject <principal-uuid>). Pass --force to re-authenticate. — naming the principal the probe resolved is the signal that this invocation did not authenticate, which matters on a config file shared between operators. The config file is not rewritten. The single exception is an empty file-level default pointer, which is set to the reused profile name so a following flagless call resolves the same session. --force bypasses the pre-flight and always requests a device code.
The grant path persists the binding pin it carried (idp_binding_id or idp_binding_alias) and the session's absolute expires_at alongside the token. They are the evidence a later pre-flight reads.
Exit codes
plexctl collapses every failure into the taxonomy below; the single source of truth is exitCodeFor in ../../../cmd/plexctl/app.go.
| Code | Reachable | Meaning |
|---|---|---|
0 | yes | The device-code session completed and the token was persisted, or the pre-flight kept the stored session and requested no device code (see Session reuse). |
1 | yes | Runtime / API error: transport failure, malformed device-code response, RFC 8628 expired_token / access_denied / invalid_grant, a 400 multiple-bindings response (the Domain has two or more active bindings and no primary — pin one with --idp-binding-id or --idp-binding, or mark a primary), a 400 domain-mismatch (the supplied --idp-binding-id belongs to a different Domain), or a session-deadline timeout. |
2 | yes | Flag-parse / misconfiguration: neither (or both) of --domain-id / --platform, malformed UUID, unknown --output format. |
3 | yes | No server resolved (--server not set, PLEXSPHERE_URL empty, no profile) — login still needs to know where to post the device-code request. |
4 | rare | Permission denied / not found — surfaces when the configured IdP rejects the operator at approval time (HTTP 403), when the Domain has no active IdP binding to resolve (404 binding_not_found), or when --idp-binding names an alias no active binding carries (404 alias-not-found). |
64 | no | Not reachable — login is fully implemented and never returns *NotImplementedError. |
Examples
Happy path against the kind dev stack
shell
export PLEXSPHERE_URL="${PLEXSPHERE_URL:-https://localhost:8080}"
plexctl login \
--server "${PLEXSPHERE_URL}" \
--domain-id 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0The CLI prints the verification URL and user code, then blocks on the poll loop. After approval in the browser, stdout reports Logged in. Saved profile "default". and a subsequent plexctl whoami succeeds without further flags. The server resolves the Domain's single active IdP binding, so no binding id is needed.
Pin a binding when the Domain has several (exit 1 otherwise)
shell
plexctl login \
--server "${PLEXSPHERE_URL}" \
--domain-id 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--idp-binding-id 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b0When a Domain has two or more active IdP bindings and no primary the server returns 400 multiple-bindings (exit 1) until a binding is pinned. The binding must belong to --domain-id, or the request fails with 400 domain-mismatch. Marking one binding as the Domain's primary (plexctl domain-idp update --id <id> --primary) removes the need to pin on every login.
Pin a binding by alias
shell
plexctl login \
--server "${PLEXSPHERE_URL}" \
--domain-id 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--idp-binding github--idp-binding resolves the binding whose alias is github within the Domain — the human-friendly alternative to --idp-binding-id. The two flags are mutually exclusive; an unknown alias surfaces as 404 alias-not-found (exit 4).
Sign in as a Platform Operator (no tenant Domain)
shell
plexctl login \
--server "${PLEXSPHERE_URL}" \
--platformWith a single active platform-shared binding the CLI auto-selects it, posts a device-code request with no domain_id, and prints the verification URL as usual. The browser approval page states that a platform-scoped authorization is being approved and requires a platform-scoped browser session. After approval, plexctl whoami reports SCOPE platform with no Domain, and platform-admin surfaces such as plexctl platform-idp list are reachable without ever selecting a tenant Domain. Per-Domain commands fail closed server-side for the platform token (403, exit 4) unless the operator also holds relations on the addressed Domain. With several active platform bindings the CLI lists them and aborts; pin one via --idp-binding-id <uuid> or --idp-binding <alias>.
Expired session (exit 1)
shell
plexctl login \
--server "${PLEXSPHERE_URL}" \
--domain-id 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0
# operator does not approve before the session deadline
# stderr: plexctl: device-code session expired before approval; restart with `plexctl login`
echo $? # 1The polling loop short-circuits once the local clock passes the device-code expires_in; the typed auth.ErrSessionExpired error maps to exit 1.
JSON summary for CI scripts
shell
plexctl login \
--server "${PLEXSPHERE_URL}" \
--domain-id 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--output jsonIn json / yaml mode the verification prompt still goes to stdout (it is the primary success-path output), but a structured summary follows on completion:
json
{
"profile": "default",
"server": "https://localhost:8080",
"token_type": "Bearer",
"expires_in": 3600,
"has_token": true,
"reused": false
}The bearer string itself is intentionally omitted — no secret ever crosses an unguarded surface; the persisted config file is the canonical store.
reused tells the scraper which of the two success paths produced the session. On the reuse path it is true, expires_in counts the whole seconds the stored session still has, and token_type is Bearer:
json
{
"profile": "default",
"server": "https://localhost:8080",
"token_type": "Bearer",
"expires_in": 2874,
"has_token": true,
"reused": true
}Cross-references
../../../api/openapi/plexsphere-v1.yaml—PostAuthDeviceCode(/v1/auth/device-code) andPostAuthDeviceToken(/v1/auth/device-token) operation definitions and theDeviceCodeResponse/ServiceTokenResponseschemas.../../contexts/identity/idp.md— bounded-context reference for the IdP binding aggregate the--domain-id(and optional--idp-binding-id/--idp-binding) flags address, including the alias and primary fields.../../../cmd/plexctl/commands/login.go— source of truth for the cobra command, flag wiring, and the device-code request shape.../../../cmd/plexctl/internal/auth/devicecode.go— RFC 8628 polling loop (authorization_pending,slow_down, terminal-error mapping, session-expiry semantics).