Uplink Docs
Identity Provider Contract
Documentation

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 optional iat and nbf semantics;
  • kind, userId, and a stable audit sub.

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:

PathRequestSuccess
/api/sso/token{ "code", "redirectUri" }Human session
/api/token/refresh{ "refreshToken" }Rotated session
/api/account-tokensBearer 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.

Search across 15 pages.
↑↓ navigate openEsc close