# OIDC Reference

The complete wire contract: endpoints, protocol requirements, parameters, claims,
and errors. Discovery is live at
`https://factor.octetproof.com/.well-known/openid-configuration`.

---

## Endpoints

| Endpoint | URL |
|---|---|
| Issuer | `https://factor.octetproof.com` |
| Authorization | `https://factor.octetproof.com/authorize` |
| Token | `https://factor.octetproof.com/token` |
| UserInfo | `https://factor.octetproof.com/userinfo` |
| JWKS | `https://factor.octetproof.com/.well-known/jwks.json` |
| Device revocation (LFA extension) | `https://factor.octetproof.com/v1/device/revoke` |

## Protocol requirements

LFA implements only the authorization-code flow, with these requirements:

| Requirement | Value |
|---|---|
| Response type | `code` only |
| Grant | `authorization_code` only, with no refresh tokens |
| PKCE | **S256 required.** An `/authorize` request without a code challenge is rejected |
| `state` and `nonce` | **Both mandatory** (stricter than the OIDC spec, which only recommends `state`) |
| Client authentication | At `/token`: `client_secret_post`, `client_secret_basic`, or `private_key_jwt` (RFC 7523 §2.2, broker clients, verified against the same pinned keys as the request object). `/v1/device/revoke` accepts a client secret only |
| Authorization code | Single-use (enforced atomically), **60-second** redemption window |
| id_token signing | **ES256 by default, RS256 available per client.** Both keys are published at the JWKS endpoint with distinct `kid`s, and the id_token header names the one that signed it. Ask us to switch your client to RS256 if your stack cannot verify EC signatures. Okta, for example, verifies only RS256 |
| Scopes | `openid` and `octet.location`, the only scopes advertised. Extras a library adds (`profile email`) are ignored |
| UserInfo | Accepts the access token only, which is valid for 120 seconds. An id_token or location assertion presented as a bearer token is rejected (401) |

## `/authorize` parameters

Beyond the standard authorization-code parameters:

| Parameter | Purpose |
|---|---|
| `octet_policy_id` | Selects which registered [policy](/docs/lfa/concepts/policies/) this login must satisfy. Omit for the client's pinned default. An unknown id is rejected |
| `login_hint` | Optional and unverified, echoed back as the `octet_login_hint` claim |
| `request` | A signed request object (RFC 9101 JAR). **Required for broker clients, ignored for direct clients.** See below |

### Signed request objects (brokers)

Identity brokers, such as Okta's IdP-Authenticator "Factor only", run a fixed, standard token exchange and cannot add the `octet_user_id`
that a direct client sends at `/token`. A broker instead authenticates the front
channel: it signs its `/authorize` request as a JWS and passes it as
`request=<JWS>`, and the user it binds is taken from inside that signed object.

A client is a broker client when Octet has registered your broker's issuer and
request-signing public keys for it. This is fail-closed: once a client is a
broker, an `/authorize` without a valid signed object is rejected. For a direct client a
stray `request=` is ignored.

| Check | Rule |
|---|---|
| Verification | Verified against the broker's **pinned** keys only, with no network key fetch. `RS256` and `ES256` only. `none` and `HS*` are rejected |
| `iss` | Must equal the configured broker issuer |
| `aud` | Must equal LFA's own issuer (`https://factor.octetproof.com`) |
| `exp` / `nbf` | Must be valid, in a strict 3-segment JWS |
| `client_id` | The object's `client_id` must equal the query `client_id` (RFC 9101 §5) |
| `login_hint` | **Required inside the object.** Becomes the broker-authenticated binding identifier. An object with no `login_hint` is rejected |

Parameters inside the signed object take precedence over the query string:
`response_type`, `redirect_uri`, `scope`, `state`, `nonce`, the PKCE challenge
and method, `acr_values`, and `octet_policy_id`.

:::note
The device binds only to the `login_hint` from the **signed** object, never the
raw front-channel query `login_hint`. A browser that edits the query `login_hint`
to a victim's id breaks the signature, so the request is rejected. See
[Okta integration](/docs/lfa/integration/okta/) for the broker setup.
:::

