# Browser-direct sign-in, tokens and member projection

Use the SSO [base URL, context, handles and proof rules](https://m7.org/docs/api/sso.user.m7.org/browser-direct.md).
The operations below have no confidential client authentication and require
browser-direct authentication enabled. Only registered scopes/audiences can be
issued. Unknown fields are rejected.

## Password and provider sign-in

| Method / relative path | Exact input | Success and lifecycle |
| --- | --- | --- |
| POST `/challenge` | Authorization context | `transaction`, `challenge`, `expires_in: 300`; no DPoP JWT yet |
| POST `/login` | `transaction`, `username` (≤320), `password` (≤4096), `dpop` (≤16384); optional `signup_transaction` | `code`, echoed `state`, `expires_in: 120`, or `outcome: signup_required` with signup transaction/status |
| GET `/authorize` | Context as query parameters plus `provider`: `github`, `discord` or `m7` | 303 provider navigation; validated callback receives `code,state` or `error,error_description,state` |
| POST `/token` | Grant-specific body below and fresh header proof | Token package, or `outcome: two_factor_required` |
| POST `/token/ack` | Session handles, `activation_id`; optional `selection_id` | Activation receipt described below |
| POST `/me` | Session handles and fresh endpoint proof | Scope-limited tenant profile |

For example, send this body to `/challenge` (replace every placeholder with a
fresh valid value; the challenge and thumbprint must each be 43 base64url characters):

```json
{
  "redirect_uri":"https://app.example.org/auth/callback",
  "state":"<fresh unpredictable state>","nonce":"<fresh nonce>",
  "code_challenge":"<S256 challenge>","code_challenge_method":"S256",
  "dpop_jkt":"<signing-key thumbprint>",
  "scope":"openid profile email","aud":"https://api.example.org"
}
```

The example audience must first be registered for the application. After the
challenge response, `/login` receives:

```json
{"transaction":"<password transaction>","username":"sample.member","password":"<password>","dpop":"<signed login proof including returned challenge>"}
```

For password login, sign a fresh `/login` proof containing the returned
`challenge` and send it in JSON `dpop`. The password challenge is consumed
before credentials are checked; a wrong password or lost response requires a
new challenge. A username or unambiguous verified email can identify the member.
Credentials and groups are checked within the application's organization.
Consumer-linked members use their linked credentials but keep tenant ownership,
projection and tenant-factor policy. A disabled account does not become active
merely because its password is correct. Only a proved incomplete signup can
return the [signup continuation](https://m7.org/docs/api/sso.user.m7.org/browser-direct-registration-recovery.md).

Provider context expires after ten minutes. Validate callback state and preserve
the original key/verifier. A login-only miss is `access_denied`; it never creates
or links an account by matching email. Use explicit provider signup or linking
for those operations. A provider callback is one-use. Current federation,
member and group policy is rechecked before issuance. Brokered/federated sign-in
never requires local tenant 2FA and does not assert that the broker performed MFA.
Closing navigation discards local state; there is no password/provider-login
cancel endpoint. Restart after expiry, denial or lost one-use completion.

## Token pickup and two-factor continuation

```json
{
  "grant_type":"authorization_code",
  "code":"<one-use code>",
  "code_verifier":"<original 43–128 character PKCE verifier>",
  "redirect_uri":"https://app.example.org/auth/callback"
}
```

These four fields are required for code pickup. An optional **pair**
`bd_carrier,bd_key` attaches a newly authenticated session to an existing carrier;
do not include `bd_session` on this grant. Retain the original callback and S256
verifier (43–128 characters from letters, digits, `.`, `_`, `~`, `-`). The code
expires after 120 seconds and is consumed before upstream issuance. It cannot
be redeemed again to recover a lost initial token response.

When local tenant 2FA is required, pickup consumes this code and returns:

```json
{"outcome":"two_factor_required","continuation":"<opaque continuation>","expires_in":300}
```

No usable token or new member session is released. Submit
`POST /two-factor/login` with `continuation`, `code` and optional boolean
`recovery` (default false), using the original signing key. Successful proof
returns a new `code`, original `state`, `expires_in: 120` and boolean
`two_factor_disabled`. Redeem that new code with the **original PKCE verifier**.
See [tenant two-factor](https://m7.org/docs/api/sso.user.m7.org/tenant-two-factor.md) for attempts, expiry and backup-code
behavior. Lost continuation or consumed proof requires fresh sign-in.

## Token package and custody

```json
{
  "access_token":"<provisional access token>",
  "token_type":"DPoP",
  "expires_in":300,
  "scope":"openid profile email",
  "token_state":"pending",
  "bd_carrier":"<carrier>",
  "bd_session":"<member session>",
  "bd_key":"<carrier secret>",
  "dpop_jkt":"<key thumbprint>",
  "activation":{
    "id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
    "expires_in":60,
    "ack_endpoint":"https://id.m7.org/api/v2/oauth/token/ack"
  }
}
```

The example lifetimes are illustrative; use returned values. Public fields are
`access_token`, `token_type`, `token_state` (`active` or `pending`), the three
handles, `dpop_jkt`, and optional `expires_in`, `scope`, `activation`,
`selection_id`, `session_cleanup_pending`. `activation` appears for pending
tokens and contains only `id`, `expires_in`, `ack_endpoint`. A pending switch
can also carry a `selection_id`. No refresh token, ID token, activation secret,
binding chain or private root data is returned. Rely on `/me` for browser profile
display and the protected resource's token validator for authorization.

Save the package before ACK. Pending access is unusable. Send the HTTP request
to this app's `/token/ack`, even though the proof's `htu` is the fixed Identity
ACK URL shown above. The SSO service supplies its held activation secret.

```json
{
  "bd_carrier":"<carrier>","bd_session":"<member session>","bd_key":"<carrier secret>",
  "activation_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
}
```

Include the returned `selection_id` when activating a pending selection.
Successful ACK returns `token_state: active`, normalized `activation_id` and
boolean `idempotent`, optionally `session_cleanup_pending`. It does not return
a replacement access token: use the saved package after confirmed activation.
Repeated ACK with a fresh proof can return an idempotent receipt. Never copy
the ordinary confidential [ACK request](https://m7.org/docs/api/sso.user.m7.org/token-acknowledgement.md) into the browser.

```mermaid
sequenceDiagram
  participant B as Browser
  participant S as SSO
  participant I as Identity
  B->>S: Code + verifier + endpoint proof
  S->>I: Bound token request
  I-->>S: Pending token package
  S-->>B: Public pending package + handles
  Note over B: Save package; do not use token yet
  B->>S: ACK + proof targeting Identity ACK URL
  S->>I: ACK with server-held activation secret
  I-->>S: Active receipt
  S-->>B: Active receipt; commit selection
```

## Refresh and interrupted activation

`POST /token` refresh accepts only `grant_type: refresh_token`, session handles
and optional `scope` narrowing. It takes no browser refresh-token value. It
renews the currently selected session; use `/sessions/switch` for another one.
The server checks current member/app policy, scope/audience admission, IP and
token binding. Refresh and switching do not make primary authentication fresh.

```json
{"grant_type":"refresh_token","bd_carrier":"<carrier>","bd_session":"<selected session>","bd_key":"<carrier secret>"}
```

Browser-direct uses the server's ACK/supersession lifecycle. A confirmed
predecessor refresh credential remains authoritative until its successor ACK
commits. If a pending package was already saved, refresh/switch returns that
package instead of rotating again; its scope cannot be changed while pending.

| Interruption | Required response |
| --- | --- |
| Pending package received, ACK response lost | Retry ACK with a new proof and the same activation/selection receipt |
| Renewal response lost but handles retained | Refresh/switch with fresh proof to recover server-saved pending package; then ACK |
| Wrong proof/key, network error, 5xx or malformed ACK response | Preserve pending state and predecessor; do not declare activation or discard recovery authority |
| Terminal ACK error: `invalid_grant`, `unknown_activation`, `unknown_activation_lineage`, `activation_not_pending`, `activation_not_current`, `activation_parent_not_current`, `activation_parent_invalid`, `activation_expired` | Discard terminal pending state, preserve confirmed predecessor, and attempt one fresh renewal; sign in if that fails |
| Initial pickup lost before any handles were received | Fresh sign-in; authorization-code replay is not a recovery mechanism |
| `session_changed` for a stale selection | Reload carrier list and selected account; never install an obsolete selection |

Do not claim every upstream interruption is automatically recoverable. Selected
account changes commit only with an active token; denial leaves the previous
selection intact and never falls back to a different remembered account.

## Email-code and magic-link sign-in

These five POSTs require endpoint DPoP, an admitted origin and the email-sign-in
allowance. They do not require a selected session or `email` scope merely to
prove ownership; scopes for the eventual token come from the context.

| Relative path | Exact input | Output |
| --- | --- | --- |
| `/email-login/start` | Context plus `email` (valid address, ≤254; trimmed/lowercased) | `transaction`, `status`, `expires_in`, `request_id`, `resend_after`, entered `email`, generic `message` |
| `/email-login/resend` | `transaction` | Current view with new `request_id` and cooldown |
| `/email-login/verify` | `transaction`, `request_id` UUID, `method: code` or `link`, `proof` | Updated view; completed includes one-use `code` and `state` |
| `/email-login/status` | `transaction` | Current view; completed receipt can recover its code while still valid |
| `/email-login/cancel` | `transaction` | Cancelled view before completion starts |

`proof` is a six-digit string for `code` or the 43-character base64url fragment
token for `link`. The transaction stays bound to app, org, origin, original
browser key and authorization context. Unknown, unverified, ambiguous,
ineligible or member-throttled addresses receive the same generic send view and
no usable email. The email echo is the entered address, not an ownership oracle.
Current unique verified ownership, admission and policy are checked again
before completion. Native, federated-only and consumer-linked tenant members
can qualify; email proof neither links providers nor merges accounts.

```json
{"transaction":"<email transaction>","request_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","method":"code","proof":"123456"}
```

One email offers both alternatives; first successful consumption invalidates
both. Magic links return to the registered callback with fragment fields
`email_signin=1`, `state`, `request_id`, `token`. Remove the fragment immediately,
hold it only in memory, validate original state/request, and require an explicit
confirmation POST. A GET or preview must not consume it. Forward confirmation
only to the original browser/tab with its key and transaction. If the link opens
elsewhere, enter the emailed code in the original browser; do not adopt the
other browser's authority or silently begin a different account session.

States are `verification_required → completing → completed`, or `cancelled`.
The ceremony and proof last ten minutes; resending does not extend the original
ceremony. Limits: five failed proofs per request, ten failed proofs and five
sends per member per 15 minutes, 60 seconds between sends; IP limits are ten
sends per 15 minutes and 60 verification requests per five minutes. A new
transaction does not reset member budgets. IP throttling can return
`slow_down` (429). Resend replaces previous proofs. Wrong bound proof is
`invalid_grant`; malformed input is `invalid_request`.

Use status after a lost verify response; a completed code remains one-use and
expires 120 seconds after issuance. In this family's current response projection,
`expires_in` remains the **ceremony's** remaining lifetime even when completed;
it is not a fresh code lifetime, and a status read does not renew the code.
Redeem promptly; an expired code requires new sign-in. `completing` without a recoverable completion is
ambiguous: start fresh rather than replay issuance. Cancel is rejected with
409 once issuance began. After email proof, token pickup may require the
[tenant email 2FA preference](https://m7.org/docs/api/sso.user.m7.org/tenant-two-factor.md); email proof alone never
resets a factor.

## `me` projection

`POST /me` requires selected active session handles and endpoint proof. `sub`
is the tenant member UUID. With `profile` scope, nonempty fields may include
`name` (system login name), `display_name` (editable name), `picture`, `locale`,
`zoneinfo`, `website`. **`preferred_username` is intentionally absent.** Hosted
userinfo has its own mapping and is not changed by this projection.

With `email` scope, a unique active primary address supplies `email` and an
explicit `email_verified` boolean. Unverified primary email is not silently
verified. Missing or ambiguous primary rows omit both fields. Never select an
arbitrary email from an ambiguous result.

```json
{"sub":"cccccccc-cccc-4ccc-8ccc-cccccccccccc","name":"sample.member","display_name":"Sample Member","email":"member@example.org","email_verified":true}
```

No profile/email scope means those optional fields are absent. Expired active
access can produce `invalid_token` (401): refresh/ACK once then retry. Revoked
membership, groups or session admission require resolving that condition or
fresh sign-in, not a hidden account switch.
