RookOne
Running your own relay

Federate your identity provider

Federate your Okta, Entra or ADFS to the relay's optional identity broker, so an administrator signs in through your own provider before minting an agent enrollment grant.

Your relay can be administered by someone who signed in through your own Okta, Entra or ADFS, and that person can mint the enrollment grant a new agent needs. This page is the whole path, in the order it has to happen.

Two things to understand before you start, because they explain most of the surprises.

The relay never talks to your identity provider. It verifies bearer tokens from exactly one OpenID authority, checking their issuer, audience, authorized party, deployment, tenant, roles and scopes on every request. The optional broker in compose.logto.yaml is what federates to your provider and issues those tokens. If you already run an OpenID provider that can be made to emit the four claims in the table below, point the relay's ROOKONE_RELAY_EXTERNAL_AUTHORITY_* values at it and skip this page entirely.

Signing in is not being allowed in. The first person through a new federation arrives with no roles and can do nothing at all. Granting the role is step 7, and it is deliberately yours to do rather than your identity provider's.

Before you start

Run these commands from your extracted release bundle. The broker layer ships inside it: compose.logto.yaml and the config/identity-broker/ one-shots it mounts are staged into the signed tarball alongside the ROOKONE_RELAY_IDENTITY_BROKER_* settings in release.env.example, and the bundle fails verification if any of them is missing. You need nothing from a checkout of the relay repository to complete this page.

Have the broker layer running. Inside a bundle the release compose file is named compose.yaml:

docker compose -f compose.yaml -f compose.logto.yaml up -d

From a checkout of the repository the same layer is compose.release.yaml plus compose.logto.yaml; the two are the same bytes.

Its settings live in your release environment file and its four secrets are files in your secrets directory; config/identity-broker/README.md lists every one of them. Four of those settings are worth checking now:

  • ROOKONE_RELAY_IDENTITY_BROKER_ENDPOINT — the broker's public URL. Your users and your identity provider both reach it. It must be https: the relay refuses a non-https issuer or key-set URL when it builds its configuration, before any token exists.
  • ROOKONE_RELAY_IDENTITY_BROKER_ADMIN_ENDPOINT — the administration URL. The commands below use it for one thing only: minting a management token.
  • ROOKONE_RELAY_IDENTITY_BROKER_CA_FILE — the certificate authority behind those two URLs. Every curl below passes it.
  • ROOKONE_RELAY_IDENTITY_BROKER_CLIENT_ID — the application your operators sign in through. You cannot know its value yet: you create that application in step 1, and nothing creates it for you. Put any non-empty placeholder here for this first bring-up. Until it holds the real id the relay refuses every token, which is the correct behaviour and not a fault.

The examples use a shell variable for each of the first three, plus SECRETS for your secrets directory:

ENDPOINT=https://identity.acme.example:8444
ADMIN=https://identity.acme.example:8445
CA=/srv/rookone-relay/state/tls/identity-broker-ca.pem
SECRETS=/srv/rookone-relay/secrets

A management token

Several steps need one. This is the pairing that works, and it is worth pasting rather than reconstructing from memory: the m-default application, at the administration endpoint, for the default tenant's management API.

TOKEN=$(curl -sS --cacert "$CA" \
  --basic -u "m-default:$(cat "$SECRETS/identity-broker-management-secret")" \
  -X POST "$ADMIN/oidc/token" \
  -d grant_type=client_credentials \
  --data-urlencode 'resource=https://default.logto.app/api' \
  -d scope=all | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')

The near-miss is m-admin on the public endpoint, which answers 400 invalid_client, and its quieter cousin m-admin on the administration endpoint, which succeeds and returns a token for a different tenant that then fails validation several steps later. Everything on this page lives in the default tenant.

1. Register the application your operators sign in through

Nothing creates this for you. The layer's one-shot creates the API resource, the four roles, the enrollment scope, the sign-in policy, the claim script and — if you supplied a federation document — the connection. It never creates an application, because which application your operators sign in through is your decision, not the layer's.

It carries more weight than it looks. The claim script sets a token's azp to the id of the application that asked for the token, and the relay refuses any token whose azp is not exactly ROOKONE_RELAY_IDENTITY_BROKER_CLIENT_ID. The application you sign in through and the id in that setting have to be the same one.

curl -sS --cacert "$CA" -X POST "$ENDPOINT/api/applications" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
        "name": "RookOne relay operators",
        "type": "SPA",
        "oidcClientMetadata": {
          "redirectUris": ["http://localhost:9999/callback"],
          "postLogoutRedirectUris": []
        }
      }'

