Identity Provider Contract
A self-hosted edge can delegate connector identity, visitor sign-in, or both to a service you operate. The service may federate OIDC, SAML, LDAP, or any other upstream system, but its edge-facing interface is the small versioned contract below. The management console is not part of this protocol.
The operator pins the provider authority with UPLINK_IDENTITY_PROVIDER_URL (or
--identity-provider during setup). The edge fetches discovery at boot and verifies
JWTs offline against the advertised JWKS. It never follows a token-controlled jku
or chooses trust from an unverified claim.
Discovery
Serve this document from:
GET /.well-known/opentunnels-identity
Example:
{
"protocolVersion": 1,
"issuer": "https://identity.example.com",
"jwks": "/.well-known/jwks.json",
"visitorSso": {
"authorizationUrl": "https://accounts.example.com/authorize"
}
}
protocolVersion must be 1. The pinned provider authority is an HTTP(S) origin,
not a URL with a path, query, or fragment. issuer is the exact JWT iss value
and must not end in / or contain a query or fragment. jwks is an absolute path
on the pinned provider authority. authorizationUrl, which a browser opens, is a
complete URL and must not contain a fragment.
All remote authorities and browser URLs must use HTTPS. Plain HTTP is accepted only
for loopback development. Discovery may be cached briefly. Identity-authenticated
connector mode and the setup wizard fail closed if discovery is unavailable or
invalid. An API-key-authenticated edge can start without its optional provider;
visitor identity is disabled until the provider is healthy and the edge restarts.
Omit visitorSso when the edge should verify connector tokens but not offer visitor
sign-in.
Signing keys and identity tokens
The JWKS response uses the standard { "keys": [...] } shape. Version 1 accepts
Ed25519 public keys (kty: "OKP", crv: "Ed25519") with a stable kid. JWTs are
compact JWS values with alg: "EdDSA"; the edge selects the published key by kid.
Every identity token must contain:
iss: the exact discovered issuer;aud:"uplink"or an array containing it;- numeric
exp, with normal optionaliatandnbfsemantics; kind,userId, and a stable auditsub.
A human/visitor token has this additional shape:
{
"kind": "user",
"userId": "usr_123",
"sub": "user:usr_123",
"email": "person@example.com",
"accounts": ["acct_personal", "acct_team"]
}
accounts must contain at least one account. Include a normalized email when
email allowlists are supported.
A connector token is scoped to exactly one owner account:
{
"kind": "connector",
"userId": "usr_123",
"sub": "user:usr_123",
"email": "person@example.com",
"account": "acct_team",
"namespace": "team7k2",
"credentialScope": "interactive",
"cnf": { "device_key": "<hex Ed25519 public key>" }
}
account, namespace, and cnf.device_key are mandatory for connector admission.
The connector proves possession of that key during tunnel registration, so copying
the bearer token to another device is insufficient. credentialScope is
interactive or device; unattended device tokens normally omit email.
Keep identity tokens short-lived. JWKS keys are cached for five minutes and are
refetched on an unknown kid; publish old and new keys together for at least the
maximum token lifetime plus cache overlap during rotation.
Fixed v1 endpoint requests
All POST endpoints consume and return JSON. A successful session response contains
token and may include refreshToken, expiresAt, and refreshExpiresAt as Unix
seconds. Interactive login responses also include user, accounts, and
defaultAccountId. Error responses should use { "error": "message" } or
{ "message": "message" }; use 401 only when a credential is definitively
invalid or expired, 429 for throttling, and 5xx for retryable provider failure.
Server-to-server routes are fixed by protocol version rather than repeated in discovery:
| Path | Request | Success |
|---|---|---|
/api/sso/token | { "code", "redirectUri" } | Human session |
/api/token/refresh | { "refreshToken" } | Rotated session |
/api/account-tokens | Bearer human token plus { "accountId" } | Connector session |
/api/login/request | { "email" } | Any 2xx JSON body |
/api/login/verify | { "email", "code", "devicePublicKey"? } | Human session |
/api/cli-login/start | { "devicePublicKey" } | { "verificationUrl", "pollToken", "expiresAt", "intervalSeconds" } |
/api/cli-login/poll | { "pollToken" } | 202 { "status": "pending" } or a human session |
/api/device-token/exchange | { "deviceToken", "devicePublicKey" } | Connector session |
The edge uses only /api/sso/token, and /api/token/refresh when an SSO response
includes a refresh token. The remaining routes provide integrated uplink login
flows. A minimal provider may instead mint a connector JWT through its own tooling;
the connector can import it with uplink login --token ... --auth <provider>.
For visitor SSO, Uplink opens authorizationUrl with redirect_uri and state
query parameters. After authenticating, redirect to that exact URI with one-time
code and unchanged state parameters. The edge then posts the code and exact
callback URI to /api/sso/token. Codes must be short-lived, single-use, and bound to the
redirect URI.
Configure and validate
Provider-backed connector admission:
docker compose run --rm setup \
--connector-auth identity \
--identity-provider https://identity.example.com
uplink login --auth https://identity.example.com
uplink edge add edge.example.com \
--connector-auth identity \
--identity-provider https://identity.example.com
API-key connector admission with provider-backed visitor SSO:
docker compose run --rm setup \
--identity-provider https://identity.example.com
uplink edge add edge.example.com \
--api-key <uek-key> \
--identity-provider https://identity.example.com
Validate discovery, JWKS, DNS, TLS, and edge health together:
docker compose run --rm operator doctor \
--domain edge.example.com \
--identity-provider https://identity.example.com
The hosted management API publishes this same contract, so the shipped clients and edges exercise the public provider interface rather than a private hosted-only path.
If a provider later adds or removes visitorSso, refresh the client-side capability
snapshot with uplink edge refresh edge.example.com. The edge itself re-discovers
the provider on every startup.