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 -dFrom 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. Everycurlbelow 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/secretsA 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 -d2. 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-initThat 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 it | Value |
|---|---|
| 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.
- Create a SAML 2.0 application.
- Single sign-on URL — the ACS URL from step 3.
- Audience URI (SP Entity ID) — the audience from step 3.
- Name ID format —
EmailAddress. Application username —Email. The broker maps the assertion'snameIDto the user's identifier and readsemailandnameas attributes; an opaque or non-email name id will federate but will not give you a person you can recognise in a user list. - 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.
- 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-initconfig 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
| Claim | Comes from | Rule |
|---|---|---|
iss | the broker's issuer | exact match with …_EXTERNAL_AUTHORITY_ISSUER, https only |
aud | the API resource indicator | equals …_AUDIENCE, or is a member of it |
azp | the claim script | non-empty, equals …_CLIENT_ID |
rookone_deployment_id | the claim script, from bundle settings | non-empty, equals …_DEPLOYMENT_ID |
rookone_tenant_id | the claim script, per-user override or bundle default | non-empty, an allowed tenant |
sub | the broker's user or application | non-empty |
roles | the claim script, from the roles you granted | non-empty, every member one of admin, auditor, automation, member |
scope | native, from the resource's granted scopes | optional; enrollment:grant is what the grant endpoint requires |
header alg | the broker's signing key | RS256, 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
- Deployment identity — the trust your clients place in the relay itself, which is a separate mechanism from operator sign-in.
- Hosted vs self-hosted — what your users will and will not have.