The redirect address is wherever you will catch the authorization code in step 8. A loopback address on the operator's own machine is the simplest choice, and nothing has to be listening on it as long as whoever signs in can read the code out of their browser's address bar.

Take the id from the response, put it in ROOKONE_RELAY_IDENTITY_BROKER_CLIENT_ID, and bring the layer up again. The relay reads its authority configuration once, when it starts:

docker compose -f compose.release.yaml -f compose.logto.yaml up -d

2. Create the connection first

The two URLs your identity provider needs both contain the connection's id, so the connection has to exist before you can configure anything on your side.

Write a federation document to $SECRETS/identity-broker-sso-connection.json. For a first pass it needs only three fields:

{
  "providerName": "SAML",
  "connectorName": "acme-okta",
  "domains": ["acme.example"]
}

domains is what makes the broker's sign-in page send someone@acme.example to your provider instead of asking them for a password. providerName may also be OIDC, Okta, AzureAD, AzureAdOidc or GoogleWorkspace; this page uses generic SAML, which is what the worked example below was measured against.

Apply it:

docker compose -f compose.release.yaml -f compose.logto.yaml up logto-init

That one-shot is idempotent from end to end — it reads current state and writes only the difference — so you will run it again in step 4 and can re-run it after any partial failure.

3. Read the connection's two URLs

curl -sS --cacert "$CA" -H "Authorization: Bearer $TOKEN" "$ENDPOINT/api/sso-connectors"

Take the id of the connection you just created, and build:

Your provider calls itValue
Single sign-on URL, ACS URL, Reply URL$ENDPOINT/api/authn/single-sign-on/saml/<id>
Audience URI, SP Entity ID, Identifier$ENDPOINT/enterprise-sso/<id>

Both are on the public endpoint, not the administration one.

4. Create the application in your identity provider

This one is in your provider, and is a different object from the broker application in step 1: this is the SAML side of the federation, that is the OpenID client your operators sign in through. The worked example is Okta, from the run that produced this page. Entra and ADFS want the same three values under different names.

  1. Create a SAML 2.0 application.
  2. Single sign-on URL — the ACS URL from step 3.
  3. Audience URI (SP Entity ID) — the audience from step 3.
  4. Name ID format — EmailAddress. Application username — Email. The broker maps the assertion's nameID to the user's identifier and reads email and name as attributes; an opaque or non-email name id will federate but will not give you a person you can recognise in a user list.
  5. Assign the people who should be able to administer the relay. Assignment is your provider's decision and the relay never sees it — but an unassigned user cannot even reach the broker.
  6. Copy the application's metadata URL.

Then hand that metadata to the connection by extending the same federation document with a config object, and re-running the one-shot:

{
  "providerName": "SAML",
  "connectorName": "acme-okta",
  "domains": ["acme.example"],
  "config": {
    "metadataUrl": "https://acme.okta.com/app/exk.../sso/saml/metadata"
  }
}
docker compose -f compose.release.yaml -f compose.logto.yaml up logto-init

config also accepts the metadata document inline as {"metadata": "<?xml …"}, or the three values pulled out by hand as entityId, signInEndpoint and x509Certificate. The measured run passed the contents of the metadata URL; the broker parsed the entity id and sign-in endpoint out of it.

5. Turn single sign-on on

This step is nobody's default. A freshly seeded broker has singleSignOnEnabled false, and the one-shot does not change it — it sets the multi-factor policy and nothing else on the sign-in experience. A correct federation behind a false flag is simply not offered to anyone, which reads like a broken connection and is not one.

curl -sS --cacert "$CA" -H "Authorization: Bearer $TOKEN" "$ENDPOINT/api/sign-in-exp"