## `/token` parameters

Beyond the standard code-exchange parameters (`grant_type`, `code`,
`redirect_uri`, client credentials, `code_verifier`):

| Parameter | Purpose |
|---|---|
| `octet_user_id` | **Required for direct clients.** Your stable, opaque identifier for the user signing in. Drives [device-to-user binding](/docs/lfa/concepts/device-lifecycle/) |
| `octet_device_enroll` | Optional. `true` replaces the user's bound device with the one that just proved presence, revoking the old device. Send only after your own step-up verification |

There are two binding sources, depending on the client:

- **Direct clients** send `octet_user_id` on the RP-authenticated `/token`, as
  documented above, and use `octet_device_enroll` for device replacement.
- **Broker clients** bind on the `login_hint` from the signed request object at
  `/authorize` (see [Signed request objects](#signed-request-objects-brokers)) and **cannot** send
  `octet_user_id` or `octet_device_enroll`. Enrollment is forced off, so a new or
  different device for an already-bound user is rejected (`invalid_grant`).
  To recover, ask Octet to revoke the binding with `POST /v1/device/revoke`. The next
  login binds the new phone automatically. A broker session that reaches `/token` with no
  authenticated hint is rejected regardless of attestation.

The token response:

```json
{
  "token_type": "Bearer",
  "access_token": "...",
  "expires_in": 120,
  "id_token": "...",
  "location_assertion": "..."
}
```

`location_assertion` is a top-level mirror of the `octet_location_assertion`
claim, for stacks that do not want to open a nested JWT.

## id_token claims

| Claim | Meaning |
|---|---|
| `iss` | `https://factor.octetproof.com` |
| `sub` | The bound user id, returned verbatim: the `octet_user_id` you supplied at `/token` for a direct client, or the signed-request `login_hint` for a broker client |
| `aud` | Your `client_id` |
| `exp`, `iat` | Standard timestamps |
| `auth_time` | When the location proof was accepted, which is the moment the user authenticated |
| `nonce` | Echo of your `nonce`. Verify it |
| `acr` | `urn:octet:loc:strong`, meaning the proof was hardware-attested (Apple App Attest on iOS, hardware key attestation on Android) and the policy passed |
| `amr` | `["octet-loc"]` |
| `token_use` | `id` (the access token carries `access`) |
| `octet_login_hint` | Echo of the `login_hint`, if any. **Unverified** for a direct client (front-channel passthrough). For a broker client it is the signature-verified hint from the request object, the same value bound as `sub` |
| `preferred_username` | **Broker clients only.** The signed hint, echoed back so account-matching works with default configuration. Not emitted for direct clients |
| `email`, `email_verified` | **Broker clients only**, and only when the signed hint is an email address. `email_verified` is **always `false`**, because LFA verifies location rather than mailboxes. Do not treat it as a verified address |
| `octet_location_assertion` | The signed location assertion (nested JWS) stating the region verdict |

### Why brokers get identity claims and direct clients do not

A direct client's `login_hint` is an ordinary query parameter. Anyone can put any
value there, so reflecting it into `email` or `preferred_username` would let a
caller impersonate someone to their own RP. A **broker's** hint arrives inside a
signature-verified request object, signed by a key LFA has pinned, and LFA returns
it only to the party that signed it. LFA does not vouch for the identity. It
returns the broker's own assertion, which is why `email_verified` stays `false`.

### `GET /userinfo`

Returns the same identity claims as the id_token: `sub`, `octet_login_hint`, and
(for brokers) `preferred_username`, `email`, `email_verified`. Some products build
their user profile from **UserInfo rather than the id_token**, so the two return
the same identity claims. Present the **access token** as a bearer token. An id_token
or location assertion is rejected with 401.

## The location assertion

A compact ES256 JWS, verifiable against the same JWKS, asserting the region
verdict for this login. It expires **120 seconds** after issue and is bound to the
login's nonce. It is a statement of presence at that moment, for audit use, not
a credential. The subject is device-rooted (the enrolled device, not your user id),
so the assertion can be verified downstream without carrying your user
identifiers.

## `POST /v1/device/revoke`

Revokes the device bound to one of your users. Use it when a phone is lost or
stolen, and to move a broker user to a new phone. Direct clients call it themselves. Broker clients hold no client secret, so Octet calls it for them. See [Device lifecycle](/docs/lfa/concepts/device-lifecycle/).

| | |
|---|---|
| Authentication | Client secret only (`client_secret_post` or `client_secret_basic`). `private_key_jwt` is not accepted here |
| Body | `octet_user_id=<the user whose device to revoke>`. For a direct client this is the `octet_user_id` you send at `/token`. For a broker client it is the `login_hint` value from the signed request object |
| Effect | The user's bound device is denied from the next proof submission onward. Their next successful login on a new phone binds it automatically |
| Response | `200 {"revoked": true, "device_id": "<id>"}` |
| Errors | `404 no_device` when no active device is bound to that user. `401 invalid_client` for bad credentials. `400 invalid_request` when `octet_user_id` is missing |
| Scope | Per client. You can only revoke bindings under your own `client_id` |

## Errors

| Error | Where | Meaning |
|---|---|---|
| Error page on LFA: `unknown client_id` | `/authorize` | The `client_id` is not registered. LFA shows its own page (HTTP 400) and does not redirect |
| Error page on LFA: `redirect_uri not registered for this client` or `missing required parameter: redirect_uri` | `/authorize` | The `redirect_uri` must match a registered one exactly. Email [hello@octetproof.com](mailto:hello@octetproof.com) to add one |
| Error page on LFA: `this client must send a signed request object` | `/authorize` | A broker client sent no `request=` object |
| Error page on LFA: `signed request object failed verification` | `/authorize` | The object failed signature, `iss`, `aud`, `exp`, or `nbf` verification, or used an unsupported algorithm |
| Error page on LFA: `request object client_id mismatch` | `/authorize` | The `client_id` inside the signed object does not match the query `client_id` (RFC 9101 §5) |
| Error page on LFA: `request object must carry login_hint` | `/authorize` | A broker's signed object carried no `login_hint`, so there is no user to bind |
| `invalid_request` (PKCE, `state`, or `nonce` missing) | `/authorize` | The request does not meet the [protocol requirements](#protocol-requirements) |
| `invalid_scope` (`scope must include openid`) | `/authorize` | Add `openid` to the requested scopes |
| `unsupported_response_type` | `/authorize` | Only `response_type=code` is supported |
| `invalid_request` (`unknown octet_policy_id`) | `/authorize` | The policy id is not registered for your client. LFA does not fall back to another policy |
| HTTP 429 (`try again shortly`) | `/authorize` | LFA could not start a login session. Retry after a short wait |
| `access_denied` | callback redirect | The phone's proof failed verification or policy: wrong region, stale proof, spoof verdict below the floor, attestation failure, or revoked device |
| `invalid_grant` (code expired or already redeemed) | `/token` | Codes are single-use and live 60 seconds |
| `invalid_client` (HTTP 401) | `/token` | The client credentials are missing or wrong |
| `invalid_grant` (`PKCE code_verifier does not match challenge`) | `/token` | The `code_verifier` is not the one whose S256 hash you sent as `code_challenge` |
| `invalid_grant` (`redirect_uri mismatch`) | `/token` | The `redirect_uri` differs from the one sent at `/authorize` |
| `invalid_grant` (`client_id does not own this code`) | `/token` | The code was issued to a different client |
| `invalid_request` (`octet_user_id is required`) | `/token` | Exchanges from a direct client must carry the user identifier |
| `invalid_grant` (`this device is bound to a different user`) | `/token` | The proving phone belongs to another account |
| `invalid_grant` (`a new device requires enrollment`) | `/token` | The user already has a bound device. See [Device lifecycle](/docs/lfa/concepts/device-lifecycle/) |
| `401` | `/userinfo` | The bearer token is not an access token (`token_use` mismatch) |
