One-time launch-code authentication
Agor can accept a generic external launch handoff when another trusted app has
already authenticated the user. The browser carries only an opaque, short-lived
launch_code; the daemon exchanges that code over a server-to-server
backchannel, verifies the returned assertion, maps a local user, and issues the
same runtime access and refresh tokens used by normal login.
Flow
- The external launch provider opens the runtime UI with
/ui/?launch_code=<opaque-code>. - The UI calls
POST /auth/launchonce with{ "launchCode": "..." }. - The daemon posts the code to the configured exchange endpoint with its runtime audience, instance ID, and optional service credential.
- The exchange endpoint returns a signed assertion for the authenticated subject.
- The daemon verifies issuer, audience, expiration, subject, and the configured
instance ID; then it maps or creates a local user by
(provider, issuer, subject). - The daemon returns normal runtime auth tokens. The UI stores those tokens and
removes
launch_codefrom the URL withreplaceState.
Configuration
external_launch:
enabled: true
exchange_url: https://launch.example.com/runtime/exchange
issuer: https://launch.example.com
audience: agor-runtime:my-instance
instance_id: my-instance
# Production: configure exactly one assertion verification method.
jwks_url: https://launch.example.com/.well-known/jwks.json
# Optional daemon-to-provider bearer credential. Prefer env vars for secrets.
service_credential_env: AGOR_EXTERNAL_LAUNCH_SERVICE_TOKEN
# Optional: allow role claims above member. Defaults to false.
allow_admin_roles: false
# Optional backchannel timeout, 1-120000 ms. Defaults to 10000.
request_timeout_ms: 10000
# Optional non-empty allow-list. HS* is symmetric-only; RS*/PS*/ES* is
# asymmetric-only. Defaults to HS256 for a dev secret and RS256 otherwise.
algorithms: [RS256]
# Optional: primary action shown when launch sign-in cannot continue.
# Must be http:// or https://.
login_redirect_url: https://workspace.example.com/open
# Optional host-bound launch for a single daemon that serves several hosts.
# When enabled, the daemon forwards the normalized inbound browser Host to the
# exchange endpoint as an opaque `request_host`, letting the issuer bind a code
# to the exact route the browser entered.
forward_request_host: false
# Header the daemon reads the browser Host from. The trusted proxy / edge owns
# host normalization and must overwrite it. Default: host.
trusted_host_header: host
# Query param appended to login_redirect_url carrying the current host as an
# opaque return context for direct-host entry. Default: return_host.
return_host_param: return_hostExternal user and role authority
External launch authentication does not disable Agor user administration by itself. A deployment whose launch provider is also authoritative for user lifecycle, profile fields, and roles must opt into the identity contract:
identity:
user_lifecycle: external
role_authority: claims
local_auth: disabled
external:
provider: external_launch
provisioning: jitWith this profile, verified launches may still create a missing local user and synchronize JWT-owned email, name, avatar, execution-home key, and role. Those fields update when the next external launch succeeds; the daemon does not call the provider on every request. User and administrator calls through REST, Socket.IO, CLI, and MCP cannot create or delete users or edit those fields. Agor-owned preferences, onboarding state, AI-provider credentials and defaults, environment variables, personal API keys, and MCP selections/OAuth grants stay editable.
The trusted (tenant, provider, issuer, subject) binding is stored in a
tenant-owned relation with database uniqueness enforcement. PostgreSQL JIT
projection is one tenant-scoped transaction and serializes the same subject
across replicas. Execution-home keys are also unique per tenant, preventing two
users from sharing one delegated credential context.
The role claim is required and exact in this mode. Administrative claims still
require external_launch.allow_admin_roles; a superadmin claim also requires
execution.allow_superadmin. A disabled role fails authentication rather than
being silently downgraded.
Omitting identity preserves normal local user, role, and password authority.
The current contract does not synchronize external deactivation or revoke
already-issued Agor credentials: the external provider can prevent a new
launch, while cleanup and revocation remain a separate lifecycle concern.
Before enabling this profile, apply the external-identity migration and upgrade every daemon replica. The migration fails closed if legacy external bindings or non-null execution-home keys conflict. New daemons can bind a legacy JSON link on the next successful launch, but old daemons do not maintain the new binding relation.
For local development only, a symmetric assertion secret can be used:
external_launch:
enabled: true
exchange_url: http://localhost:4000/exchange
issuer: http://localhost:4000
audience: agor-runtime:dev
dev_shared_secret_env: AGOR_EXTERNAL_LAUNCH_SHARED_SECRETFailure behavior and login redirects
When a launch code is missing, expired, already used, invalid, or cannot be exchanged because of a non-transient authentication failure, the UI removes the one-time code from the URL and shows a clear failure message.
If external_launch.login_redirect_url is configured, the unauthenticated
screen makes Return to workspace the primary action so the user can open a
fresh launch link from the external workspace. The button appends a return_to
query parameter containing the current Agor path, allowing the launcher to
preserve deep links such as /ui/s/<session>/ when it issues a fresh
launch_code. Local username/password login is still available as a secondary
fallback unless external identity authority disables it.
If login_redirect_url is omitted, the normal local login screen is unchanged.
Direct-host entry
A browser can also navigate straight to a workspace host. If a valid session
already exists for that host it opens immediately with no new code. Runtime
sessions are stored in origin-scoped localStorage — never a Domain-wide
cookie — so a session on one host is never sent to another. If there is no
host-local session, the unauthenticated screen sends the browser to
login_redirect_url with return_to (the current relative Agor route) and, when
return_host_param is configured, the current host. The issuer allow-lists that
host, mints a fresh code and returns to the same host for the normal exchange.
Agor only ever redirects to the operator-configured URL, so it adds no open
redirect.
Exchange contract
The daemon sends a JSON POST to exchange_url:
{
"launch_code": "opaque-one-time-code",
"audience": "agor-runtime:my-instance",
"instance_id": "my-instance",
"request_host": "primary.workspace.example.com"
}request_host is included only when forward_request_host is enabled; it is the
normalized inbound Host read from the trusted trusted_host_header (default
Host), never a client-supplied body field or arbitrary forwarded header.
audience and instance_id are compatibility echoes — a correct issuer derives
authority from its own records and the authenticated exchange credential.
If service_credential or service_credential_env is configured, the daemon
also sends Authorization: Bearer <credential>. This server-to-server exchange
credential is never returned to the browser, exposed in the public /health
settings, or written to logs.
Production verification fails closed: the none algorithm is always rejected;
jwks_url verification defaults to an RS256 allow-list, while a static
public_key derives a compatible default from its key type and EC curve. An
explicit allow-list must match that key. A missing issuer, audience, exp,
sub, or required tenant claim creates no session.
The exchange endpoint should consume the launch code exactly once and return:
{
"assertion": "<signed JWT>"
}Required assertion claims:
iss: expected issuersub: stable subject at that issueraud: expected runtime audienceexp: short expiration time
Optional claims:
email,name,avatarorpicturerole:viewerormemberby default;admin/superadminonly whenallow_admin_rolesis explicitly enabledprovider: stable provider label used in local identity mappingjtiornonce: accepted for audit/correlation; one-time replay prevention remains the exchange endpoint’s responsibility
Required when external_launch.instance_id is configured:
instance_idorruntime_instance_id: must match configuredinstance_id
Compatibility and upgrade notes
- Non-RS256 JWKS signing must be declared before upgrading.
jwks_urlverification defaults to anRS256-only allow-list and refuses HS* algorithms. Staticpublic_keyverification instead derivesRS256for RSA or the matching ES algorithm for a supported EC curve; an explicitalgorithmslist must be compatible with the key. If a JWKS issuer signs with another asymmetric algorithm (RS384,ES256,PS256, …), setalgorithmsexplicitly before upgrading. login_redirect_urldeployments begin receiving a return-host parameter. Whenlogin_redirect_urlis enabled, direct-host entry appends the configuredreturn_host_param(defaultreturn_host) to that URL. Existing deployments are therefore not unchanged: the issuer’s launch-init endpoint will start receiving this query parameter and should tolerate and/or consume it. Leavelogin_redirect_urlunset if the issuer should not receive it.
Security notes
- Put only an opaque, short-lived, one-time code in the browser URL.
- Do not put runtime bearer tokens or external provider tokens in URLs.
- The daemon-to-provider exchange should require HTTPS and an authenticated backchannel in production.
login_redirect_urlmust be an HTTP(S) URL; malformed URLs and schemes such asjavascript:are rejected during config loading.- Assertions should be audience-bound to the runtime, instance-bound when
instance_idis configured, and expire quickly. - Configure exactly one assertion verification method (
jwks_url,public_key, or dev-onlydev_shared_secret). - Local users are mapped by stable external identity
(provider, issuer, subject). A matching email alone never merges identities.