# Okta Integration

LFA plugs into Okta as a **step-up location factor**. Okta remains the identity
authority and proves who the user is. LFA proves where they are. The mechanism is
Okta's **IdP Authenticator**: an external OIDC IdP registered with
*IdP Usage = "Factor only"*, wrapped as an authenticator, and required by an
app's authentication policy.

:::note[Broker setups are configured with you]

Broker integrations need a signing key exchanged and your org's request objects
pinned on the LFA side, so this one is not self-service. Work through it with us
rather than in isolation. Email
[hello@octetproof.com](mailto:hello@octetproof.com) and we will validate the
end-to-end flow against your Okta org before you enable it for users.

:::

---

## How the pieces map

| Okta concept | LFA side |
|---|---|
| OIDC IdP, *Factor only* | LFA's `/authorize`, `/token`, JWKS endpoints |
| Signed authorize request | A broker client Octet pins to your org (see below) |
| IdP Authenticator | The "Octet UK presence" factor users see |
| App authentication policy | Where the factor becomes required |
| Username matching | The `preferred_username` claim, which is Okta's default (see below) |

Okta acts as a **broker**: it runs a fixed, standard token exchange and cannot
attach custom parameters to it. Two consequences shape the whole setup.

First, Okta cannot send an `octet_policy_id`, so the
[policy](/docs/concepts/policies/) is pinned per registered client. That means **one
client per policy, and therefore one IdP per policy**. This guide uses a single
`Octet UK presence` throughout as a running example. It is a name you choose, not
a fixed value. To enforce a second region you repeat the whole setup (a new LFA
client, a new Okta IdP, its own authenticator) named for that region
(`Octet US presence`, and so on). Nothing below is shared between regions except
the LFA endpoints.

Second, because Okta cannot add its own fields to the token exchange, LFA
authenticates the front channel instead. Your `/authorize` request is
cryptographically signed, and LFA trusts the signed request to say which user is
logging in. That is the core of this integration, and it is what the Okta-side
settings below configure.

## What Octet sets up for you

A broker client is configured on the LFA side by the Octet team. You do not edit
any server configuration yourself.

**One client, one policy.** Each client we register is pinned to a single region
[policy](/docs/concepts/policies/) on our side (its `default_policy_id`), because a
broker cannot choose a policy per login. Every region you want to enforce is its
own client and its own Okta IdP, so UK presence and US presence means two clients
and two IdPs rather than one shared setup. Name all the policies you need in step
1 and we register a client for each.

Setup takes **two exchanges with us**, and the order is forced: Okta generates
the key that signs your requests only when you create the IdP, and you cannot
create the IdP without the client ID we issue.

| # | Who | What happens |
|---|---|---|
| 1 | **You** | Send your Okta org URL and the [policy](/docs/concepts/policies/) you need, for example "UK presence" |
| 2 | **Octet** | Issues your `client_id` |
| 3 | **You** | Create the IdP in Okta with it (below) and click **Finish** |
| 4 | **You** | On the IdP page click **Download public key**, send us that JWK |
| 5 | **Octet** | Pins the key and confirms the client is live |
| 6 | **You** | Finish the remaining Okta screens and test |

:::caution[Send the IdP's key, not your org's]

Step 4 means the key on the **Identity Provider page**, generated for this IdP.
It is not the org signing key at `https://<your-org>.okta.com/oauth2/v1/keys`.
Those are different keys, and Okta signs request objects with the IdP's key. If
we pin the org key, every login fails verification with an error visible only in
our logs.

:::

Your **redirect URI** is not something you choose. Okta fixes it per org, and it
is shown on the IdP page:

```
https://<your-org>.okta.com/oauth2/v1/authorize/callback
```

Octet pins your issuer and that signing key to your broker client. From then on
LFA accepts a signed `/authorize` from your org and **rejects an unsigned or
wrongly-signed one**. That fail-closed behaviour is the point.

