# Okta simulator

A stateful Okta Management API simulator — users and their lifecycle, groups, app assignments, passwords and software-TOTP factors.

`okta-twin` is a local, stateful simulator of the Okta Management API — users and their lifecycle, groups and memberships, bookmark applications and their assignments, password operations and software-TOTP factors. It never calls Okta: no activation email is sent and no real directory changes.

## Quickstart

You need an API key from [Account → API keys](https://www.rystic.ai/account/api-keys) ([how](https://www.rystic.ai/docs/licensing.md#get-an-api-key)).

```bash
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                                          # your API key
rystic run okta-twin -d --port 0 --scenario design_studio   # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
```

Expect `"product":"okta-twin"` and `"version":"0.0.1"`. An older version means an older install; `rystic pull okta-twin` upgrades it. `--scenario design_studio` boots the same world on every plan. The examples below use `localhost:8080`; use the port `rystic run` printed.

On Enterprise, `rystic login ./rystic-license.json` takes the place of the key ([license files](https://www.rystic.ai/docs/licensing.md#enterprise-license-files)).

```bash
curl -s -H 'Authorization: SSWS placeholder' -H 'Content-Type: application/json' \
  'localhost:8080/api/v1/users?activate=false' \
  -d '{"profile":{"firstName":"New","lastName":"Hire","email":"new.hire@wdcontractors.example","login":"new.hire@wdcontractors.example"}}'
# -> {...,"id":"00u00000000000000001",...,"status":"STAGED",...}
```

Point the SDK at it — the org URL only, code unchanged. The twin serves plain HTTP, so turn off the SDK's HTTPS check for tests:

```python
client = OktaClient({
    "orgUrl": "http://localhost:8080",
    "token": "placeholder",
    "testing": {"disableHttpsCheck": True},
})
```

## Plans

What `okta-twin` does on each plan ([plans](https://www.rystic.ai/docs/licensing.md#plans)):

| | Free | Pro | Enterprise |
|---|---|---|---|
| Credential | API key | API key | license file |
| Boots | `design_studio` | an empty org, or the `--scenario` you name | same as Pro |
| `POST /_rystic/reset` | re-applies `design_studio` | wipes to empty | wipes to empty |
| Okta API (all 49 routes) | yes | yes | yes |
| Control plane | `version`, `routes`, `reset` only | every route | every route |

**Free** runs 5 hours of simulator time a month. It serves the whole Okta surface against `design_studio`. Every control route beyond `GET /_rystic/version`, `GET /_rystic/routes` and `POST /_rystic/reset` answers 403, naming the feature it needs (the loopback-only `GET /_rystic/activity` aside):

```json
{"feature":"state_set","message":"/_rystic/seed needs the \"state_set\" feature, which this plan does not include; upgrade at https://www.rystic.ai/pricing","upgrade_url":"https://www.rystic.ai/pricing"}
```

Free also refuses these boot inputs, and the twin exits naming the feature: `--scenario` naming any other world (`scenarios`), `--seed` or `RYSTIC_SEED` (`state_set`), and `RYSTIC_NETWORK` (`faults`). See [boot inputs](https://www.rystic.ai/docs/control-plane.md#boot-inputs).

**Pro** opens every control route below. Without `--scenario` the twin boots an **empty** org, and `POST /_rystic/reset` wipes it back to empty; load `design_studio` with `--scenario design_studio` or `POST /_rystic/scenario`.

**Enterprise** is Pro on a license file instead of an API key.

## Auth

Okta takes an API token as `Authorization: SSWS <token>`. The twin accepts any non-empty `Authorization` value, SSWS or Bearer, and checks no token shape, admin role, scope or expiry; no Okta org or token is involved.

1. A request with no `Authorization` header answers 403 `E0000006`. That refusal is not measured against Okta.
2. `GET /api/v1/users/me` answers 404: the twin has no token owner.

```bash
curl -s -H 'Authorization: SSWS placeholder' localhost:8080/api/v1/users
```

## Coverage

49 routes on the Management API. `GET /_rystic/routes` returns the same list from a running twin.

| Family | Routes |
|---|---|
| Users (7) | `GET`, `POST /api/v1/users`; `GET`, `POST`, `PUT`, `DELETE /api/v1/users/{id}`; `GET /api/v1/users/{id}/groups` |
| User lifecycle (9) | `POST /api/v1/users/{id}/lifecycle/` `activate`, `deactivate`, `suspend`, `unsuspend`, `reactivate`, `expire_password`, `expire_password_with_temp_password`, `reset_password`, `reset_factors` |
| Credentials (1) | `POST /api/v1/users/{userId}/credentials/change_password` |
| Factors (7) | `GET`, `POST /api/v1/users/{userId}/factors`; `GET …/factors/catalog`; `GET`, `DELETE …/factors/{factorId}`; `POST …/factors/{factorId}/lifecycle/activate`, `POST …/factors/{factorId}/verify` |
| Groups (8) | `GET`, `POST /api/v1/groups`; `GET`, `PUT`, `DELETE /api/v1/groups/{groupId}`; `GET …/{groupId}/users`; `PUT`, `DELETE …/{groupId}/users/{userId}` |
| Applications (7) | `GET`, `POST /api/v1/apps`; `GET`, `PUT`, `DELETE /api/v1/apps/{appId}`; `POST …/{appId}/lifecycle/activate`, `…/deactivate` |
| App assignments (10) | `GET`, `POST /api/v1/apps/{appId}/users`; `GET`, `POST`, `DELETE …/users/{userId}`; `GET /api/v1/apps/{appId}/groups`; `GET`, `PUT`, `PATCH`, `DELETE …/groups/{groupId}` |

1. Bodies are JSON both ways. The user and group lists take `q`, `search` and `filter`, and page with `limit` and an `after` cursor in a `Link` header.
2. A first `DELETE` on a user deactivates it (`DEPROVISIONED`); a second purges it. A plain user list leaves `DEPROVISIONED` users out; `search` and `filter` include them.
3. A duplicate `login` answers 400 `E0000001`; every refusal carries Okta's `errorCode`, `errorSummary`, `errorLink`, `errorId` and `errorCauses`.

Not modelled: OAuth 2.0 and OIDC, browser and Identity Engine sign-in, sessions, unlock, roles and grants, group rules and owners, linked objects, the System Log, hooks, and every other Management API family. An unserved route answers 404 `{"message":"No route in twin for …"}`.

## Control plane

Every route under `/_rystic/`, and the plan that reaches it. [Routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) covers the whole control plane.

| Route | What it does | Plan (feature) |
|---|---|---|
| `GET /_rystic/version` | health check: product, version, model hash, edition, `auth_scheme`, `initial_clock` | every plan |
| `GET /_rystic/routes` | every Okta API route this twin serves; not the control plane | every plan |
| `POST /_rystic/reset` | Free: back to `design_studio`. Pro, Enterprise: wipe every record, rewind the id sequence and the clock | every plan |
| `GET /_rystic/scenarios` | the shipped worlds | Pro, Enterprise (`scenarios`) |
| `POST /_rystic/scenario` | `{"name":"design_studio"}`: reset, then load the world | Pro, Enterprise (`scenarios`) |
| `GET /_rystic/inspect` | dump the entire state | Pro, Enterprise (`state_view`) |
| `GET /_rystic/registers` | every seedable register and its fields | Pro, Enterprise (`state_view`) |
| `GET /_rystic/egress` | the outbound effect log; Okta declares none, so it is always empty | Pro, Enterprise (`state_view`) |
| `GET /_rystic/events` | live activity stream (server-sent events) the state UI reads | Pro, Enterprise (`state_view`) |
| `POST /_rystic/seed` | land a register map | Pro, Enterprise (`state_set`) |
| `POST /_rystic/clock` | `{"time":"<RFC3339>"}` pins the clock, `{"advance_seconds":N}` moves it | Pro, Enterprise (`state_set`) |
| `POST /_rystic/seed-rng` | `{"seed":N}` fixes the RNG | Pro, Enterprise (`state_set`) |
| `POST /_rystic/egress-retain` | `{"retain":N}` bounds the egress log; `0` = unbounded | Pro, Enterprise (`state_set`) |
| `GET`, `POST /_rystic/network` | read or set a network profile: rejected requests and latency ([fault injection](https://www.rystic.ai/docs/control-plane.md#network-profiles-and-fault-injection)) | Pro, Enterprise (`faults`) |
| `GET /_rystic/ui` | the state UI | Pro, Enterprise (`ui`) |
| `GET /_rystic/ws` | open WebSocket connections; Okta has no WebSocket feed, so none | Pro, Enterprise (`state_view`) |
| `POST /_rystic/ws-drop` | cut WebSocket connections; there are none to cut | Pro, Enterprise (`faults`) |
| `GET /_rystic/activity` | the usage counters the CLI reads; any other caller gets 403 | every plan, loopback callers only |

## Scenarios

`design_studio` is the one shipped world, and the one Free boots: Website Design Contractors' org on `2026-03-01T00:00:00Z`. 36 users on `wdcontractors.example` — 33 `ACTIVE`, three `DEPROVISIONED` ex-staff — in ten groups: the `BUILT_IN` Everyone group and nine `OKTA_GROUP`s (Leadership, Development, Design, Content, Project Management, New Business, Operations, Freelancers, Okta Admins). Every profile carries title, department, manager and `managerId`, so the org chart walks down from the owner. The same staff are in the studio's Slack workspace. No applications are seeded.

```bash
curl -s -g -H 'Authorization: SSWS placeholder' \
  'localhost:8080/api/v1/users?search=profile.department%20eq%20%22Design%22'
# -> the eight designers, each profile naming its manager
```

To list the worlds: `GET /_rystic/scenarios` (Pro); `rystic docs okta-twin scenarios` prints the archive's `SCENARIOS.md` (the container image does not carry it); `--list-scenarios` on the installed binary works on every plan without a key:

```bash
~/.rystic/twins/okta-twin/0.0.1/okta-twin --list-scenarios
```

## Seeding state

Pro and Enterprise. `POST /_rystic/seed` takes a register map — `state_user`, `state_group`, `state_group_membership` (keyed `<groupId>/<userId>`), `state_application`, `state_app_user`, `state_app_group_assignment`, `state_user_factor`, `state_user_password`, `state_factor_challenge` — and lands it directly; `GET /_rystic/registers` lists every field. A scenario file booted with `--seed <file>` or `RYSTIC_SEED=<file>` wraps the same map in `seed`, beside optional `clock`, `rng_seed` and `network`; a bare register map boots too.

```bash
curl -X POST localhost:8080/_rystic/seed -d '{"state_user":{"00useed0000000000001":{"id":"00useed0000000000001","status":"ACTIVE","type":{"id":"otyseed000000000001"},"credentials":{"provider":{"name":"OKTA","type":"OKTA"}},"profile":{"firstName":"Seed","lastName":"User","email":"seed@example.com","login":"seed@example.com"}}}}'
curl -s -H 'Authorization: SSWS x' localhost:8080/api/v1/users/00useed0000000000001
```

Success returns 204. Only an unknown register or a record that is not a JSON object is refused (400, and an unknown register's error names every valid one); a record missing required fields, or carrying a misspelled one, is accepted with `WARNING — incomplete seed` in the log unless `RYSTIC_SEED_GATE=reject`.

## Determinism & reset

Execution is serialized, ids are a sequence (`00u00000000000000001`, `00g00000000000000001`, `0oa00000000000000001`) and the clock starts at a pinned instant — `2026-01-01T00:00:00Z` empty, `2026-03-01T00:00:00Z` in `design_studio` — and moves one millisecond per request that stamps a time (a write), never by wall time. Two identical runs from the same reset give byte-identical users and groups, down to `id`, `created`, `lastUpdated` and `statusChanged`.

1. **Free:** `POST /_rystic/reset` re-applies `design_studio`, clock and id sequence included.
2. **Pro, Enterprise:** reset wipes to empty and rewinds the sequence and the clock; `POST /_rystic/clock` and `POST /_rystic/seed-rng` pin the rest.

State is in memory: a restart boots the plan's world again — `design_studio` on Free; on Pro and Enterprise whatever `--scenario` or `--seed` names, or an empty org.

## Egress

None. Okta's model declares no outbound effects: the twin sends no email and calls no hook, and `GET /_rystic/egress` is always empty.

## Limitations

1. Nothing is sent and nobody signs in: activation and password reset send nothing, `activationUrl` and `resetPasswordUrl` name the twin's own origin and serve nothing, `lastLogin` never moves, and `LOCKED_OUT` is reachable only from a seed.
2. Every change happens in the request that makes it; nothing transitions on its own.
3. No password policy, and passwordless activation is not modelled: any password is accepted, and a user activated without one lands `ACTIVE` at once.
4. Factors are OKTA software TOTP only. Compute codes from the enrollment's `sharedSecret` at the twin's clock, not wall time. Other factor types enroll as `PENDING_ACTIVATION`; activating or verifying one answers 501.
5. Applications are bookmark only: another sign-on mode is stored without the credentials Okta would generate.
6. A `design_studio` record read as stored (a user, a group, a group's members) keeps `_links` on `https://wdcontractors.okta.com`, the org it was captured from: resolve the link's path against your org URL. Links the twin builds, every list page's `Link` header, the users list's `_links`, `activationUrl` and `resetPasswordUrl`, are on the origin you reached the twin on (RYS-1386).
7. No rate limits: no `X-Rate-Limit-*` headers, never a 429.
8. Ids and `errorId` values are the twin's own sequences, not Okta's random ids.

Fidelity is scored differentially against a live Okta Integrator Free Plan org with an SSWS token, per response field: the last full-suite round matched **143 of 143 probes on 2026-10-01**. The suite sends SSWS requests only, so it shows nothing about invalid credentials, admin privileges, scopes or OAuth; the `DEPROVISIONED`-user refusal (403 `E0000038`) is that org's policy with deactivated-profile edits turned off. Each release states its own number.

The full guide ships in the release as `HOWTO.md`; `rystic docs okta-twin` prints it.

## Release notes

Newest first.

### v0.0.1 — 2026-10-01

1. **A local Okta Management API.** `okta-twin` serves 49 routes of Okta's Management API — users and their lifecycle, groups and memberships, bookmark applications with their user and group assignments, password operations and OKTA software-TOTP factors — plus the `/_rystic/` control plane for reset, seeding and the clock. It accepts any non-empty `Authorization` header and never calls Okta, so no activation email is sent and no real directory changes. (RYS-1384)

2. **Okta's user lifecycle, two-step delete included.** Users move through `STAGED`, `ACTIVE`, `SUSPENDED` and `DEPROVISIONED` by the lifecycle routes. A first `DELETE` deactivates a user and a second purges it; a plain `GET /api/v1/users` leaves `DEPROVISIONED` users out while `search` and `filter` include them; and a profile update on a `DEPROVISIONED` user answers 403 `E0000038`, as an org with deactivated-profile edits turned off does. (RYS-1065)

3. **Password operations and software-TOTP factors.** Reset, change and expire a password, reactivate a user and reset their factors; list a user's factor catalog, and enroll, read, activate, verify and delete an OKTA `token:software:totp` factor with codes computed from its shared secret. Other factor types enroll but do not activate, and OAuth/OIDC and browser sign-in are not served. (RYS-1135)

4. **One shipped world, `design_studio`.** Website Design Contractors' Okta org: 36 users (33 `ACTIVE`, three `DEPROVISIONED` ex-staff) in ten groups, Everyone included, each profile carrying title, department and manager. Free boots it and `POST /_rystic/reset` returns to it; Pro and Enterprise boot an empty org unless `--scenario design_studio` names it. (RYS-1177)

5. **Measured against a live Okta org.** The 2026-10-01 full-suite round matched 143 of 143 probes against an Okta Integrator Free Plan org. `HOWTO.md` states what the suite leaves out and the differences this release ships with. (RYS-1384)

---

Source: https://www.rystic.ai/docs/simulators/okta · Markdown: https://www.rystic.ai/docs/simulators/okta.md
