Okta¶
The Okta LimaCharlie Extension exposes the incident-response and investigation surface of an Okta org (the Okta management API) to D&R rules and AI agents. It enables automated investigation and containment of an Okta account compromise directly from detections — suspend a user, reset their MFA, kill their sessions and OAuth tokens, revoke their app grants, quarantine them into a group, and pull the System Log for triage.
The extension provides two layers:
- Typed actions for the common containment, credential, MFA, session, and triage workflows, with friendly parameter names and built-in safety rails.
- A generic
api_callpassthrough for any Okta management endpoint not covered by a typed action.
Authentication is either a user-owned SSWS API token or an OAuth 2.0 "API Services" app (client credentials) — no user interaction, no delegated tokens. Okta's DPoP (sender-constrained token) requirement is handled transparently.
Setup¶
Pick one authentication mode.
Option A — SSWS API token (simplest)¶
- In the Okta Admin Console, go to Security → API → Tokens → Create Token and copy the token value (shown only once).
- Create the token from a dedicated service admin account, not a person's account: an SSWS token inherits the creator's privilege level, and it is revoked after 30 days of inactivity or if the creating account is deactivated.
Option B — OAuth API Services app (recommended)¶
- In the Admin Console, go to Applications → Create App Integration → API Services.
- On the app's General tab, switch client authentication to Public key / Private key and generate (or add) a key. Save the private key — a PEM or the private JWK Okta hands out.
- On the app's Okta API Scopes tab, grant the
okta.*scopes for the actions you will use (least-privilege set below), then admin-consent them.
Okta requires
private_key_jwtfor management scopes. The org authorization server rejects a plainclient_id+client_secretforokta.*scopes — use a private key. (Aclient_secretfield is accepted by the extension for completeness but expect Okta to reject it for these scopes.) New API Services apps also have DPoP locked on; the extension detects and satisfies this automatically.
Least-privilege scopes:
| Capability | Scope |
|---|---|
| Read users / lists | okta.users.read |
| User lifecycle / password / factor writes | okta.users.manage |
| Clear sessions / revoke tokens | okta.sessions.manage |
| Read groups | okta.groups.read |
| Group membership changes | okta.groups.manage |
| System Log | okta.logs.read |
The org authorization server silently drops requested-but-ungranted scopes from the minted token; a missing grant then surfaces as that endpoint returning 403 (
insufficient_scope), not a mint failure. Grant only what you use — every action degrades independently.
Subscribe to the extension¶
Subscribe to ext-okta from the LimaCharlie Marketplace (Extensions → Add-Ons).
Store the secret¶
In Secrets Manager, create a secret (for example okta-api-token or okta-private-key) and paste the SSWS token or the private key as its value.
Configure the extension¶
In Extensions → ext-okta → Configuration, fill in org_url plus the fields for your chosen auth mode:
| Field | Required | Value |
|---|---|---|
org_url |
yes | Okta org URL or host, e.g. https://acme.okta.com (admin / -admin URLs are normalized). |
api_token |
Option A | Reference to the SSWS-token secret, e.g. hive://secret/okta-api-token. |
client_id |
Option B | The API Services app client ID. |
private_key |
Option B | Reference to the private-key secret (PEM or private RSA JWK), e.g. hive://secret/okta-private-key. |
key_id |
no | kid for a PEM private key (JWKs carry their own). |
client_secret |
no | App client secret (alternative to private_key; rejected by Okta for management scopes). |
scopes |
no | OAuth scope override; empty uses an IR-oriented default set. |
Provide api_token or the OAuth fields, not both.
Actions¶
Every action that targets an entity requires an explicit selector (user_id, group_id + user_id, factor_id) — the extension refuses to run without one, preventing accidental org-wide containment.
user_id accepts either the Okta user id (e.g. 00u1a2b3c4...) or the user's login (e.g. alex@acme.com).
Pagination¶
Okta paginates with the HTTP Link header (rel="next"), not a body cursor. The list_* actions return {data: [...], pagination: {next_link}}; pass the opaque next_link back as the next_link parameter to fetch the next page (it is an absolute, self-contained URL — never hand-build it). limit is clamped to 200 for users/groups and 1000 for the System Log.
Generic¶
api_call¶
Generic passthrough to the Okta management API.
| Field | Type | Notes |
|---|---|---|
method |
enum | GET (default), POST, PUT, DELETE. |
path |
string | Required. Path relative to the org base (e.g. api/v1/users/{id}/lifecycle/reactivate) or a full Link: rel="next" URL. |
query |
object | Query-string parameters. |
headers |
object | Extra request headers. |
body |
object | JSON body for POST/PUT. |
User investigation¶
| Action | Parameters | What it does |
|---|---|---|
list_users |
q, search, filter, sort_by, sort_order, limit, after, next_link, extra_query |
List/search users. Use q for a starts-with search on name/email, search for an expression (e.g. status eq "ACTIVE" or profile.email eq "a@b.com"), or filter for the older syntax. |
get_user |
user_id |
Get one user. Read the returned status before a lifecycle action (see below). |
list_user_factors |
user_id + pagination |
List a user's enrolled MFA factors (id, factorType, status) — spot attacker-registered MFA. |
list_user_groups |
user_id + pagination |
List a user's group memberships (find a privileged group to strip, or confirm quarantine). |
list_user_grants |
user_id + pagination |
List a user's OAuth consent grants (which apps/scopes they authorized). |
list_user_roles |
user_id + pagination |
List a user's admin roles — is the account privileged (e.g. SUPER_ADMIN)? |
list_groups |
q, search, filter, limit, after, next_link, extra_query |
List/search groups. Only OKTA_GROUP-type groups accept membership changes. |
list_system_log |
since, until, filter, q, sort_order, limit, next_link, extra_query |
Query the System Log (/api/v1/logs). Scope with since/until (ISO-8601) and a SCIM filter, e.g. eventType eq "user.session.start", actor.id eq "00u...", or target.id eq "00u...". |
Read the user's status first: suspend needs ACTIVE, unsuspend needs SUSPENDED, activate needs STAGED/DEPROVISIONED, unlock needs LOCKED_OUT. A 400 on a lifecycle action is a state mismatch; a 403 is a scope/role problem.
User lifecycle¶
| Action | Parameters | What it does |
|---|---|---|
suspend_user |
user_id |
Block sign-in while preserving the account and assignments — the recommended reversible containment lever. Reverse with unsuspend_user. |
unsuspend_user |
user_id |
Return a suspended user to ACTIVE. |
deactivate_user |
user_id, send_email (default false) |
Deactivate/deprovision the user. Destructive — removes app access and is not a clean inverse of activate; prefer suspend_user. May complete asynchronously on large orgs. |
activate_user |
user_id, send_email (default false) |
Activate a STAGED/DEPROVISIONED user. With send_email=false the response includes an activation URL/token. |
unlock_user |
user_id |
Unlock a LOCKED_OUT user, returning them to ACTIVE. |
Credentials¶
| Action | Parameters | What it does |
|---|---|---|
expire_password |
user_id, temp_password (default false) |
Force a password change at next sign-in (PASSWORD_EXPIRED). With temp_password=true the current password is invalidated immediately and a one-time temp password is returned. |
reset_password |
user_id, send_email (default false) |
Trigger a reset (transitions the user to RECOVERY). With send_email=false the response carries a one-time resetPasswordUrl to deliver out-of-band. |
set_user_password |
user_id, password |
Set a specific new password directly — lock an attacker out with a known-only-to-you value. Must satisfy the org password policy. |
MFA¶
| Action | Parameters | What it does |
|---|---|---|
reset_user_factors |
user_id, remove_recovery_enrollment (default false) |
Unenroll all of a user's MFA factors, forcing re-enrollment — high-value against attacker-registered MFA. Does not change the password. |
reset_user_factor |
user_id, factor_id, remove_recovery_enrollment (default false) |
Unenroll a single factor (from list_user_factors). Note: removing a push/signed-nonce factor also removes the user's related Okta Verify factors. |
Sessions & OAuth tokens¶
| Action | Parameters | What it does |
|---|---|---|
clear_user_sessions |
user_id, oauth_tokens (default true), forget_devices (default false) |
Revoke all of a user's Okta sessions, forcing re-authentication. oauth_tokens defaults to true (the IR-safe choice) so refresh/access tokens are revoked too — Okta's own API default of false leaves them valid, the single most common containment mistake. forget_devices also clears remembered-device / factor-trust. |
revoke_user_grants |
user_id |
Revoke all of a user's OAuth consent grants across all clients — cut off OAuth-based persistence. Inspect first with list_user_grants. |
Clearing Okta sessions does not terminate sessions already established inside downstream apps (M365, Salesforce, …); those need the app's own session revocation.
Group containment¶
| Action | Parameters | What it does |
|---|---|---|
add_user_to_group |
group_id, user_id |
Add a user to an OKTA_GROUP — e.g. drop a compromised user into a quarantine / high-friction sign-on-policy group. Find the group id with list_groups. |
remove_user_from_group |
group_id, user_id |
Remove a user from a group — e.g. strip a compromised user out of a privileged/admin group. |
Containment sequencing¶
For a confirmed account takeover, the effective combination is: reset_user_factors (drop attacker MFA) → expire_password with temp_password=true or set_user_password (invalidate the credential) → clear_user_sessions (kill live sessions and OAuth tokens) → revoke_user_grants (cut OAuth persistence). Revoke sessions/tokens last so the attacker's live session can't outlive the other steps. Use suspend_user as the single reversible lever to stop everything first.
Detection & Response¶
Example response action that suspends the Okta user named in a detection:
- action: extension request
extension action: suspend_user
extension name: ext-okta
extension request:
user_id: '{{ .event/user_id }}'
Wrap literal strings in
{{ "..." }}. Values underextension requestare evaluated as templates. A bare string without{{ }}is interpreted as a gjson path against the event and, if it doesn't resolve, the key is silently dropped from the payload.
extension request actions are fire-and-forget — the rule engine does not surface the response back into the rule's evaluation context. Workflows that chain (find the user, reset factors, clear sessions, revoke grants) belong in a Playbook or an AI agent, which can hold state between calls.
Notes¶
- Two auth modes. SSWS is the fastest to set up but is tied to an admin account's privilege and dies after 30 days of inactivity. The OAuth API Services app (private_key_jwt) is decoupled from any person, uses short-lived scoped tokens, and is the direction Okta officially steers integrations.
- DPoP (sender-constrained tokens) is forced on for new API Services apps; the extension mints a plain token first and, if Okta answers
invalid_dpop_proof, transparently flips to DPoP for the token request and every API call. - A single
401on a call using an OAuth token is treated as a token-expiry race: the cached token is dropped and the request retried once with a fresh token. Rotating the secret in Secrets Manager recovers the same way — the next auth failure evicts the cached client and re-reads the secret. - Token-endpoint
429/5xxare backed off and retried; a data-endpoint429honors Okta'sRetry-Afterfor a bounded number of retries. - Error messages are formatted
okta api <status> on <method> <path>: <errorCode>: <errorSummary>, with query strings redacted. - Unsubscribing from the extension preserves its saved configuration; re-subscribing restores it without reconfiguration.