:::caution[Notify Octet when you rotate signing keys]

LFA verifies your requests against the key Octet has pinned. It does not fetch
new keys automatically (automatic refresh is a planned follow-on). If you
regenerate this IdP's keypair, or delete and recreate the IdP, **send Octet the
new public key first** (again via **Download public key**). Otherwise signed
requests start failing verification the moment the old key stops being used, and
the error appears only in our logs, not yours.

:::

See [Prerequisites](/docs/getting-started/prerequisites/) for what to gather before you
reach out.

## Okta-side configuration

### 1. Identity Provider

**Security -> Identity Providers -> Add -> OpenID Connect.** One per policy, using
the client credentials Octet issues.

| Setting | Value |
|---|---|
| *IdP Usage* | **Factor only** |
| Client ID | The value Octet issued you |
| *Authentication type* | **Public key / private key**, letting Okta generate the keypair |
| *Enable signed requests* | **ON** (required) |
| Algorithm | `RS256` |
| *Require PKCE* | **ON** |
| *Application context* | Off |
| *IdP username* | **Leave at default** (see step 2) |
| *JIT Settings -> Profile Source* | **Off** |

**Endpoints, to copy exactly.** Okta does not read the discovery document, so all
five are typed in by hand. They are the same for every customer:

| Okta field | Value |
|---|---|
| Issuer | `https://factor.octetproof.com` |
| Authorization endpoint | `https://factor.octetproof.com/authorize` |
| Token endpoint | `https://factor.octetproof.com/token` |
| JWKS endpoint | `https://factor.octetproof.com/.well-known/jwks.json` |
| Userinfo endpoint | `https://factor.octetproof.com/userinfo` |

No trailing slashes, and the issuer has **no path**. You can check these against
the live discovery document at any time:

```bash
curl -s https://factor.octetproof.com/.well-known/openid-configuration | jq
```

Four of those settings need explanation.

**Userinfo is not optional.** Okta builds the user profile from the **UserInfo
response**, not the id_token. Leaving it blank fails the login.

**Signed requests also change client authentication.** Turning them on switches
Okta to `private_key_jwt` at the token endpoint, signed with the keypair Okta
generates and verified by LFA against your pinned key. **There is no client
secret to enter.** Okta hides the field in this mode, and Octet does not issue
you one for a broker client.

**RS256 is required.** Okta verifies only RS256 and rejects LFA's default ES256.

**Leave JIT off.** JIT provisioning lets an external IdP create or update Okta
user profiles. LFA is a factor, not an identity source, and Okta remains the
authority on who the user is. It matters practically too: LFA emits `email` with
`email_verified: false`, because that value is the hint your org signed rather
than an address we verified. With *Profile Source* enabled, that unverified value
could overwrite the real email on the Okta user record. Left off, the claim is
only ever a matching input.

### 2. Username matching

**Leave *IdP Username* at its default.**

A Factor-only IdP always matches on `idpuser.preferredUsername` and **ignores
whatever you type in that field**. LFA emits `preferred_username` for broker
clients precisely so the default works.

You do **not** need a Profile Editor attribute, and a custom expression has no
effect. If you set one, Okta still evaluates `preferredUsername`, and the login
fails with a message that does not mention usernames at all.

### 3. The authenticator

The IdP by itself is inert. Okta offers it to users only once it is registered as
an authenticator.

**Security -> Authenticators -> Setup tab -> Add authenticator -> Identity
Provider.** Select the IdP you just made, and give it the name users will see at
sign-in, for example `Octet UK presence`. Save.

### 4. Enrollment policy

**Security -> Authenticators -> Enrollment tab** -> edit the policy that covers your
users (the Default Policy, unless you have added others) -> set the new
authenticator to **Optional**. Save.

Without this the authenticator exists but no one can use it, and it does not
appear in the policy picker in the next step.

### 5. The app policy

This is where the factor gets exercised. You need **an app to sign in to** and
**a policy that forces the factor** for that app.

