Read the trust tree
To decide whether an organization is in a deployment's trust tree, fetch two signed statements and check them against the trust anchor's keys. This page gives the addresses, what each answers, and how each refuses.
The addresses follow OpenID Federation 1.0. A client built for that specification needs nothing from this page except the list of what is not served. What the statements mean is in The trust tree.
Every address here is public and read-only. No token is sent. Only GET and
HEAD are served. The examples use a local stack, whose anchor is
https://oktoid.identity.local.oktoid.app, and an admitted organization acme.
CA=infra/compose/.state/local/ca/root.pem
ANCHOR=https://oktoid.identity.local.oktoid.app
ENTITY=https://acme.identity.local.oktoid.app
--cacert "$CA" is for the local stack's development certificate only. An
entity identifier is https:// and a lower-case host name, with no port, path
or trailing slash.
Fetch an organization's entity configuration
curl -sS --cacert "$CA" "$ENTITY/.well-known/openid-federation"
The answer is 200 with Content-Type: application/entity-statement+jwt: a
compact JWS signed with ES256, whose header has typ set to
entity-statement+jwt and kid naming the signing key. The payload of an
admitted organization holds:
| Claim | Holds |
|---|---|
iss, sub | The organization's entity identifier, the same in both |
iat, exp | Issue and expiry times |
jwks | The organization's public keys. The kid of each is its RFC 7638 thumbprint |
authority_hints | One entry: the anchor's entity identifier |
metadata.federation_entity.organization_name | The organization's name |
The anchor's own configuration has no authority_hints, and its
metadata.federation_entity adds federation_fetch_endpoint,
federation_list_endpoint, policy_uri and contacts:
{
"iss": "https://oktoid.identity.local.oktoid.app",
"sub": "https://oktoid.identity.local.oktoid.app",
"iat": 1791817568,
"exp": 1791903968,
"jwks": { "keys": [{ "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": "ZeOMDHaQNQUIVZAgk-Niu-o22Z2irYT0edWkdZqDAEg", "x": "...", "y": "..." }] },
"metadata": {
"federation_entity": {
"organization_name": "OktoID Local",
"contacts": ["trust@local.oktoid.app"],
"federation_fetch_endpoint": "https://oktoid.identity.local.oktoid.app/federation/fetch",
"federation_list_endpoint": "https://oktoid.identity.local.oktoid.app/federation/list",
"policy_uri": "https://oktoid.identity.local.oktoid.app/governance/framework/manifest"
}
}
}
Read the fetch and list addresses from the anchor's configuration; do not build them.
Fetch the anchor's statement about an organization
curl -sS --cacert "$CA" --get "$ANCHOR/federation/fetch" --data-urlencode "sub=$ENTITY"
The answer is 200 with Content-Type: application/entity-statement+jwt. Its
iss is the anchor, its sub is the organization, its jwks holds the keys
the anchor accepts as the organization's, and constraints.max_path_length is
0. It is signed by the anchor's key.
sub is the only parameter. Any valid spelling of the identifier is accepted,
percent-encoded or not, and every spelling gets the same answer with no
redirect.
Check the chain
- Verify the organization's entity configuration with a key from its own
jwks. - Verify the subordinate statement with a key from the anchor's entity configuration.
- Check that the key that signed the organization's configuration is in the
subordinate statement's
jwks. - Check the anchor's keys against the ones you hold. Keys read from the anchor's own configuration prove only that the anchor is consistent with itself.
- Check
iatandexpon all three statements.
The repository carries a verifier that does this from an identifier alone. It
is the package @oktoid/federation-verify, built on jose and sharing no code
with the platform, and pnpm oktoid verify-federation is its command line:
pnpm oktoid verify-federation "$ENTITY" --anchor "$ANCHOR" --anchor-jwks anchor-keys.json
It exits 0 when every check holds, 1 when a check fails, and 3 when a
document could not be fetched, which says nothing either way. Without
--anchor-jwks it reads the anchor's keys from the anchor, and its verdict says
so in brackets: VERIFIED (anchor not pinned: ...). Asked about the anchor
itself with no pinned key, it answers VERIFIED (self-asserted: ...), which
proves only that the anchor's configuration is consistent with itself.
List the organizations the anchor vouches for
curl -sS --cacert "$CA" "$ANCHOR/federation/list"
curl -sS --cacert "$CA" "$ANCHOR/federation/list?entity_type=federation_entity"
The answer is 200 with Content-Type: application/json: an array of entity
identifiers, sorted, empty when there are none. It is not signed. An
organization appears only once its subordinate statement can be fetched.
entity_type filters by one OpenID Federation entity type and may be given
once. A type the platform does not know answers 404.
Fetch the governance framework
The anchor's policy_uri names a signed manifest:
curl -sS --cacert "$CA" "$ANCHOR/governance/framework/manifest"
The answer is 200 with Content-Type: application/jwt: a compact JWS signed
by the anchor's key, with typ set to governance-framework-manifest+jwt. The
manifest is OktoID's own format, not part of OpenID Federation. Its payload:
{
"iss": "https://oktoid.identity.local.oktoid.app",
"sub": "https://oktoid.identity.local.oktoid.app",
"iat": 1791817568,
"exp": 1791903968,
"versions": [
{
"version": 1,
"uri": "https://oktoid.identity.local.oktoid.app/governance/framework/v1",
"sha256": "1c8483f10ff50245f4fbd2d1b25b3cdb40a7a8fc7bdd4697366c29fd891fbd8b"
}
]
}
A framework document in the ToIP Governance Metamodel form (one with
"schemaVersion": 2) adds to the manifest. Its version's entry carries documents, the schedule of controlled
documents, each with id, category, uri, version, language and
sha256. Once such a framework names the anchor's did:webvh, the payload
carries did, the framework's DID, and every version's entry a did_url: the
DID followed by the version's path, <did>/governance/framework/v<n>, which
resolves to the same bytes as uri. A manifest whose documents carry no
schemaVersion has none of these.
Each uri answers 200 with Content-Type: application/json: the framework
document for that version. Compute the SHA-256 of the bytes as received and
compare it with sha256. A framework version never changes, so its copy may be
kept for a year.
Caching
Every answer carries the Cache-Control to follow.
| Document | max-age |
|---|---|
| An entity configuration, a subordinate statement, the manifest | An eighth of the document's lifetime, at most one hour |
| The list | Five minutes |
| A framework version | One year, immutable |
A statement is replaced before half its lifetime has passed, so a client that
honors max-age always holds one that has not expired. Do not keep a statement
past its exp.
When a request is refused
A refusal is application/json with two members, as OpenID Federation 1.0
specifies:
{"error":"not_found","error_description":"No such entity or document is published on this host."}
Branch on error and the status. error_description is for people and may
change. These addresses never answer with the application/problem+json body
the OktoID API uses.
| Status | error | When |
|---|---|---|
400 | invalid_request | On fetch: sub is missing, is given more than once, is not an https:// entity identifier, or names the anchor itself. On list: entity_type is empty. On any address: the path is malformed |
400 | unsupported_parameter | On fetch: a parameter other than sub. On list: a parameter other than entity_type, or entity_type more than once |
404 | not_found | No statement is published for that sub; the host is not an admitted organization's; the path is not one of those on this page; a query on an address that takes none; an unknown entity_type |
405 | invalid_request | A method other than GET or HEAD. The answer carries Allow: GET, HEAD |
429 | temporarily_unavailable | Too many requests from one address in a minute. Wait for the number of seconds in Retry-After |
503 | temporarily_unavailable | The store behind these addresses could not be reached. Retry later |
Refusals are not cached (Cache-Control: no-store).
A 308 redirect is possible in one case: a list request whose entity_type
value is percent-encoded where it need not be. Follow it, or send the value
unencoded.
What is not served
- The resolve endpoint of OpenID Federation 1.0. A client builds and checks the chain itself, as above.
- Trust marks and their status.
metadata_policyin subordinate statements.- Signed (
application/jwt) answers from the list address. - Any address under
/federation/or/governance/other than those on this page.