Admit an organization and publish it
This page takes an organization that already exists in OktoID and gives it a
place in the trust tree that anyone can verify. At the end, acme has an
address of its own, https://acme.identity.local.oktoid.app, that serves a
signed statement about itself. The trust anchor serves a second signed statement
that vouches for it, and a verifier that knows only that address has followed
both to the anchor and checked every signature.
Two decisions put it there, and they are separate on purpose. Admitting says the organization belongs in the tree; nothing is signed and nothing is public yet. Approving publication releases the statements, and it is refused until you have read the exact bytes that will be signed. A published statement cannot be recalled from a verifier that has fetched it, so the reading comes first.
Why the tree has this shape, what each statement says and how long it lives are in The trust tree.
Before you start
This page begins where setup ends. You need a deployment that is complete for
this version, from Install the platform
or from the unattended install of
Install and sign in, and the
organization acme, which the root account creates in steps 2 to 6 of
Install and sign in. Work in the same
terminal: this page reuses CA, ANCHOR and ACME, and the key and field
helpers defined there.
You act as the trust anchor administrator setup created. Who that is depends on step 4 of the setup wizard:
-
Setup created a governing account. That person admits. They follow their own invitation in Mailpit, set a password and a second factor, as the root account did in steps 2 and 3 of Install and sign in, and sign in. Your browser is still signed in as the root account, so use a private window:
OUT=$(pnpm -s oktoid login --no-open --acting-for /oktoid)TOKEN=$(echo "$OUT" | sed -n 1p)Copy the address the command prints into the private window and sign in as the governing account. The table it prints shows one grant,
/oktoidwithTRUST_ANCHOR_ADMIN. The root account cannot do what this page does: it holdsENTITY_ADMIN, and every request below answers it404. -
Setup's step 4 was skipped (a development stack only, and the unattended install of the local stack). The root account holds
SOLE_ADMIN, which includes the trust anchor administrator's permissions, and theTOKENfrom Install and sign in is the one to use.
Three things differ from Install and sign in:
- The requests go to a second address. Admission is served by the admin
gateway,
admin.local.oktoid.app, and by nothing else. The public API answers the same paths with404, as it does for any path it does not have. The edge refuses the admin address to any client outside the private address ranges; your own machine is inside them. - You need a governance bundle, as above. The person who admits holds
TRUST_ANCHOR_ADMINorSOLE_ADMIN; a person who only puts organizations forward holdsACCREDITATION_OFFICER. Bundles lists what each holds. - The organization must sit directly beneath the anchor.
/oktoid/acmequalifies./oktoid/acme/branchdoes not in this release.
Set the admin address:
ADMIN=https://admin.local.oktoid.app/api/v1
curl -sS --cacert "$CA" "$ADMIN/version"
It returns {"service":"gateway-admin","version":"..."}. A reply that names
gateway instead means the request reached the public API.
A 401 on any step means the access token has expired; it lasts five minutes.
Sign in again as above, and repeat the request.
1. Put the organization forward
ACC=$(curl -sS --fail-with-body --cacert "$CA" "$ADMIN/accreditations" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d "{\"organizationId\":\"${ACME:?create the organization first}\",\"entityTypes\":[\"federation_entity\"]}" \
| field id)
echo "${ACC:?step 1 made no accreditation: read the reply above}"
The API answers 201 with the accreditation: the record of this
organization's way into the tree. Its status is applied. Its hostname is
acme.identity.local.oktoid.app, made from the last part of the organization's
path, and that is the address its statements will be served on.
entityTypes says what kind of participant the organization is.
federation_entity is the only type this release accepts; any other is refused
with 400 and "code": "accreditation.entity_type_unsupported".
An organization has one accreditation. Running this step again for acme
answers 409 with "code": "accreditation.exists", and ACC is left empty. To
find the id again:
curl -sS --cacert "$CA" "$ADMIN/accreditations?organization=$ACME" \
-H "Authorization: Bearer $TOKEN" -H "Oktoid-Acting-For: $ANCHOR"
2. Admit it
curl -sS --fail-with-body --cacert "$CA" -X POST "$ADMIN/accreditations/$ACC/admit" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)"
The API answers 200. The accreditation's status is now admitted, with
admittedAt and admittedBy.
Admission prepares two statements for the organization and signs neither. They are prepared just after the reply, not during it.
A deployment can require that the person who admits is not the person who put
the organization forward. The local stack does not; a production deployment does
unless its answer file says otherwise. Where the rule is on, admitting your own
application answers 409 with "code": "accreditation.four_eyes", and somebody
else who holds accreditation.admit must make this request.
3. Find the prepared statements
curl -sS --cacert "$CA" "$ADMIN/entity-statements?accreditation=$ACC" \
-H "Authorization: Bearer $TOKEN" -H "Oktoid-Acting-For: $ANCHOR"
The reply lists two statements, both with "status":"prepared":
kind | issuer | subject | Says |
|---|---|---|---|
entity-configuration | https://acme.identity.local.oktoid.app | the same | acme's statement about itself, to be signed by acme's key |
subordinate-statement | https://oktoid.identity.local.oktoid.app | https://acme.identity.local.oktoid.app | The anchor's statement about acme, to be signed by the anchor's key |
An empty list means the statements are not prepared yet. Wait a moment and ask again.
Keep the two ids:
ids() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).statements.map(x=>x.id).join(" ")))'; }
STATEMENTS=$(curl -sS --cacert "$CA" "$ADMIN/entity-statements?accreditation=$ACC&status=prepared" \
-H "Authorization: Bearer $TOKEN" -H "Oktoid-Acting-For: $ANCHOR" | ids)
echo "${STATEMENTS:?no prepared statements yet: wait a moment and run this again}"
4. Read what will be signed
Publication is refused until each prepared statement has been previewed, so this step cannot be skipped. Do not treat it as a formality either: these are the bytes that become public.
claims() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const p=JSON.parse(s);console.log(p.statement.kind,"served at",p.servedAt.join(", "));console.log(JSON.stringify(JSON.parse(Buffer.from(p.payload,"base64url")),null,2))})'; }
for ID in $STATEMENTS; do
curl -sS --cacert "$CA" "$ADMIN/entity-statements/$ID/preview" \
-H "Authorization: Bearer $TOKEN" -H "Oktoid-Acting-For: $ANCHOR" | claims
done
For each statement this prints its kind, the address it will be served at, and
its claims. The reply carries the claims as payload, base64url-encoded exactly
as they will be signed; claims only decodes them for reading.
Check, in each:
| Claim | It must be |
|---|---|
sub | https://acme.identity.local.oktoid.app, the organization you meant |
iss | The same in the entity configuration; the anchor, https://oktoid.identity.local.oktoid.app, in the subordinate statement |
metadata.federation_entity.organization_name | The organization's display name, in the entity configuration. It is public from the moment you approve |
authority_hints | The anchor, in the entity configuration |
constraints.max_path_length | 0, in the subordinate statement: acme may not vouch for anyone beneath it |
jwks | One key, the same kid in both statements: acme's own key |
Nothing else appears. A hosted organization's statements carry no contact
address and no field that is not in this table, iat and exp aside.
Reading a preview records a receipt on the statement: who read it, when, and the digest of the bytes shown. The receipt belongs to the person who read. The platform signs only when the person who approves publication is the person whose receipt is on every statement, so preview and approve as the same person. If a colleague previews after you, their receipt replaces yours.
5. Approve publication
curl -sS --fail-with-body --cacert "$CA" -X POST "$ADMIN/accreditations/$ACC/publish" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)"
The API answers 200. The accreditation now carries publicationApprovedAt and
publicationApprovedBy.
The reply records the decision. Signing happens just after it. Ask for the statements again:
curl -sS --cacert "$CA" "$ADMIN/entity-statements?accreditation=$ACC" \
-H "Authorization: Bearer $TOKEN" -H "Oktoid-Acting-For: $ANCHOR"
Both now read "status":"published", with publishedAt, issuedAt and
expiresAt.
If they stay prepared, the platform declined to sign after the approval was
recorded. That happens when a statement was prepared again after you previewed
it, which the platform does once a prepared statement has waited past half its
lifetime, or when the preview on record is somebody else's. Nothing was
published. Repeat step 4, then this step: a new preview followed by a new
approval is the way on.
Approving before step 4 answers 409 with
"code": "accreditation.not_previewed".
6. Check it from outside
Everything from here on is public. No token is sent, and the requests go to the organization's own address, not to the API.
The organization's statement about itself:
curl -sS -i --cacert "$CA" https://acme.identity.local.oktoid.app/.well-known/openid-federation
answers 200 with Content-Type: application/entity-statement+jwt and a signed
statement in three dot-separated parts.
The anchor's statement about it:
curl -sS -i --cacert "$CA" \
'https://oktoid.identity.local.oktoid.app/federation/fetch?sub=https%3A%2F%2Facme.identity.local.oktoid.app'
and the anchor's list of the organizations it vouches for, which now includes
acme:
curl -sS --cacert "$CA" https://oktoid.identity.local.oktoid.app/federation/list
The list is rebuilt after the statements are published and may be cached for
five minutes, so acme can take that long to appear in it.
Then let an independent verifier walk the chain:
pnpm oktoid verify-federation https://acme.identity.local.oktoid.app
It starts from the address alone. It fetches acme's statement, reads which
anchor it names, fetches the anchor's statement about acme and the anchor's
own, and checks every signature, date and key link. It then follows the anchor's
policy_uri to the governance framework and checks the framework's digest. The
command is the command line of the repository's verifier package,
@oktoid/federation-verify, which shares no code with the part of the platform
that signed the statements. It ends with:
VERDICT: VERIFIED (anchor not pinned: its keys were read from the anchor itself). https://acme.identity.local.oktoid.app chains to https://oktoid.identity.local.oktoid.app in 3 statement(s), every signature, date, key link and constraint holding.
and exits with status 0.
Read the words in brackets. The command read the anchor's keys from the anchor,
so it has shown that acme chains to an anchor that is consistent with
itself, and no more.
The run to keep as evidence is the pinned one. A real verifier holds the anchor's keys before it asks. Give them to the command as a JWK Set file, together with the anchor you expect:
pnpm oktoid verify-federation https://acme.identity.local.oktoid.app \
--anchor https://oktoid.identity.local.oktoid.app \
--anchor-jwks anchor-keys.json
Only this form ends with the unqualified line:
VERDICT: VERIFIED. https://acme.identity.local.oktoid.app chains to https://oktoid.identity.local.oktoid.app in 3 statement(s), every signature, date, key link and constraint holding.
The file holds the anchor's public keys as {"keys":[...]}. Its worth is where
it came from: the governance authority gives it out by a channel other than the
anchor's own address. A file copied from the jwks of the anchor's published
configuration makes the command print the plain verdict, and proves no more than
the unpinned run did. On a local stack that copy is all there is, which is fine
for trying the command and is not evidence.
The command has five outcomes:
| Status | Verdict | Means |
|---|---|---|
0 | VERDICT: VERIFIED. | Every check held, and the anchor's configuration is signed by a key you pinned |
0 | VERDICT: VERIFIED (anchor not pinned: its keys were read from the anchor itself). | Every check held up to an anchor whose keys nobody pinned |
0 | VERDICT: VERIFIED (self-asserted: no pinned key or separate anchor). | The entity asked about is its own anchor, and no key was pinned |
1 | VERDICT: NOT VERIFIED. | A document was fetched and a check on it failed, or its publisher does not publish it |
2 | None; one line of usage | The command line is wrong. Nothing was fetched |
3 | VERDICT: COULD NOT CHECK. | A document could not be fetched: a refused connection, a timeout, a rate limit. This says nothing about the organization; run it again |
The self-asserted verdict is what the anchor gets when checked against itself. Any entity verifies against its own configuration, so that result proves nothing about who the anchor is.
These addresses are served from stored files through the edge. They answer the same whether the rest of the platform is running or stopped.
After publication
The platform signs each statement again before half its lifetime has passed, and replaces the published one. Nobody needs to approve a renewal: it carries the same facts under new dates, and the platform refuses a renewal that would say anything else.
This release has no request that suspends or withdraws an accreditation.
When a request is refused
Refusals from the admin address have the same shape as everywhere else in the
API: an application/problem+json body with a code and a correlationId.
| You see | It means | Do this |
|---|---|---|
404 on every request to $ADMIN/accreditations | The request went to the public API, the organization or accreditation is outside the organization you act for, or your bundle lacks the permission. The three are indistinguishable | Check ADMIN, the id, and that your bundle is TRUST_ANCHOR_ADMIN, ACCREDITATION_OFFICER or SOLE_ADMIN |
403 from admin.local.oktoid.app, with no problem body | The edge refused a client outside the private address ranges | Call the admin address from inside the deployment's network |
400, accreditation.entity_type_unsupported | An entity type other than federation_entity | Send ["federation_entity"] |
400, accreditation.hostname_invalid | The organization's slug does not make a valid host name under this deployment's domain | The organization needs a different slug |
409, accreditation.exists | The organization already has an accreditation | Find it with ?organization=<id> and carry on from where it stands |
409, accreditation.superior_missing | The organization is not directly beneath the admitted anchor | Only organizations directly beneath the anchor can be admitted in this release |
409, accreditation.organization_inactive | The organization is not active | Restore the organization, then apply again |
409, framework.missing | No governance framework is published | Have the operator run the setup again (pnpm stack:up on a local stack); it publishes version 1 with the anchor |
409, accreditation.four_eyes | You put this organization forward, and the deployment requires a second person to admit | Ask another holder of accreditation.admit |
409, accreditation.not_applied | The accreditation is not waiting for admission | Read it to see where it stands |
409, accreditation.path_mismatch | The organization has moved in the tree since it was put forward | The accreditation no longer describes the organization, and this release has no request that withdraws one. Report it to the operator |
409, accreditation.not_admitted | Publication was approved before admission | Admit it first |
409, accreditation.not_prepared | The statements are not prepared yet | Wait a moment, then list, preview and approve |
409, accreditation.not_previewed | A prepared statement was never previewed, or was prepared again since | Preview each prepared statement, then approve |
502, accreditation.statements_unavailable or accreditation.identity_unavailable | The platform could not ask one of its own parts, so it decided nothing | Retry after a short wait |