curl -sS --cacert "$CA" -X PATCH "$ENDPOINT/api/sign-in-exp" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"singleSignOnEnabled": true}'

The document the sign-in page itself reads is public, and reading it back is the honest check that the flag took:

curl -sS --cacert "$CA" "$ENDPOINT/api/.well-known/sign-in-exp"

6. Sign in once, and expect to be able to do nothing

Open the broker's sign-in page — the authorization URL in step 8 is how you reach it — choose single sign-on, and enter an email address in the domain you registered. You should be redirected to your own provider, satisfy whatever policy it enforces — including its multi-factor requirement, which is the customer's to set and which the relay never sees — and come back signed in.

That sign-in creates a broker user just in time, with no roles. A token minted for that user is rejected by the relay, because the relay requires a non-empty roles array. This is the system working: your provider has answered who this person is; it has not been allowed to answer what they may do on this relay.

7. Grant the role

curl -sS --cacert "$CA" -H "Authorization: Bearer $TOKEN" "$ENDPOINT/api/users"
curl -sS --cacert "$CA" -H "Authorization: Bearer $TOKEN" "$ENDPOINT/api/roles"

curl -sS --cacert "$CA" -X POST "$ENDPOINT/api/users/<user-id>/roles" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"roleIds": ["<admin-role-id>"]}'

The four roles are admin, auditor, automation and member, and they are the only names the relay accepts. Only admin and automation carry the enrollment:grant scope, so an auditor's token provably cannot mint a grant.

If this relay serves more than one tenant, also pin the person's tenant. The body wraps the value in customData, and the response hands it back unwrapped — which is the easiest way to end up sending the wrong shape. Sent flat, the broker answers 400 guard.invalid_input and stores nothing.

curl -sS --cacert "$CA" -X PATCH "$ENDPOINT/api/users/<user-id>/custom-data" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"customData": {"rookone_tenant_id": "acme"}}'

Without it the person inherits the deployment's default tenant.

8. Mint the first enrollment grant from that session

The token comes from the application you registered in step 1, through an ordinary authorization-code exchange, and the request has to name the relay's audience as its resource. That last part is not a detail: without it the broker issues an opaque token — not a JWT at all — carrying neither the audience nor the resource's scopes, and the relay cannot read it.

Build a one-time verifier and its challenge:

AUDIENCE=https://relay.acme.example/api
CLIENT=<the application id from step 1>
VERIFIER=$(python3 -c 'import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("="))')
CHALLENGE=$(python3 -c "import base64, hashlib; print(base64.urlsafe_b64encode(hashlib.sha256('$VERIFIER'.encode()).digest()).decode().rstrip('='))")

Open the authorization URL in a browser, sign in through single sign-on, and read the code out of the address bar the redirect lands on:

echo "$ENDPOINT/oidc/auth?client_id=$CLIENT&redirect_uri=http://localhost:9999/callback&response_type=code&scope=openid%20enrollment:grant&resource=$AUDIENCE&code_challenge=$CHALLENGE&code_challenge_method=S256&state=first-grant"

Exchange it, passing the same resource:

curl -sS --cacert "$CA" -X POST "$ENDPOINT/oidc/token" \
  -d grant_type=authorization_code \
  -d 'code=<the code from the address bar>' \
  -d 'redirect_uri=http://localhost:9999/callback' \
  -d "client_id=$CLIENT" \
  -d "code_verifier=$VERIFIER" \
  --data-urlencode "resource=$AUDIENCE"

The access token that comes back carries roles: ["admin"] and scope: "enrollment:grant", and the relay's administration API will accept it. If what you got has no dots in it, it is the opaque kind and the resource went missing.

curl -sS -X POST https://relay.acme.example/api/v1/admin/enrollment/grants \
  -H "Authorization: Bearer <the access token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "schema_version": "rookone.relay.admin-enrollment-grant/v1",
        "deployment_id": "acme-production",
        "tenant_id": "acme",
        "agent_id": "agent-1",
        "ttl_seconds": 3600,
        "capabilities": ["http-relay"]
      }'

