Install and sign in
This page takes you from a clean machine to a signed-in request against the
OktoID API. At the end, the root account has created an organization called
acme, registered a person under it, and made that person the organization's
administrator. That person has then signed in and created an organization inside
acme, and cannot reach anything outside it.
Everything runs on your machine, in Docker, under the names
*.local.oktoid.app. There is no console yet: you sign in through the identity
provider's own pages in a browser, and every other step is a curl command.
This page sets the stack up without asking: pnpm stack:up takes every answer
from the deployment's answer file. To set it up through the setup wizard instead,
confirming each decision in a browser, follow
Install the platform, then continue here from step 2.
What organizations, persons, bundles and grants are, and why the requests below are shaped the way they are, is in the governance guide.
Before you start
You need a machine prepared as the repository's
docs/development/getting-started.md describes:
- Node 24 and pnpm, with
pnpm installrun in the repository; - Docker with Compose;
- the names
*.local.oktoid.appresolving to127.0.0.1; - an authenticator app on a phone, for the second factor (any app that reads a TOTP QR code).
Run every command on this page from the repository root.
Your browser must trust the stack's development root, because the sign-in pages
are served by the stack under https://id.local.oktoid.app. The root is created
by the first pnpm stack:up; getting-started.md shows how to trust it on macOS
and Linux.
1. Start the stack
pnpm stack:up
The first start builds the platform images and takes several minutes. It ends with:
healthy in <n>s
https://id.local.oktoid.app/realms/oktoid-staff/.well-known/openid-configuration
https://api.local.oktoid.app/api/v1/version
https://docs.local.oktoid.app/
sign in: pnpm oktoid login
CA: <repository>/infra/compose/.state/local/ca/root.pem
followed by the commands that trust the development root. If the browser does not trust it yet, do that now.
pnpm stack:reset, trust the new rootA reset deletes the development root, and the next pnpm stack:up creates a new
one. Until you trust the new root, the browser refuses every
*.local.oktoid.app address, including the sign-in pages in step 3. Chrome
reports it as ERR_CERT_AUTHORITY_INVALID.
On macOS, from the repository root:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$PWD/infra/compose/.state/local/ca/root.pem"
Then quit Chrome completely with Cmd+Q and open it again. Closing its windows is
not enough. Firefox needs root.pem imported into its own certificate settings
as well.
The commands for Linux, and how to find and remove the old root after a reset,
are in the repository's docs/development/getting-started.md, under Trust the
development root.
The stack runs Keycloak as its identity provider unless you ask for WSO2 Identity Server:
pnpm stack:up --idp wso2
A stack runs one provider at a time. The rest of this page is the same on both, except where a step says otherwise.
The last thing stack:up does is the setup run. It creates the two realms, the
anchor organization /oktoid, and the root account: a staff account for the
mailbox root@local.oktoid.app, holding the SOLE_ADMIN bundle at the anchor.
SOLE_ADMIN is the local stack's choice: it lets one account both run
organizations and govern the trust tree.
These come from the deployment's answer file,
infra/environments/local/answers.yaml (answers.wso2.yaml for WSO2).
Set two variables for the rest of the page:
CA=infra/compose/.state/local/ca/root.pem
API=https://api.local.oktoid.app/api/v1
and check that the API answers:
curl -sS --cacert "$CA" "$API/version"
It returns {"service":"gateway","version":"..."}.
2. Find the root account's invitation
The setup run sent the root account an invitation. On a local stack, mail does not leave your machine: Mailpit catches every message the identity provider sends.
On WSO2, the setup run's output reports the invitation as an
invitation-unconfirmed finding for root-account. That is expected and is not
an error: WSO2 sends mail in the background and does not tell the setup run
whether its mail server accepted the message. Keycloak sends while the setup run
waits, so on Keycloak a refused message is reported as invitation-failed
instead. On either provider, if no message reaches Mailpit, check the answer
file's identityProvider.mail and the provider's log
(pnpm stack:logs wso2 or pnpm stack:logs keycloak).
- Open http://127.0.0.1:18025 in the browser.
- Open the message to
root@local.oktoid.appfromno-reply@local.oktoid.app. - Follow the link in it.
Mailpit keeps its messages in memory, so they are gone if its container
restarts. The invitation link also expires. In either case the account exists
but cannot be set up from this mailbox; pnpm stack:reset followed by
pnpm stack:up starts again from an empty stack and sends a new invitation.
3. Set the password and the second factor
The link opens the identity provider's own pages. The staff realm asks for three things before the account can sign in:
- Verify the email address. Following the link does this.
- Set a password. It must meet the staff realm's password policy, which the page states. There is no "forgot password" link on the sign-in page, so keep the password somewhere safe.
- Set up the second factor. Scan the QR code with the authenticator app and enter the six-digit code it shows.
From now on, signing in as the root account asks for the username root (or the
email address), the password, and a current code from the app.
4. Sign in from the command line
TOKEN=$(pnpm -s oktoid login)
The command opens the provider's sign-in page in your browser. Sign in as the
root account. The browser then shows Signed in. You can close this tab., and
the command puts an access token in TOKEN. Messages go to the terminal; only
the token goes to the variable.
After signing in, the command asks the API which organizations you hold a grant
for (GET /api/v1/me, step 5) and prints them to the terminal as a table. For
the root account on a new stack, that is one row:
PATH BUNDLE ORGANIZATION ID
/oktoid SOLE_ADMIN <a UUID>
If that request fails, the command prints a warning instead of the table and
still puts the token in TOKEN.
The token is valid for five minutes. When a request answers 401, run the same
command again. While the browser still has a session with the provider, it
returns a new token without asking for the password again.
| Option | Use it when |
|---|---|
--no-open | The command should only print the sign-in address. Open it yourself, for example in a private window (step 9) |
--idp keycloak or --idp wso2 | Normally not needed: the command signs in to the provider the last pnpm stack:up started |
--env ci | You are signing in to the ci stack rather than local |
--acting-for <path> | You also want the id of the organization at that path, such as /oktoid. The command prints a second line, ORG=<id>, after the token (step 5). If you hold no grant at that path, it prints an error and exits with status 1, after the token line |
The command signs in with the authorization code flow and PKCE, as the public
client operator-cli. No password passes through the command, and the provider
offers no way to exchange a password for a token directly.
5. Find the anchor's id
Each step from here on reuses shell variables an earlier step set: CA and API
from step 1, TOKEN and ANCHOR from this step, SLUG, ACME and PERSON
from steps 6 and 7, and the key and field helpers defined below. Paste the commands into
the same terminal window, in order. A new window or tab starts without them.
A 401 means the access token has expired; it lasts five minutes. Run the
sign-in line again for the user whose token it was: the OUT=, TOKEN= and
ANCHOR= lines in this step for the root account, or the PERSON_TOKEN= line in
step 9 for the person. Then repeat the request that failed. The other variables
keep their values.
A person always says which organization they act for, with the header
Oktoid-Acting-For and the organization's id. The root account acts for the
anchor, /oktoid.
GET /api/v1/me is the one request that needs no Oktoid-Acting-For. It lists
the organizations you hold an active grant for, each with the id to send in that
header:
curl -sS --cacert "$CA" "$API/me" -H "Authorization: Bearer $TOKEN"
For the root account it returns:
{"grants":[{"organizationId":"<a UUID>","path":"/oktoid","bundle":"SOLE_ADMIN"}]}
An empty list is not an error: it means you hold no active grant anywhere.
pnpm oktoid login makes the same request, and with --acting-for it hands you
the id directly. Sign in again this way to set both variables at once:
OUT=$(pnpm -s oktoid login --acting-for /oktoid)
TOKEN=$(echo "$OUT" | sed -n 1p)
ANCHOR=$(echo "$OUT" | sed -n 2p); ANCHOR=${ANCHOR#ORG=}
echo "$ANCHOR"
The first line of the output is the token and the second is ORG=<id>; the
grants table goes to the terminal, not into OUT. It prints one UUID. While the
browser still has its session with the provider, this second sign-in does not ask
for the password again.
Every request from here on that changes something also carries an
Idempotency-Key: a fresh value for each new change, and the same value again if
you retry that change. This page makes one with Node:
key() { node -p 'crypto.randomUUID()'; }
and reads a field from a JSON reply with:
field() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let v;try{v=JSON.parse(s)[process.argv[1]]}catch{}if(v===undefined){console.error(s||"no reply");process.exit(1)}console.log(v)})' "$1"; }
When the reply has no such field, because the request was refused, field
prints the whole reply to the terminal, sets nothing and fails. The steps below
then stop at a line that names the missing variable, instead of carrying on with
an empty one.
6. Create an organization
SLUG=acme
ACME=$(curl -sS --fail-with-body --cacert "$CA" "$API/subjects" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d "{\"kind\":\"organization\",\"slug\":\"$SLUG\",\"displayName\":\"Acme\"}" \
| field id)
echo "${ACME:?step 6 made no organization: read the reply above}"
The API answers 201 with the new organization. Its path is /oktoid/acme:
the new organization goes beneath the organization you act for, unless the body
names another parentPath inside it.
If the last line prints ACME: step 6 made no organization instead of a UUID,
the request was refused and the reply above it says why. Do not go on to step 7.
When acme already exists, for example because you ran this step before,
the reply is 409 with "code": "subject.slug_taken". A slug is unique across
the whole deployment, wherever the organization sits, because it becomes part
of a hostname. The reply says only that the name is in use, and no request in
this release finds an organization by its slug, so pick another one: set SLUG to a new
value, such as SLUG=acme2, and run the request again. Steps 7 to 9 use $SLUG,
so they follow. If this terminal already holds the id from the run that created
acme, echo "$ACME" prints it and you can carry on from step 7 instead. To
start again from an empty stack, run pnpm stack:reset and then pnpm stack:up.
7. Register a person and invite them
PERSON=$(curl -sS --fail-with-body --cacert "$CA" "$API/persons" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d "{\"givenName\":\"Amal\",\"familyName\":\"Haddad\",\"email\":\"amal.haddad@acme.example\",\"parentPath\":\"/oktoid/${SLUG:?run step 6 first}\",\"invite\":true}" \
| field id)
echo "${PERSON:?step 7 registered no person: read the reply above}"
The API answers 201 with the person. Their path is /oktoid/acme/<id>: a
person sits beneath the organization that registered them.
"invite": true asks the platform to create the person's staff account at the
identity provider and send them an invitation. That happens just after the reply,
not during it, and is retried if the provider is briefly unavailable. Within a
few seconds the invitation appears in Mailpit, addressed to
amal.haddad@acme.example. Mailpit catches mail for any address, so the domain
does not need to exist.
Run this step once. Each run registers another person, even when PERSON is
already set. If the last line names PERSON instead of printing a UUID, nobody
was registered and you can run it again.
8. Make the person the organization's administrator
curl -sS --fail-with-body --cacert "$CA" "$API/grants" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d "{\"granteeId\":\"${PERSON:?run step 7 first}\",\"organizationId\":\"${ACME:?run step 6 first}\",\"bundle\":\"ENTITY_ADMIN\"}"
The API answers 201 with the grant, "status":"active". The person may now act
for acme and everything beneath it, with the permissions of ENTITY_ADMIN.
If PERSON or ACME is not set, the shell stops with run step 7 first or
run step 6 first and sends nothing. Running this step a second time answers
409 with "code": "grant.exists": the person already holds the grant, and
nothing changed.
9. The person signs in
The person follows their own invitation, as in steps 2 and 3: verify the address, set a password, set up the second factor.
Your browser is still signed in as the root account, and the provider would sign the person in as root without asking. Use a private window for the person:
PERSON_TOKEN=$(pnpm -s oktoid login --no-open)
Copy the address the command prints into a private window, and sign in as the
person. The table the command prints shows their one grant, /oktoid/acme with
ENTITY_ADMIN, and its organization id is the same value as $ACME. A person
who was not handed that id learns it this way, or from GET /api/v1/me
(step 5), or with --acting-for /oktoid/acme.
Then create an organization inside acme, acting for acme:
curl -sS -i --cacert "$CA" "$API/subjects" \
-H "Authorization: Bearer $PERSON_TOKEN" \
-H "Oktoid-Acting-For: ${ACME:?run step 6 first}" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d '{"kind":"organization","slug":"branch","displayName":"Branch"}'
The API answers 201, and the new organization's path is /oktoid/acme/branch.
The same person cannot reach outside acme. Asking for an organization beneath
the anchor:
curl -sS -i --cacert "$CA" "$API/subjects" \
-H "Authorization: Bearer $PERSON_TOKEN" \
-H "Oktoid-Acting-For: $ACME" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d '{"kind":"organization","slug":"elsewhere","displayName":"Elsewhere","parentPath":"/oktoid"}'
answers 404, as an absent parent would. Acting for the anchor
instead (Oktoid-Acting-For: $ANCHOR) answers 403, because the person holds no
grant there.
When a request is refused
Every refusal is an application/problem+json body with a code and a
correlationId. Quote the correlation id when you ask for help: it finds the
request in every service's log.
| You see | It means | Do this |
|---|---|---|
401 | No token, or the token has expired | Run pnpm -s oktoid login again |
403 on any request | No Oktoid-Acting-For header, or you hold no grant for the organization it names | Send the header, with the id of an organization you hold a grant for. GET /api/v1/me lists them |
404 | The thing is outside the organization you act for, or does not exist, or your grant there lacks the permission the request needs. For example, GET /api/v1/grants answers 404 to a holder of ORGANIZATION_CLIENT, which cannot read grants. The three are deliberately indistinguishable | Check the id, which organization you act for, and that your bundle holds the permission (Bundles) |
409 with "code": "subject.slug_taken" | An organization with that slug already exists somewhere in the deployment. It may be one you cannot read, so the reply does not say where | Choose another slug. Step 6 shows how |
400 with "code": "subject.slug_reserved" | The slug is a name the platform uses for one of its own hosts, such as api, admin or setup | Choose another slug |
409 with "code": "grant.exists" | The person already holds an active grant for that organization | Read it with GET /api/v1/grants?grantee=<id>, or revoke it first to change the bundle |
409 with "code": "idempotency.key_reused" | The same Idempotency-Key was sent with a different body | Use a new key for a new change |
409 with "code": "person.invitations_unavailable" | The deployment has no identity provider configured for invitations. Nobody was created | Send the request without invite, or have the operator configure the provider |
400 | The body or a header is malformed. The body names the field | Correct the field |
429 | Too many requests in a minute | Wait for the number of seconds in Retry-After |
502 from api.local.oktoid.app | The gateway is not running, usually because the stack is in infrastructure mode | Run pnpm stack:up |
The platform answers 404 rather than 403 for a missing permission so that a
refusal never reveals what exists. A 403 for "you may not read this" would
confirm that the thing is there to be read; a 404 reads the same whether the
thing is missing, outside your reach, or not yours to see. A 403 is only ever
about the caller, such as a missing Oktoid-Acting-For header, never about the
thing asked for.
To stop the stack, run pnpm stack:down; your data survives. pnpm stack:reset
removes everything, including the root account and the development root.