Skip to main content

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:

ClaimHolds
iss, subThe organization's entity identifier, the same in both
iat, expIssue and expiry times
jwksThe organization's public keys. The kid of each is its RFC 7638 thumbprint
authority_hintsOne entry: the anchor's entity identifier
metadata.federation_entity.organization_nameThe 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​

  1. Verify the organization's entity configuration with a key from its own jwks.
  2. Verify the subordinate statement with a key from the anchor's entity configuration.
  3. Check that the key that signed the organization's configuration is in the subordinate statement's jwks.
  4. 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.
  5. Check iat and exp on 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.

Documentmax-age
An entity configuration, a subordinate statement, the manifestAn eighth of the document's lifetime, at most one hour
The listFive minutes
A framework versionOne 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.

StatuserrorWhen
400invalid_requestOn 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
400unsupported_parameterOn fetch: a parameter other than sub. On list: a parameter other than entity_type, or entity_type more than once
404not_foundNo 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
405invalid_requestA method other than GET or HEAD. The answer carries Allow: GET, HEAD
429temporarily_unavailableToo many requests from one address in a minute. Wait for the number of seconds in Retry-After
503temporarily_unavailableThe 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_policy in subordinate statements.
  • Signed (application/jwt) answers from the list address.
  • Any address under /federation/ or /governance/ other than those on this page.