deployment_id and tenant_id must match the token's own binding as well as the relay's — an operator cannot mint a grant into a deployment or tenant they were not issued for, even with the right scope.

When omitted, agent_number defaults to agent_id. Public agent numbers allow letters, digits, _ and - and are at most 128 characters. If an internal agent_id contains . or :, include a separate transport-safe "agent_number" in the request. The relay validates this before persisting the single-use grant. A revoked id may later enroll again with a new grant and fresh keys; the old credentials remain revoked.

Hand the returned enrollment_code to the agent operator, who registers with that one value. It carries the deployment, tenant, agent id, public number, capabilities, and single-use grant; the client verifies those bindings before redemption. From here the flow is the ordinary enrollment path.

The example intentionally grants only http-relay; keep that baseline for an ordinary agent. For a dedicated enrolled identity that operates a host's NATS leaf uplink, an admin or automation operator may instead include nats-relay alongside http-relay in that identity's enrollment grant. That capability authorizes the enrolled principal to call POST /api/v1/nats/issue-leaf-creds and obtain relay-uplink/v1 credentials for residents that separately authorized that machine. The relay pre-creates their filtered consumers; the uplink cannot create consumers or publish application traffic. Do not add it to general agent grants. The enrollment code carries the grant response's capabilities unchanged into the identity's signed enrollment proof; the API key and later access JWT inherit that same capability set. The operator's SSO role authorizes creating the grant, but does not add nats-relay implicitly.

What the relay checks, and where each part comes from

ClaimComes fromRule
issthe broker's issuerexact match with …_EXTERNAL_AUTHORITY_ISSUER, https only
audthe API resource indicatorequals …_AUDIENCE, or is a member of it
azpthe claim scriptnon-empty, equals …_CLIENT_ID
rookone_deployment_idthe claim script, from bundle settingsnon-empty, equals …_DEPLOYMENT_ID
rookone_tenant_idthe claim script, per-user override or bundle defaultnon-empty, an allowed tenant
subthe broker's user or applicationnon-empty
rolesthe claim script, from the roles you grantednon-empty, every member one of admin, auditor, automation, member
scopenative, from the resource's granted scopesoptional; enrollment:grant is what the grant endpoint requires
header algthe broker's signing keyRS256, ES256 or EdDSA only

Machine principals work the same way. An application's roles and tenant come from its custom data — {"rookone_roles": ["automation"], "rookone_tenant_id": "acme"} — rather than from a role grant, and its token comes from a plain client-credentials request that still has to carry the same resource. Set that custom data with PATCH /api/applications/<id> and the same customData wrapper. This endpoint is the quieter twin of the one in step 7: where the user endpoint refuses a flat body outright, this one answers 200 and stores nothing at all, and the mistake surfaces much later as a rejected token.

When it does not work

unsupported_algorithm. The broker signs with an elliptic-curve P-384 key until it is told otherwise, and the relay does not accept ES384. The layer's one-shot rotates the signing key to RSA; if you rebuilt the broker's database by hand, run logto-init again. Afterwards the key set serves two keys, the previous one included — that is correct, not a leak.

invalid_client_id. Either the token has no azp — the claim script is what adds it, and a broker configured by hand will emit client_id instead — or it has one that is not the id in ROOKONE_RELAY_IDENTITY_BROKER_CLIENT_ID, which means the token came from a different application than the one the relay was told to trust. Step 1, and a restart of the relay after changing that value.

The relay refuses every token with a roles error. Somebody signed in but was never granted a role — step 7.

Discovery works but key fetching fails. Check that whatever terminates TLS in front of the broker forwards Host $http_host and not $host. $host strips the port, after which the broker advertises a key-set URL without it while its issuer keeps it.

The application tile in your provider does nothing useful. Identity-provider-initiated SAML is refused at this pin: an assertion that arrives without a RelayState, which is what a tile launch sends, is answered 404 session.connector_validation_session_not_found. This is not a misconfiguration you can correct — the broker's vendor lists IdP-initiated SSO among the features its self-hosted edition does not carry, so a version bump is not the answer either. Send your users to the broker's own sign-in URL, which is the service-provider-initiated flow described here and the one that is tested.

Next

On this page