**a. An app.** If you are only testing, make a throwaway one:
**Applications -> Create App Integration -> OIDC -> Web Application.** Give it any
redirect URI. A dead `http://localhost:8080/authorization-code/callback` is fine,
and you never need a server there. Note two things from its **General** tab: the
**Client ID**, and the **exact Sign-in redirect URI** Okta saved. It may differ
from what you typed, so copy it verbatim. You need it to launch the test.

With **Federation Broker Mode** on (the default for a new OIDC app), the app is
implicitly assigned to all users and access is governed entirely by its sign-on
policy, so there is no separate assignment step. If you turned that off, assign
yourself under the app's **Assignments** tab. Otherwise the policy below never
runs and the login silently skips the factor.

**b. A policy that forces Octet.** **Security -> Authentication Policies -> Add a
policy**, pick the **app sign-in policy** type (labelled *"OIDC"* or *"Okta
Sign-On"*, **not** account-management). A new policy comes with one **Catch-all
Rule**. Edit that rule rather than adding another:

- *User must authenticate with*: **Password + Another factor**
- *Possession factor constraints are*: **uncheck every box.** A new rule ships
  with **Device Bound** required, and an external-IdP factor is not device bound,
  so leaving it ticked silently removes Octet from the options.
- *Authentication methods*: **Allow specific authentication methods**, and add
  exactly **Password** and **Octet UK presence**, nothing else.

That last setting is what forces the factor, and it is easy to get wrong:

:::caution[Do not leave the rule as "any 2 factors"]

If the rule allows any second factor (the default), Okta satisfies it with
whatever the user already has enrolled, usually Okta Verify, and **never prompts
for Octet at all.** The login looks like 2FA while the location factor stays
optional. Restricting *Authentication methods* to Password and Octet is what
makes presence mandatory.

:::

**c. Attach the app to the policy.** Easy to miss, and nothing works without it:
on the policy's **Applications** tab, **Add app** and pick the app from (a). A
policy governs nothing until an app points at it.

### Setup checklist

- The authenticator is **Active** on Authenticators -> Setup.
- Your test app is listed on the policy's **Applications** tab.
- The rule's **Authentication methods** list is exactly *Password* and
  *Octet UK presence*, and every possession-factor constraint is **unchecked**.

## Testing the login

Launch the app's sign-in directly at Okta. No running app is needed behind it.
Build this URL, substituting your app's **Client ID** and the **exact** redirect
URI from step 5a:

```
https://<your-org>.okta.com/oauth2/v1/authorize?client_id=<CLIENT_ID>&response_type=code&scope=openid+profile+email&redirect_uri=<REDIRECT_URI>&state=test&nonce=test
```

Open it in a **fresh private or incognito window**. Expected sequence:

1. Okta login: username and password.
2. **"Verify with Octet UK presence"**. On the first run Okta enrols it in the
   same step. If you instead see only Okta Verify and Password, the rule is not
   forcing the factor, so recheck step 5b.
3. The app redirects to your phone, and the phone proves presence.
4. The browser lands on your dead redirect URI showing
   `.../callback?code=...&state=test`.

**That final `?code=...` on a dead page is success.** It means Okta completed the
whole exchange and issued an authorization code. The connection error is expected,
because you never ran a server there.

:::caution[After any policy change, start a brand-new sign-in]

Okta snapshots the policy at the moment a sign-in begins and holds it for the
whole flow. A tab you started before a change keeps the old rules no matter how
many times you save. Always retest in a fresh private window, never the error
screen's **Verify** button, which resumes the old transaction.

:::

## What travels where

1. **Okta -> LFA `/authorize`.** Okta signs the request as a JWS request object
   (`request=<JWS>`). LFA verifies it against the key pinned to your broker
   client. The issuer, audience, and expiry must check out, and the parameters
   inside the signed object take precedence over anything in the URL. The
   `login_hint` **inside** that signed object is the user LFA binds the device
   to. A signed request that carries **no** `login_hint` is rejected.

2. **Phone -> LFA.** A signed location proof plus Apple App Attest, carrying a
   **region verdict only, no raw coordinates**. See
   [Trust & Privacy](/docs/concepts/trust-and-privacy/).

3. **LFA -> Okta.** An RS256 id_token (verifiable against LFA's JWKS) with:
    - `acr: urn:octet:loc:strong`, meaning hardware-attested presence with the
      policy passed.
    - `octet_location_assertion`, the signed region-verdict statement.
    - `preferred_username`, the hint from the signed request. This is what Okta
      matches the user on.
    - `octet_login_hint`, plus `email` with `email_verified: false` when the hint
      is an address. The same claims are returned from `/userinfo`, which is
      where Okta reads them.

The device binds to the `login_hint` from the **signed** request, never a raw
value from the browser's URL. A tampered front-channel request therefore cannot
bind a victim's account: editing the hint breaks the signature, and the request
is rejected.

## Device replacement and recovery

A broker login binds the user's phone on first success, the same as any LFA login
(see [Device Lifecycle](/docs/concepts/device-lifecycle/)). Because Okta cannot send
device-enrollment fields on the token exchange, a broker user's **new or
different phone is rejected** (`invalid_grant`) rather than silently rebinding.

To move a user to a new phone, ask Octet to **revoke the binding**
(`POST /v1/device/revoke`). The user's next login then binds the replacement
phone automatically.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `/authorize` rejected as an unsigned or invalid request | Signed requests are off, or the key Octet pinned is not the one Okta is signing with | Turn **Enable signed requests** on. Otherwise re-send the key from **Download public key** on the IdP page, not `/oauth2/v1/keys`, which is a different key and a common mix-up |
| **"Octet UK presence" never appears** at the verify screen, only Okta Verify and Password | The rule is not forcing it: it allows any 2 factors, a possession-factor constraint (Device Bound) is filtering the external-IdP factor out, or (with Federation Broker Mode off) you are not assigned to the app | Step 5b: set *Authentication methods* to **Allow specific -> Password + Octet**, uncheck all possession constraints, and confirm the app is assigned |
| `Authorization error: signed request object failed verification` on LFA's page | The public key on file does not match the one Okta is signing with | Re-send the key from **Download public key** on the IdP page so Octet can re-pin it. This mismatch does not show up in Okta's own logs |
| Login rejected with no bind user | The signed request reached LFA without a `login_hint` | Ensure Okta is sending `login_hint` on the authorize. A broker request without it is rejected |
| "Unable to verify authenticator", or "The UserInfo response is invalid" | Okta reports several unrelated failures with these two strings, and neither describes the real cause | Open **Reports -> System Log**, expand the `Authenticate user via IDP` failure, and read `debugContext.debugData.errorMessage`. It names the exact check that failed. The summary text does not |
| Changing *IdP Username* makes no difference | Expected. A Factor-only IdP ignores that field | Leave it at the default. Matching is always on `preferred_username` |
| Login fails immediately after a config change, but the settings look right | An in-flight sign-in keeps the configuration it started with | Retest from a fresh private window, not the error screen's **Verify** button |
| User's new phone rejected (`invalid_grant`) | The user is already bound to a previous device | Ask Octet to revoke the binding. The next login rebinds the new phone |
| QR code or login expires before completion | The login session (90 s) or the authorization code (60 s) lapsed | Restart the login. Presence proofs are deliberately short-lived |

## Where to go next

- [Policies](/docs/concepts/policies/). What the pinned policy enforces.
- [Device Lifecycle](/docs/concepts/device-lifecycle/). Binding, revocation, and new
  phones on the broker path.
- [Direct OIDC integration](/docs/integration/direct-oidc/). The same endpoints without
  a broker, where your token exchange sends `octet_user_id` directly.
