Skip to main content

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 install run in the repository;
  • Docker with Compose;
  • the names *.local.oktoid.app resolving to 127.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.

After pnpm stack:reset, trust the new root

A 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).

  1. Open http://127.0.0.1:18025 in the browser.
  2. Open the message to root@local.oktoid.app from no-reply@local.oktoid.app.
  3. 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:

  1. Verify the email address. Following the link does this.
  2. 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.
  3. 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.

OptionUse it when
--no-openThe command should only print the sign-in address. Open it yourself, for example in a private window (step 9)
--idp keycloak or --idp wso2Normally not needed: the command signs in to the provider the last pnpm stack:up started
--env ciYou 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​

Run steps 5 to 9 in one terminal

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 seeIt meansDo this
401No token, or the token has expiredRun pnpm -s oktoid login again
403 on any requestNo Oktoid-Acting-For header, or you hold no grant for the organization it namesSend the header, with the id of an organization you hold a grant for. GET /api/v1/me lists them
404The 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 indistinguishableCheck 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 whereChoose 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 setupChoose another slug
409 with "code": "grant.exists"The person already holds an active grant for that organizationRead 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 bodyUse a new key for a new change
409 with "code": "person.invitations_unavailable"The deployment has no identity provider configured for invitations. Nobody was createdSend the request without invite, or have the operator configure the provider
400The body or a header is malformed. The body names the fieldCorrect the field
429Too many requests in a minuteWait for the number of seconds in Retry-After
502 from api.local.oktoid.appThe gateway is not running, usually because the stack is in infrastructure modeRun 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.