diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..6f4fd63c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,14 @@
+# Agent Safety Rules
+
+## Stable Docker environment and PostgreSQL boundary (non-negotiable)
+
+- The Docker containers `modgud` and `postgres` form a stable, shared integration environment used by other applications and agents. Never treat them as disposable or exclusively owned by the current task.
+- Read-only diagnosis of the stable environment is allowed. This includes `docker ps`, `docker inspect`, `docker logs`, HTTP health checks, and provably read-only SQL queries.
+- Without explicit user authorization in the current conversation, never write to PostgreSQL on host port `5432`. This prohibition includes migrations, schema changes, seeds, DML, cleanup, restores, and starting any alternate or locally built Modgud process that connects to it.
+- Without explicit user authorization, never stop, start, restart, remove, recreate, replace, reconfigure, or rebuild the stable `modgud` or `postgres` containers. Do not change their images, networks, ports, volumes, environment, or connection strings.
+- Never bind a development process to the stable container's port or otherwise route development traffic in a way that replaces or masks the stable instance.
+- Do not copy, derive, or reuse a connection string from the running `modgud` container for development or testing.
+- All local development, UI verification, manually started backend processes, migrations, and seeds must use the `postgres-dev` container on host port `5433` and a development database.
+- Before starting a backend, verify from its resolved configuration that it targets `postgres-dev`/port `5433`. If this cannot be established, do not start it.
+- If `postgres-dev` is unavailable or unsuitable, stop and ask the user. Never fall back to `postgres`/port `5432`.
+- A running Docker application, known credentials, prior access, or a request to test the UI is not authorization to mutate the stable environment.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 735ba801..1397452e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,14 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
-Pre-1.0 development. See the [Roadmap](./docs/roadmap.md) for current
-status, shipped features, and what's intentionally out of scope.
-Day-to-day commit history lives in `git log`.
+### Added
+
+- Feature-flagged Positions and shared terminals (MG-FT-FLEX), including
+ multi-position terminal enrollment, configurable activation proofs and
+ device bindings, realm security floors, staffing/refresh/step-up lifecycle,
+ activation-token administration, and the matching admin UI and consumer
+ contract. `PositionTerminals` remains off by default and is enabled with
+ `AppSettings__Features__PositionTerminals=true`.
+
+See the [Roadmap](./docs/roadmap.md) for the full pre-1.0 product snapshot and
+what remains intentionally out of scope. Day-to-day history lives in `git log`.
diff --git a/docs/admin/positions-concepts.md b/docs/admin/positions-concepts.md
index 26a5b1ec..3be621ee 100644
--- a/docs/admin/positions-concepts.md
+++ b/docs/admin/positions-concepts.md
@@ -84,14 +84,17 @@ moment, its own flow — and answers a different question.
| Link | Question it answers | When & how |
|---|---|---|
| ① Person ↔ Position | **Who** may staff this post? | A simple list on the position ("authorized users"). Grant, suspend, revoke — takes effect immediately. |
-| ② Terminal ↔ Position | **Where** may this post be staffed? | Created when you add a terminal to the position. At its core an authorization: "the gate may be activated on this slot." |
-| ③ Device ↔ Terminal | **Which hardware** actually stands there? | At installation, exactly once. From then on exactly this device *is* "left terminal" — a replacement device needs a fresh slot. |
+| ② Terminal ↔ Position | **Where** may this post be staffed? | An authorization assignment. One terminal may carry several positions, selected for each shift. |
+| ③ Device ↔ Terminal | **Which hardware** actually stands there? | At installation, exactly once. DPoP pins a device key, client-secret identifies its holder, while `none` deliberately leaves this link unproven. |
::: tip Mnemonic
Link ① says *who*, ② says *where*, ③ says *with what*. The daily unlock is
not a fourth link — it is the moment all three are checked at once.
:::
+The realm security floor decides how strong links ① and ③ must be. A weaker
+position policy cannot silently undercut that floor.
+
> For engineers: ① is the *grant*, ② is the *terminal slot* with its
> auto-created OAuth client, ③ is the *enrollment* (device key binding). The
> client appears in the OAuth grid as inventory only — everything is managed
@@ -152,13 +155,14 @@ in front of it.
## A position never authenticates — it gets activated
-A position owns no credentials of its own (that is the difference to a
+A position has no login credential of its own (that is the difference to a
[service account](/admin/service-accounts), which identifies *itself*, from
-anywhere). Every position token starts with someone — an authorized person —
-proving themselves **at an enrolled terminal**. The chain is strict:
+anywhere). Every position token starts with an allowed activation proof — a
+person proving themselves or a position-owned hardware token — **at an enrolled
+terminal**. The chain is strict:
```
-Position → terminal slot → enrolled device → unlock by an authorized person → session
+Position → terminal assignment → enrolled device → allowed activation proof → session
```
No slot → no device → no unlock → never a token. A position without terminals
@@ -193,9 +197,10 @@ Who *actually clicked* the alarm at 07:15 is not recorded — if Anna was on a
break and a colleague clicked, the log still shows Anna's shift. That is not a
gap; it is the nature of every shared device. What the model guarantees:
**only authorized people can unlock, and who unlocked is cleanly recorded.**
-Accountability is **session-level, not action-level**. If a use case ever
-needs per-action attribution, a step-up proof per critical action is the
-designed extension point — not a new system.
+Accountability is **session-level, not action-level**. For a critical action,
+the consumer can request a fresh step-up proof. Modgud then returns a separate
+access token valid for at most 60 seconds; it may be bound to an action and
+consumer nonce and is intended to be consumed once by `jti`.
## Which principal for which job?
@@ -210,30 +215,25 @@ person, the post, or the machine?"* One concept per answer, and no fourth is
needed. (A **group** is none of the three — it distributes rights, it never
acts.)
-## Where the model can go — design direction
-
-::: warning Roadmap, not current behavior
-Today only the strictest configuration exists: personal passkey for the
-unlock, cryptographic device binding (DPoP) for the terminal. Everything in
-this section is the **accepted design direction** (ADR 0003) — implemented
-when a concrete consumer needs it.
-:::
-
-The flows above are normative; **how** person and device prove themselves is
-planned to become per-position policy, chosen from a curated menu with the
-current behavior as the recommended default — and every downgrade shown as an
-explicit, informed operator decision:
-
-- **Unlock proof:** personal passkey *(default)* → personal PIN / password →
- **position-owned tokens** (FIDO2 sticks registered on the *position*; the
- customer hands them out, the audit says "unlocked with token #2", each
- stick individually revocable) → shared team PIN *(weakest — the audit knows
- no name)*. Multiple classes can be allowed at once on one position.
-- **Device binding:** DPoP key *(default)* → client secret (for devices that
- cannot do DPoP) → none *(only defensible behind physical access control or
- in test realms — there is no device identity left)*.
-- **Realm guard rails:** the realm sets minimum tiers ("production: nothing
- below DPoP + personal proof"); a test realm may allow everything for POCs.
-- **Multi-position terminals:** one device serving several positions
- ("reception" by day, "night gate" after hours) — the assignment is an
- authorization, so it can be a list; still one active shift per terminal.
+## Policy choices and guard rails
+
+How people and devices prove themselves is a per-position policy. Multiple
+activation classes can be enabled together; DPoP + personal passkey remains
+the recommended default.
+
+- **Activation proof:** personal passkey, personal password, personal e-mail
+ OTP, or a **position-owned activation token**. The token is a logical,
+ individually revocable object with an RP-bound WebAuthn credential; the audit
+ names the token rather than a person. `team-secret` is reserved for a future
+ feature and is deliberately unavailable today.
+- **Device binding:** DPoP key, client secret, or none. Client-secret and none
+ still run the complete admin-approved Device Flow; `none` only removes a
+ cryptographic device identity and is appropriate only where the physical and
+ network controls justify it.
+- **Realm guard rails:** the realm declares required proof and binding
+ capabilities. Tightening a floor first previews affected positions and, when
+ confirmed, immediately ends sessions that no longer comply.
+- **Multi-position terminals:** one device may serve several positions
+ ("reception" by day, "night gate" after hours). New assignments are fixed
+ before enrollment; adding one later requires a replacement slot and fresh
+ approval. Exactly one active shift still exists per terminal.
diff --git a/docs/admin/positions.md b/docs/admin/positions.md
index 5b585295..d685e009 100644
--- a/docs/admin/positions.md
+++ b/docs/admin/positions.md
@@ -5,8 +5,8 @@
A **position** is a business identity that changing people staff in shifts —
"gate porter for customer XY", "reception HQ". Unlike a user or a service
-account, a position never owns credentials: its tokens are minted when an
-authorized person taps their passkey on an **enrolled shared terminal**.
+account, a position never signs in directly: its tokens are minted only after
+an allowed activation proof succeeds on an **enrolled shared terminal**.
Downstream systems then see the POSITION as the actor (`sub` = the
position), never the person — who tapped stays visible only to you, in the
staffing-session audit view.
@@ -27,6 +27,12 @@ events) lives under
namespace with user and service-account names.
- **Terminal use** — off by default. Terminal slots can only be created
and enrolled while this is on.
+- **Activation proofs** — one or more of personal passkey, personal password,
+ personal e-mail OTP, or a position-owned activation token. Team secret is a
+ reserved wire ID and is not selectable yet.
+- **Device bindings** — one or more of DPoP, client secret, or no binding.
+ DPoP is the recommended default; the weaker choices are explicit policy
+ decisions and may be forbidden by the realm security floor.
- **Staffing session (minutes)** — how long one shift lives (default
960 = 16 h). **Absolute maximum** — the hard ceiling no refresh can
extend past (default 1440 = 24 h). Access tokens stay short-lived
@@ -48,10 +54,11 @@ staff this position". One live grant per (position, user); grants are
suspend-/resume-/revocable, revoke is final (re-authorizing later creates
a fresh grant with its own audit trail).
-Watch the **"No passkey" badge**: staffing happens by passkey tap, so a
-grantee without a passkey under the terminals' RP-ID cannot actually
-activate the position. Have them register a passkey in their account
-settings first.
+The **"No passkey" badge** matters when `personal-passkey` is enabled. A user
+may still activate with password or e-mail OTP when the position permits that
+method. Password and OTP failures are locked per grant as well as rate-limited
+per source IP; changing/resetting the password or disabling e-mail OTP ends
+sessions established with that proof.
Suspending or revoking a grant **immediately ends** that person's running
staffing sessions and revokes the session tokens.
@@ -60,9 +67,15 @@ staffing sessions and revokes the session tokens.
**Position detail → Terminals** (or the same tab while creating the
position). One slot per physical device.
-Each slot atomically creates its own locked-down OAuth client (public,
-no secret, DPoP mandatory, reference tokens — the generic OAuth admin
-surface is read-only for it).
+Each slot atomically creates its own locked-down OAuth client (reference
+tokens; the generic OAuth admin surface is read-only for it). The selected
+binding fixes the client profile:
+
+| Binding | Client | Device identity |
+|---|---|---|
+| `dpop` | public, no secret | enrolled P-256 key; DPoP required |
+| `client-secret` | confidential | one-time-displayed secret |
+| `none` | public, no secret | no cryptographic device identity |
- **WebAuthn RP ID** — the domain staff passkeys verify against. Use ONE
RP-ID for all terminals of the consuming app, so a staff passkey works
@@ -70,47 +83,73 @@ surface is read-only for it).
RP-ID and the field locks — staff passkeys hang off the RP-ID, so only a
matching RP-ID lets the already-enrolled tokens unlock a new terminal.
- The slot view shows the **`client_id`** and the slot id — hand both to
- whoever installs the terminal device.
+ whoever installs the terminal device. For `client-secret`, copy the secret
+ immediately; it is never returned again.
+- A new slot can be assigned to several compatible positions before
+ enrollment. One terminal may then staff any of them, but still runs only one
+ staffing session at a time. Removing an assignment is immediate. Adding an
+ assignment after enrollment is intentionally rejected: create a replacement
+ multi-position slot and run Device Flow again.
### How terminal clients appear elsewhere
-The position modal is the **only UI** that creates and manages terminal
-clients — deliberately: a terminal client is the technical footprint of a
-slot, not a configurable OAuth client. In the **OAuth Clients grid** they
-stay visible as inventory (the Terminal column names the owning position,
-so the device fleet is countable at a glance), but they are read-only
-there: opening one deep-links into the position modal instead — the same
-rule SA-managed clients follow with the Service-Account editor.
-
-For automation, the admin **API** also accepts the client-side create
-(`POST /api/admin/oauth/clients` with the staffing grant): reference an
-existing position (`LinkedPositionPrincipalId`) or inline-create one
-(`NewPosition`) — never both, mirroring the `client_credentials` ⇔
-service-account rule. Position (if new), slot, and client land in one
-atomic save; the profile is **fixed server-side** (public, secretless,
-DPoP mandatory, reference tokens, exactly device_code + refresh_token +
-staffing), the `client_id` is generated (`{position}.terminal.{suffix}`),
-and the call needs `position:write` in addition to `oauth-client:write`.
+There are two equivalent UI entry points for creating a terminal slot:
+
+- **Position detail → Terminals** starts with the business position and adds
+ one or more slots.
+- **OAuth Clients → Create → staffing** starts with the technical client. As
+ with `client_credentials` and Service Accounts, you then choose an existing
+ Position or draft a new one in the same dialog.
+
+Selecting `staffing` is a **terminal profile**, not a freely combinable grant.
+The dialog replaces the grant selection with the fixed package `device_code +
+refresh_token + staffing`; browser login, native-login and
+`client_credentials` grants cannot be added. Position (if new), slot, and
+client land in one atomic save. The server derives the remaining OAuth profile
+from the chosen binding (reference tokens; public + DPoP, confidential + client
+secret, or public + no binding) and generates the `client_id`
+(`terminal.{suffix}`).
+
+After creation, terminal clients stay visible in the **OAuth Clients grid** as
+inventory. Their lifecycle is managed from the Position detail, so opening an
+existing terminal client is read-only and links back to its slot — the same
+ownership rule that SA-managed clients follow with the Service-Account editor.
+
+For automation, the same contract is available through
+`POST /api/admin/oauth/clients`: reference an existing position
+(`LinkedPositionPrincipalId`) or inline-create one (`NewPosition`) — never
+both. The call needs `position:write` in addition to `oauth-client:write`.
## 4. Approve the enrollment
-The device starts its enrollment and shows a **user code** plus a
-**device-key fingerprint** (`XXXX-XXXX`). Open the verification link (or
-enter the code at `/device`), and you'll see the terminal consent:
-position, terminal, location, client, and the fingerprint of the key that
-made the request.
+Every binding uses the complete RFC 8628 Device Flow and explicit admin
+approval. The device starts enrollment and shows a **user code**. Open the
+verification link (or enter the code at `/device`) to see position(s), terminal,
+location, client, and binding.
-**Compare the fingerprint with what the device shows** — that is the
-whole point of the ceremony: you are permanently binding THIS device's
-key to the slot. Approving requires the `position-terminal:enroll`
+For DPoP, also compare the **device-key fingerprint** (`XXXX-XXXX`) with the
+device display before approving; the enrollment pins that key permanently.
+For client-secret, the device authenticates with its one-time secret. With no
+binding, approval is the sole issuance barrier and the consent highlights that
+risk. Approving requires the `position-terminal:enroll`
permission (deliberately separate from `position:write` — registering a
physical device is a higher-trust act).
-Enrollment is one-shot: an enrolled slot can never be re-enrolled with a
-different key. Device replaced or key lost? Revoke the slot and create a
-fresh one.
+Enrollment is one-shot for every binding. Device replaced, key/secret lost,
+or positions added? Revoke the slot and create a fresh one.
+
+## 5. Position-owned activation tokens
+
+**Position detail → Activation tokens.** A logical token can be assigned to
+one or more positions, disabled/reactivated, or permanently revoked. Its
+WebAuthn credential is registered from an enrolled terminal so browser origin
+and terminal RP-ID match. The credential is therefore RP-bound; register the
+same logical token separately for each consuming RP where it must work.
+
+The staffing audit records the logical token and credential, not a person.
+Unassigning or revoking it immediately ends every session established with it.
-## 5. Monitor & intervene
+## 6. Monitor & intervene
**Position detail → Staffing sessions** (requires
`staffing-session:read`): every shift with terminal, **who
@@ -126,7 +165,9 @@ events), start, absolute end, and the end reason.
also deletes the slot's OAuth client.
- Everything cascades automatically: deactivating the position, binning
the user, deleting the used passkey, or revoking the grant all end the
- affected sessions immediately. Expired sessions are swept by the
+ affected sessions immediately. The same applies to password/OTP changes,
+ activation-token invalidation, policy tightening, or removing a terminal's
+ position assignment. Expired sessions are swept by the
`staffing-sweep` system job (every 5 minutes).
## Permissions reference
diff --git a/docs/integrate/position-terminals.md b/docs/integrate/position-terminals.md
index ebb147f1..9e3c361c 100644
--- a/docs/integrate/position-terminals.md
+++ b/docs/integrate/position-terminals.md
@@ -1,64 +1,221 @@
# Position terminals (consumer contract)
> **Status:** behind the `PositionTerminals` feature flag (default off). This
-> page is the versioned contract (V1) for systems consuming position tokens —
-> e.g. an alerting product whose shared gate terminals are staffed by changing
-> personnel.
+> page describes Control-Plane V2 and the position business-token contract.
-A **position** ("gate porter for customer XY") is a first-class principal:
-the business actor in your system is the position itself, never the person
-currently staffing it. A person authorizes a shift with a passkey tap on an
-**enrolled terminal**; Modgud mints tokens whose subject is the position.
+A position is the business actor. A terminal first obtains a control-token
+chain through an admin-approved Device Flow; an allowed activation proof then
+opens a staffing session and mints business tokens with the position as
+subject.
## Token classes
-Every position token carries `principal_type: "position"` and a `token_use`
-discriminator. Consumers MUST branch on `token_use` — the two classes have
-disjoint capabilities:
+Consumers MUST branch on `token_use`; control and business tokens have disjoint
+audiences and capabilities.
-| | Enrollment token | Staffing token |
+| | Control token V2 | Staffing token | Step-up token |
+|---|---|---|---|
+| `token_use` | `terminal_enrollment` | `staffing_session` | `staffing_step_up` |
+| `principal_type` | `terminal` | `position` | `position` |
+| `sub` | terminal id | selected position id | same position id as the staffing session |
+| Purpose | candidate selection, proof begin, local lock | ordinary business calls | fresh proof for a sensitive business call |
+| Audience | `modgud-terminal-control` only | resources resolved from staffing scopes | same resource set, no refresh/offline scope |
+| Lifetime | refreshable while the terminal remains Active | 10-minute access tokens under the session ceiling | reference access token, at most 60 seconds |
+
+Staffing and step-up tokens also carry `terminal_id`, `staffing_session_id`,
+`auth_time`, `activation_proof`, `terminal_binding`, and method-dependent
+`amr`:
+
+| `activation_proof` | `amr` | Person in token? |
+|---|---|---|
+| `personal-passkey` | `webauthn` | never |
+| `personal-password` | `pwd` | never |
+| `personal-email-otp` | `otp` | never |
+| `position-token` | `webauthn` | no person exists for the proof |
+
+`terminal_binding` is the open wire string selected on the terminal slot,
+currently `dpop`, `client-secret`, or `none`. `cnf.jkt` is present only for
+DPoP-bound terminals. Activating user id/name/e-mail, grant id, credential id,
+and logical activation-token id are internal security evidence and never
+travel in business tokens or integration events.
+
+### Control V1 transition
+
+Control V1 used `principal_type: "position"` and `sub = positionId`. New
+enrollments issue V2 only. Existing V1 refresh chains continue to work while
+their terminal still has exactly its original singleton position assignment;
+the endpoints detect both token forms. A second assignment requires a fresh
+V2 enrollment and V1 is rejected for that slot. V1 acceptance is deprecated
+as of 2026-08-15 and will not be removed before 2027-08-15; operators should
+replace legacy slots during normal device maintenance.
+
+## Introspection and binding
+
+All three classes are opaque reference tokens. Resource servers resolve them
+through `POST /connect/introspect` or
+`Modgud.AspNetCore.ResourceServer`. OpenIddict returns `active` only to the
+token presenter or an audience resource, so the resource server client id must
+be among the resources resolved from the granted scopes.
+
+For `dpop`, every terminal call carries a DPoP proof. Resource endpoints bind
+the proof to the presented access token through `ath` and reject reused `jti`
+values. `client-secret` terminals authenticate their confidential OAuth client
+at the token endpoint; `none` terminals have no cryptographic device binding.
+Both weaker modes still require admin-approved enrollment.
+
+## Provisioning
+
+One slot owns one managed OAuth client and one immutable binding. A slot may be
+assigned to several compatible positions before enrollment.
+
+| Parameter | Source | Notes |
|---|---|---|
-| `token_use` | `terminal_enrollment` | `staffing_session` |
-| Purpose | terminal-control surface only (begin a staffing ceremony, lock) | the business token of a staffed shift |
-| Audience | `modgud-terminal-control` (never a business API) | resolved from the granted scopes |
-| Extra claims | `terminal_id` | `terminal_id`, `staffing_session_id`, `auth_time` (the tap), `amr: ["webauthn"]` |
-| Lifetime | short access token, refreshable while the slot stays Active | 10-minute access token; the refresh chain ends hard at the session's absolute ceiling |
+| Modgud base URL | deployment | |
+| `client_id` | slot response | generated as `terminal.{8 chars}` |
+| `terminal_id` | slot response | used by lock, registration, and step-up routes |
+| `client_secret` | creation response | only for `client-secret`; shown once |
+| device P-256 key | terminal | only for `dpop`; ideally non-exportable |
+| RP-ID | slot response | WebAuthn RP for personal passkeys and position-token credentials |
+
+Changing a binding, losing a key/secret, or adding a position after enrollment
+means a fresh slot and Device Flow. Removing an assignment is immediate and
+ends a running session for that position.
+
+## Terminal flows
+
+### 1. Enrollment
+
+All bindings run RFC 8628 against the slot client and require a user-code
+approval by an admin with `position-terminal:enroll`.
+
+- `dpop`: proof on device and token requests; consent shows the JWK-thumbprint
+ fingerprint and token exchange permanently pins the key.
+- `client-secret`: confidential-client authentication; consent still approves
+ this physical installation.
+- `none`: public client without DPoP; consent explicitly warns that approval is
+ the only issuance barrier.
+
+Successful exchange returns the refreshable Control V2 chain.
+
+### 2. Staffing begin and position selection
+
+Call `POST /connect/staffing/begin` with the control access token and JSON:
+
+```json
+{
+ "methodId": "personal-passkey",
+ "accountName": "anna"
+}
+```
+
+`methodId` and `accountName` are method-specific. Begin never returns a
+position list. For a multi-position terminal it builds one proof challenge
+from the union of eligible credentials. Supplying `positionId` before proof is
+rejected with `Staffing.ProofRequiredBeforeSelection`.
+
+A successful begin returns `ceremonyId`, `methodId`, and either `publicKey`
+(WebAuthn methods) or `challenge` (password/e-mail OTP fields). Redeem the
+proof at `/connect/token`:
+
+```text
+grant_type=urn:cocoar:params:oauth:grant-type:staffing
+client_id=
+ceremony_id=
+assertion=
+```
+
+For password, assertion is `{"password":"..."}`; for e-mail OTP it is
+`{"code":"..."}`; passkey and position-token use WebAuthn assertion JSON.
+The verified user or logical token is then intersected with the terminal's
+currently allowed positions. If one remains, the response is the staffing
+token immediately. If several remain, and only then, the proof response is:
+
+```json
+{
+ "selectionRequired": true,
+ "ceremonyId": "single-use-selection-ticket",
+ "candidates": [
+ { "id": "...", "displayName": "Reception" }
+ ]
+}
+```
+
+Redeem the returned selection ticket once, without resending the proof:
+
+```text
+grant_type=urn:cocoar:params:oauth:grant-type:staffing
+client_id=
+ceremony_id=
+position_id=
+```
+
+Both proof and selection ceremonies are single-use and bound to terminal,
+client and device binding. The selection ticket stores position-specific
+evidence server-side and revalidates it immediately before minting the token.
+
+### 3. Position-token registration
-Common claims on both: `sub` (the PositionPrincipal id), `name` (the
-position's account name), `cnf.jkt` (the terminal's DPoP key thumbprint —
-all position tokens are DPoP-bound reference tokens).
+Admins create and assign a logical activation token. From an enrolled terminal,
+register an RP-bound credential with:
-**Never present:** the activating person's user id, name, e-mail, or passkey
-reference. Who tapped is Modgud-internal security audit (visible only to
-admins holding `staffing-session:read`).
+1. `POST /connect/activation-token/{tokenId}/register/begin`
+2. WebAuthn `navigator.credentials.create` using the returned options
+3. `POST /connect/activation-token/{tokenId}/register` with ceremony and
+ attestation response
-## Introspection
+The control token authenticates both calls (plus DPoP for a DPoP terminal).
+The token must be assigned to at least one position available on that terminal.
+Register once per RP-ID where the logical token must work.
-Position tokens are opaque reference tokens; resource servers resolve them
-via `POST /connect/introspect` (or the `Modgud.AspNetCore.ResourceServer`
-package, which also enforces the `cnf.jkt` DPoP binding). The introspection
-response carries the claims above. Note OpenIddict's audience rule: a caller
-only sees a token as `active` when it is the token's presenter or listed in
-its audiences — your resource server's client id must therefore be among the
-API resources the staffing token's scopes resolve to.
+### 4. Lock
-## Error contract
+`POST /connect/staffing/{terminalId}/lock` accepts the same terminal's control
+or current staffing token. DPoP terminals include an `ath`-bound, replay-safe
+proof. The operation is idempotent and immediately revokes the staffing
+authorization.
+
+### 5. Step-up
+
+Begin with the current staffing access token:
+
+```http
+POST /connect/staffing/{terminalId}/step-up
+Content-Type: application/json
+
+{
+ "methodId": "personal-passkey",
+ "accountName": "anna",
+ "action": "alarm.acknowledge",
+ "nonce": "consumer-generated-unpredictable-value"
+}
+```
-| Situation | Error | What the terminal must do |
+`action` and `nonce` are optional but must appear together. Complete the fresh
+proof, then use the normal staffing grant with `step_up=true`. The result has
+`acr: "urn:cocoar:staffing:step-up"`, fresh `auth_time`, the new `amr` and
+`activation_proof`, and—when supplied—`stepup_action` and `stepup_nonce`.
+Consumers needing one-action semantics MUST validate action/nonce and atomically
+consume the token `jti`; DPoP and a 60-second lifetime do not by themselves
+prevent multiple uses inside the window.
+
+## Errors and invalidation
+
+| Situation | OAuth/API outcome | Terminal action |
|---|---|---|
-| Staffing refresh after the session ended, expired, or was de-authorized (grant/terminal/position/user/passkey) | `interaction_required` / `staffing_required` | Lock the UI and demand a fresh passkey tap. Never retry silently. |
-| Chain-integrity violation (wrong client, wrong DPoP key, replayed ceremony) | `invalid_grant` | Treat as fatal; restart the affected flow. |
-| Missing/invalid DPoP proof | `invalid_dpop_proof` | Re-sign with the enrolled key and retry once. |
+| Session ended, expired, assignment removed, policy tightened, or proof evidence no longer valid | `interaction_required` / `staffing_required` | lock and require fresh activation |
+| Wrong client/binding, reused ceremony, wrong selected position | `invalid_grant` / forbidden | restart the affected flow |
+| Missing/invalid/replayed DPoP proof on a DPoP terminal | `invalid_dpop_proof` / forbidden | create one fresh proof; never reuse `jti` |
+| Adding a position to an enrolled slot | `Terminal.ReenrollmentRequired` (409) | create and enroll a replacement slot |
-Revocation is server-side and instant (reference tokens die with their
-authorization). Integration events are notifications only — a consumer that
-receives a `...SessionEnded` event late was already unable to use the
-session's tokens.
+Refresh revalidates the current position policy, realm floor, terminal
+assignment, and method-specific evidence. Immediate cascades revoke the
+authorization for enumerated lifecycle events; the refresh backstop bounds any
+missed cascade by the 10-minute access-token lifetime.
## Integration events (V1)
-Published records (`Modgud.Domain.PositionTerminals.Contracts.V1` — the
-namespace is the version; breaking changes ship as a side-by-side `V2`):
+Published records remain method- and person-agnostic in
+`Modgud.Domain.PositionTerminals.Contracts.V1`:
```csharp
record PositionStaffingSessionStarted(
@@ -74,60 +231,7 @@ record PositionTerminalStatusChanged(
TerminalEnrollmentStatus Status, DateTimeOffset ChangedAt);
```
-Correlate shifts by `StaffingSessionId`; a `Started` for a terminal that
-still has an open session implies the previous one ended
-(`ReplacedByNewActivation` follows). `Reason` values: `LocalLock`,
-`RemoteLock`, `ReplacedByNewActivation`, `Expired`, `PositionDisabled`,
-`TerminalDisabled`, `TerminalRevoked`, `UserDisabled`, `PasskeyDeleted`,
-`GrantSuspended`, `GrantRevoked`, `OAuthClientDisabled`.
-
-Person data is deliberately absent from every event.
-
-Delivery rides Modgud's Wolverine outbox; the external transport binding is
-deployment configuration. Events are at-least-once and unordered across
-terminals — key any projection by `StaffingSessionId`.
-
-## Provisioning (what a terminal gets at install time)
-
-A Modgud admin creates one slot per device — either in the position modal
-or from the OAuth-client side (creating a client with the staffing grant
-stages position link + slot + client in one save) — and reads the
-terminal-app configuration off the slot view:
-
-| Parameter | Source | Notes |
-|---|---|---|
-| Modgud base URL | deployment | |
-| `client_id` | slot view (auto-generated `{position}.terminal.{8 chars}`) | public client, no secret, DPoP mandatory, reference tokens |
-| `terminal_id` | slot view (the slot's GUID) | needed for the lock endpoint |
-| RP-ID | slot view (WebAuthn RP-ID set at slot creation) | use ONE RP-ID for all terminals of the consuming app so a staff passkey works on every terminal |
-
-The terminal generates an **ES256 (P-256) device key** at first start —
-ideally in a secure element / TPM, never exportable. Key loss or rotation
-means a **fresh slot** (deliberate: no silent re-enrollment). During the
-enrollment consent the admin sees a key fingerprint (`XXXX-XXXX` — first
-8 hex chars of SHA-256 over the RFC 7638 JWK thumbprint); show the same
-fingerprint on the device so the admin can visually match device and
-consent.
-
-The E2E suite (`TerminalDeviceEnrollmentTests`,
-`StaffingTests`) is the executable wire-format reference for every
-flow below — real DPoP proofs and real ES256 WebAuthn assertions against
-the full stack.
-
-## Terminal flows (for terminal implementers)
-
-1. **Enrollment** (once per device): RFC 8628 device flow against the slot's
- own OAuth client, with a DPoP proof from a device-held key on every
- request. An admin approves the terminal consent; the poll pins the key
- onto the slot and yields the enrollment token chain.
-2. **Staffing** (per shift): `POST /connect/staffing/begin` with
- `Authorization: Bearer ` plus a `DPoP` proof
- header → WebAuthn assertion options (`allowCredentials` restricted to
- authorized users' passkeys). The person taps; redeem with
- `grant_type=urn:cocoar:params:oauth:grant-type:staffing`,
- `ceremony_id` and the `assertion` JSON, DPoP-proofed.
-3. **Lock**: `POST /connect/staffing/{terminalId}/lock` with either
- position token of the same terminal (the enrollment token works even when
- the staffing access token already expired) plus a DPoP proof.
-
-All three surfaces refuse any key other than the slot's enrolled one.
+Delivery uses the Wolverine outbox: at-least-once and unordered across
+terminals. Project by `StaffingSessionId`. Events are notifications, not a
+revocation mechanism; reference-token authorization is already dead when an
+ended event is observed.
diff --git a/docs/operate/feature-flags.md b/docs/operate/feature-flags.md
index be913399..b097b539 100644
--- a/docs/operate/feature-flags.md
+++ b/docs/operate/feature-flags.md
@@ -24,6 +24,9 @@ Configure via `configuration.local.json` (gitignored) or an environment variable
```bash
# or via env (double-underscore as section separator; casing is not significant):
AppSettings__Features__PageBuilder=true
+
+# enable Positions and shared terminals (default is false):
+AppSettings__Features__PositionTerminals=true
```
Flags are read at startup; no hot-reload. A flip requires a restart.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b538efc5..ab90f491 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -45,6 +45,28 @@ in a changelog that ages between releases.
and per-RS subset narrowing; native via the
`Modgud.AspNetCore.ResourceServer` NuGet package
+**Positions and shared terminals (Technical Preview)**
+
+- Feature-flagged position principals for shared workplace terminals:
+ administrators assign people to positions, terminals are bound to one or
+ more positions, and the person currently staffing a terminal receives the
+ position's effective authorization without turning the device into a user
+ account
+- Flexible activation proofs (personal passkey, password, email OTP, or an
+ individually revocable position token) and immutable terminal-binding
+ profiles (DPoP key, client secret, or explicitly unbound), constrained by
+ realm security floors and position policy
+- Complete enrollment and staffing lifecycle: RFC 8628 admin approval,
+ refresh-time policy revalidation, targeted invalidation cascades, bounded
+ step-up tokens, V1-to-multi-position control-token compatibility, and an
+ admin UI for positions, terminal slots, activation tokens, and Staffing
+ OAuth clients
+- Ships **off by default**. Operators enable it with
+ `AppSettings__Features__PositionTerminals=true`; see
+ [Feature flags](./operate/feature-flags), the
+ [admin guide](./admin/positions), and the
+ [consumer contract](./integrate/position-terminals)
+
**OAuth 2.0 / OpenID Connect (OpenIddict 7)**
- Authorization Code + PKCE, Client Credentials, Refresh Token,
diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/PositionTerminalsFeatureFlagContractTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/PositionTerminalsFeatureFlagContractTests.cs
new file mode 100644
index 00000000..0396ef97
--- /dev/null
+++ b/src/dotnet/Modgud.Api.Tests/Authorization/PositionTerminalsFeatureFlagContractTests.cs
@@ -0,0 +1,14 @@
+using Modgud.Api;
+
+namespace Modgud.Api.Tests.Authorization;
+
+public class PositionTerminalsFeatureFlagContractTests
+{
+ [Fact]
+ public void Position_terminals_are_off_by_default()
+ {
+ var settings = new AppSettings();
+
+ Assert.False(settings.Features.PositionTerminals);
+ }
+}
diff --git a/src/dotnet/Modgud.Api.Tests/Positions/ActivationTokenTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/ActivationTokenTests.cs
new file mode 100644
index 00000000..86c0304d
--- /dev/null
+++ b/src/dotnet/Modgud.Api.Tests/Positions/ActivationTokenTests.cs
@@ -0,0 +1,138 @@
+using System.Net;
+using System.Net.Http.Json;
+using BuildingBlocks.Helper;
+using Marten;
+using Microsoft.Extensions.DependencyInjection;
+using Modgud.Api.Features.Positions;
+using Modgud.Api.Tests.Infrastructure;
+using Modgud.Application.DTOs.Positions;
+using Modgud.Domain.PositionTerminals;
+
+namespace Modgud.Api.Tests.Positions;
+
+[Collection(IntegrationTestCollection.Name)]
+public sealed class ActivationTokenTests : IntegrationTestBase
+{
+ public ActivationTokenTests(SharedPostgresFixture fixture) : base(fixture) { }
+
+ [Fact]
+ public async Task Revoke_is_side_effect_free_while_the_feature_is_disabled()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var settings = Factory.Services.GetRequiredService();
+ settings.Features.PositionTerminals = true;
+ var positionId = await CreatePositionAsync("token-feature-off", ct);
+ var create = await Client.PostAsJsonAsync($"/api/position/{positionId}/activation-tokens",
+ new { Label = "Feature-off key" }, JsonOptions, ct);
+ Assert.True(create.IsSuccessStatusCode, await create.Content.ReadAsStringAsync(ct));
+ var token = (await create.Content.ReadFromJsonAsync(JsonOptions, ct))!;
+
+ try
+ {
+ settings.Features.PositionTerminals = false;
+ var revoke = await Client.PostAsync($"/api/activation-token/{token.Id}/revoke", null, ct);
+ Assert.Equal(HttpStatusCode.NotFound, revoke.StatusCode);
+
+ using var scope = Factory.Services.CreateScope();
+ var query = scope.ServiceProvider.GetRequiredService();
+ var stored = await query.LoadAsync(new ShortGuid(token.Id).Guid, ct);
+ Assert.Equal(ActivationTokenStatus.PendingRegistration, stored!.Status);
+ Assert.Null(stored.RevokedAt);
+ Assert.Null(stored.RevokedByUserId);
+ }
+ finally
+ {
+ settings.Features.PositionTerminals = true;
+ }
+ }
+
+ [Fact]
+ public async Task Logical_token_is_multi_position_rp_bound_and_irreversibly_revocable()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ Factory.Services.GetRequiredService().Features.PositionTerminals = true;
+ var first = await CreatePositionAsync("token-position-a", ct);
+ var second = await CreatePositionAsync("token-position-b", ct);
+
+ var create = await Client.PostAsJsonAsync($"/api/position/{first}/activation-tokens",
+ new { Label = "Safe key 1" }, JsonOptions, ct);
+ Assert.True(create.IsSuccessStatusCode, await create.Content.ReadAsStringAsync(ct));
+ var token = (await create.Content.ReadFromJsonAsync(JsonOptions, ct))!;
+ Assert.Equal(ActivationTokenStatus.PendingRegistration, token.Status);
+ Assert.Equal([first], token.AssignedPositionIds);
+
+ var assign = await Client.PostAsync(
+ $"/api/position/{second}/activation-tokens/{token.Id}/assign", null, ct);
+ Assert.True(assign.IsSuccessStatusCode, await assign.Content.ReadAsStringAsync(ct));
+ var secondList = await Client.GetFromJsonAsync>(
+ $"/api/position/{second}/activation-tokens", JsonOptions, ct);
+ Assert.Equal(token.Id, Assert.Single(secondList!).Id);
+
+ // Registration normally writes this through the terminal-authenticated
+ // FIDO endpoint. Seeding the resulting document keeps this lifecycle
+ // test focused while pinning the separate, RP-bound credential model.
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var session = scope.ServiceProvider.GetRequiredService();
+ session.Store(new ActivationTokenCredential
+ {
+ Id = Guid.CreateVersion7(),
+ ActivationTokenId = new ShortGuid(token.Id).Guid,
+ CredentialId = [1, 2, 3],
+ PublicKey = [4, 5, 6],
+ UserHandle = [7, 8, 9],
+ RpId = "alerthub.localhost",
+ CreatedAt = DateTimeOffset.UtcNow,
+ });
+ await session.SaveChangesAsync(ct);
+ }
+
+ var activate = await Client.PostAsync($"/api/activation-token/{token.Id}/reactivate", null, ct);
+ Assert.True(activate.IsSuccessStatusCode, await activate.Content.ReadAsStringAsync(ct));
+ token = (await activate.Content.ReadFromJsonAsync(JsonOptions, ct))!;
+ Assert.Equal(ActivationTokenStatus.Active, token.Status);
+ Assert.Equal(["alerthub.localhost"], token.RegisteredRpIds);
+
+ var disable = await Client.PostAsync($"/api/activation-token/{token.Id}/disable", null, ct);
+ Assert.True(disable.IsSuccessStatusCode, await disable.Content.ReadAsStringAsync(ct));
+ Assert.Equal(ActivationTokenStatus.Disabled,
+ (await disable.Content.ReadFromJsonAsync(JsonOptions, ct))!.Status);
+
+ var unassign = await Client.DeleteAsync(
+ $"/api/position/{first}/activation-tokens/{token.Id}", ct);
+ Assert.True(unassign.IsSuccessStatusCode, await unassign.Content.ReadAsStringAsync(ct));
+ token = (await unassign.Content.ReadFromJsonAsync(JsonOptions, ct))!;
+ Assert.DoesNotContain(first, token.AssignedPositionIds);
+ Assert.Contains(second, token.AssignedPositionIds);
+
+ var revoke = await Client.PostAsync($"/api/activation-token/{token.Id}/revoke", null, ct);
+ Assert.True(revoke.IsSuccessStatusCode, await revoke.Content.ReadAsStringAsync(ct));
+ Assert.Equal(ActivationTokenStatus.Revoked,
+ (await revoke.Content.ReadFromJsonAsync(JsonOptions, ct))!.Status);
+
+ var resurrection = await Client.PostAsync($"/api/activation-token/{token.Id}/reactivate", null, ct);
+ Assert.Equal(HttpStatusCode.BadRequest, resurrection.StatusCode);
+ Assert.Contains("ActivationToken.Revoked", await resurrection.Content.ReadAsStringAsync(ct));
+
+ using var verifyScope = Factory.Services.CreateScope();
+ var query = verifyScope.ServiceProvider.GetRequiredService();
+ var stored = await query.LoadAsync(new ShortGuid(token.Id).Guid, ct);
+ Assert.NotNull(stored!.RevokedAt);
+ Assert.NotNull(stored.RevokedByUserId);
+ }
+
+ private async Task CreatePositionAsync(string accountName, CancellationToken ct)
+ {
+ var response = await Client.PostAsJsonAsync("/api/position", new
+ {
+ AccountName = accountName,
+ TerminalPolicy = new
+ {
+ Enabled = true,
+ AllowedActivationProofs = new[] { ActivationProofMethodIds.PositionToken },
+ },
+ }, JsonOptions, ct);
+ Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(ct));
+ return (await response.Content.ReadFromJsonAsync(JsonOptions, ct))!.Id;
+ }
+}
diff --git a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
index ca8385df..7cfaf116 100644
--- a/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Positions/PositionCrudTests.cs
@@ -7,6 +7,9 @@
using Modgud.Api.Tests.Infrastructure;
using Modgud.Application.DTOs.Positions;
using Microsoft.Extensions.DependencyInjection;
+using Modgud.Authorization.Events;
+using Modgud.Authorization.Principals;
+using Modgud.Application.DTOs.RealmSettings;
namespace Modgud.Api.Tests.Positions;
@@ -59,6 +62,8 @@ public async Task Create_normalises_the_name_and_defaults_to_disabled_terminal_p
Assert.True(created.IsActive);
// Never terminal-enabled by accident; plan defaults 16 h / 24 h.
Assert.False(created.TerminalPolicy.Enabled);
+ Assert.Equal([ActivationProofMethodIds.PersonalPasskey], created.TerminalPolicy.AllowedActivationProofs);
+ Assert.Equal([DeviceBindingIds.Dpop], created.TerminalPolicy.AllowedDeviceBindings);
Assert.Equal(16 * 60, created.TerminalPolicy.StaffingSessionLifetimeMinutes);
Assert.Equal(24 * 60, created.TerminalPolicy.MaximumStaffingSessionLifetimeMinutes);
@@ -147,6 +152,64 @@ public async Task Update_merges_the_terminal_policy_and_enforces_the_lifetime_ce
Assert.Equal(HttpStatusCode.BadRequest, nonPositive.StatusCode);
}
+ [Fact]
+ public async Task Realm_floor_tightening_requires_preview_and_confirmation_for_legacy_policy()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+
+ // Simulate a policy written by a later plug-in and then read after a
+ // rollback. Reads preserve its open string ID; the stricter floor must
+ // still find it and cannot save silently.
+ var positionId = Guid.NewGuid();
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var session = scope.ServiceProvider.GetRequiredService();
+ var policy = new PositionTerminalPolicy
+ {
+ Enabled = true,
+ AllowedActivationProofs = [ActivationProofMethodIds.PersonalPasskey],
+ AllowedDeviceBindings = ["plugin.sender-constrained"],
+ };
+ session.Events.StartStream(positionId,
+ new PositionPrincipalCreatedEvent(positionId, "legacy.binding", null, true, policy));
+ await session.SaveChangesAsync(ct);
+ }
+
+ var readPreserveWriteReject = await Client.PutAsJsonAsync(
+ $"/api/position/{ShortGuid.Encode(positionId)}",
+ new { Purpose = "must not rewrite the unavailable binding" }, JsonOptions, ct);
+ Assert.Equal(HttpStatusCode.BadRequest, readPreserveWriteReject.StatusCode);
+ Assert.Contains("Position.UnknownDeviceBinding",
+ await readPreserveWriteReject.Content.ReadAsStringAsync(ct));
+
+ var floor = new UpdatePositionSecuritySettingsDto
+ {
+ RequiredBindingCapabilities = BindingCapability.SenderConstrained,
+ };
+ var previewResponse = await Client.PostAsJsonAsync(
+ "/api/admin/realm-settings/position-security/preview", floor, JsonOptions, ct);
+ Assert.True(previewResponse.IsSuccessStatusCode,
+ await previewResponse.Content.ReadAsStringAsync(ct));
+ var preview = await previewResponse.Content.ReadFromJsonAsync(JsonOptions, ct);
+ Assert.Single(preview!.Positions);
+ Assert.Equal("legacy.binding", preview.Positions[0].AccountName);
+ Assert.Equal(["plugin.sender-constrained"], preview.Positions[0].ViolatingDeviceBindings);
+
+ var unconfirmed = await Client.PatchAsJsonAsync("/api/admin/realm-settings",
+ new UpdateRealmSettingsDto { PositionSecurity = floor }, JsonOptions, ct);
+ Assert.Equal(HttpStatusCode.BadRequest, unconfirmed.StatusCode);
+ Assert.Contains("ConfirmationRequired", await unconfirmed.Content.ReadAsStringAsync(ct));
+
+ var confirmed = await Client.PatchAsJsonAsync("/api/admin/realm-settings",
+ new UpdateRealmSettingsDto
+ {
+ PositionSecurity = floor,
+ ConfirmPositionSecurityConsequences = true,
+ }, JsonOptions, ct);
+ Assert.True(confirmed.IsSuccessStatusCode, await confirmed.Content.ReadAsStringAsync(ct));
+ }
+
[Fact]
public async Task Update_renames_with_conflict_detection()
{
@@ -254,7 +317,7 @@ public async Task Create_sets_up_staged_terminal_slots_in_the_same_save()
Assert.NotNull(slots);
Assert.Equal(2, slots!.Count);
Assert.All(slots, s => Assert.Equal(TerminalEnrollmentStatus.Pending, s.Status));
- Assert.All(slots, s => Assert.StartsWith("portier.staged.terminal.", s.ClientId));
+ Assert.All(slots, s => Assert.StartsWith("terminal.", s.ClientId));
Assert.Equal("Tor 3", slots.Single(s => s.DisplayName == "Terminal links").Location);
// Every slot's managed client committed with it — no half-created pair.
@@ -264,7 +327,7 @@ public async Task Create_sets_up_staged_terminal_slots_in_the_same_save()
{
var client = (await session.Query()
.Where(c => c.ClientId == slot.ClientId).ToListAsync(ct)).Single();
- Assert.Equal(new ShortGuid(created.Id).Guid, client.LinkedPositionPrincipalId);
+ Assert.Null(client.LinkedPositionPrincipalId);
Assert.Equal(new ShortGuid(slot.Id).Guid, client.ManagedTerminalEnrollmentId);
}
}
diff --git a/src/dotnet/Modgud.Api.Tests/Positions/PositionTerminalTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/PositionTerminalTests.cs
index 4300f3d8..0d73e52e 100644
--- a/src/dotnet/Modgud.Api.Tests/Positions/PositionTerminalTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Positions/PositionTerminalTests.cs
@@ -14,9 +14,9 @@ namespace Modgud.Api.Tests.Positions;
///
/// MG-FT-03 — terminal slots: a slot create commits enrollment + its
-/// terminal-managed public client atomically with the fixed profile (public,
-/// secretless, DPoP, reference tokens, RP-ID, exactly the three terminal
-/// grants); the generic OAuth admin surface is read-only for that client; and
+/// terminal-managed client atomically with its binding-specific fixed profile
+/// (DPoP, ClientSecret, or None; reference tokens, RP-ID, and exact grants);
+/// the generic OAuth admin surface is read-only for that client; and
/// the Pending/Disabled/Revoked lifecycle is idempotent with revoked terminal.
///
[Collection(IntegrationTestCollection.Name)]
@@ -61,7 +61,7 @@ public async Task A_slot_creates_the_managed_public_client_atomically_with_the_f
Assert.Equal(TerminalEnrollmentStatus.Pending, terminal.Status);
Assert.False(terminal.Enrolled);
- Assert.StartsWith("fn-slot.terminal.", terminal.ClientId);
+ Assert.StartsWith("terminal.", terminal.ClientId);
Assert.Equal(RpId, terminal.WebAuthnRpId);
using var scope = Factory.Services.CreateScope();
@@ -72,7 +72,7 @@ public async Task A_slot_creates_the_managed_public_client_atomically_with_the_f
// The fixed terminal profile, field by field.
Assert.Equal("public", client.ClientType);
- Assert.Equal(new ShortGuid(terminal.PositionId).Guid, client.LinkedPositionPrincipalId);
+ Assert.Null(client.LinkedPositionPrincipalId);
Assert.Equal(new ShortGuid(terminal.Id).Guid, client.ManagedTerminalEnrollmentId);
Assert.Null(client.LinkedServiceAccountId);
Assert.Equal(AccessTokenType.Reference.ToString(), client.Settings[OAuthApplicationSettingKeys.AccessTokenType]);
@@ -167,6 +167,63 @@ public async Task Two_slots_get_two_distinct_clients()
Assert.Equal(2, list!.Count);
}
+ [Fact]
+ public async Task A_pending_terminal_can_be_shared_but_an_enrolled_terminal_cannot_gain_a_position()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var first = await CreatePositionAsync("fn-shared-a", terminalEnabled: true, ct);
+ var second = await CreatePositionAsync("fn-shared-b", terminalEnabled: true, ct);
+ var third = await CreatePositionAsync("fn-shared-c", terminalEnabled: true, ct);
+
+ var create = await Client.PostAsJsonAsync($"/api/position/{first}/terminals", new
+ {
+ DisplayName = "Shared",
+ Location = "Desk",
+ WebAuthnRpId = RpId,
+ AllowedPositionIds = new[] { first, second },
+ }, JsonOptions, ct);
+ Assert.True(create.IsSuccessStatusCode, await create.Content.ReadAsStringAsync(ct));
+ var terminal = (await create.Content.ReadFromJsonAsync(JsonOptions, ct))!;
+ Assert.Equal(
+ new[] { first, second }.OrderBy(x => x, StringComparer.Ordinal),
+ terminal.AllowedPositionIds.OrderBy(x => x, StringComparer.Ordinal));
+
+ var secondList = await Client.GetFromJsonAsync>(
+ $"/api/position/{second}/terminals", JsonOptions, ct);
+ Assert.Equal(terminal.Id, Assert.Single(secondList!).Id);
+
+ var remove = await Client.PutAsJsonAsync(
+ $"/api/position/{first}/terminals/{terminal.Id}/positions",
+ new { AllowedPositionIds = new[] { first } }, JsonOptions, ct);
+ Assert.True(remove.IsSuccessStatusCode, await remove.Content.ReadAsStringAsync(ct));
+ secondList = await Client.GetFromJsonAsync>(
+ $"/api/position/{second}/terminals", JsonOptions, ct);
+ Assert.Empty(secondList!);
+
+ var restore = await Client.PutAsJsonAsync(
+ $"/api/position/{first}/terminals/{terminal.Id}/positions",
+ new { AllowedPositionIds = new[] { first, second } }, JsonOptions, ct);
+ Assert.True(restore.IsSuccessStatusCode, await restore.Content.ReadAsStringAsync(ct));
+
+ // Enrollment fixes the approved position set. A later addition must
+ // create a new V2 slot rather than silently widening this device.
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var session = scope.ServiceProvider.GetRequiredService();
+ var id = new ShortGuid(terminal.Id).Guid;
+ session.Events.Append(id, new TerminalEnrollmentEnrolled(
+ id, "test-jkt", Guid.NewGuid().ToString(), DateTimeOffset.UtcNow));
+ await session.SaveChangesAsync(ct);
+ }
+
+ var widen = await Client.PutAsJsonAsync(
+ $"/api/position/{first}/terminals/{terminal.Id}/positions",
+ new { AllowedPositionIds = new[] { first, second, third } }, JsonOptions, ct);
+ Assert.Equal(HttpStatusCode.Conflict, widen.StatusCode);
+ Assert.Contains("Terminal.ReenrollmentRequired", await widen.Content.ReadAsStringAsync(ct));
+ }
+
[Fact]
public async Task Slots_are_event_sourced_one_event_per_transition()
{
diff --git a/src/dotnet/Modgud.Api.Tests/Positions/StaffingConcurrencyTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/StaffingConcurrencyTests.cs
index daefda0e..9ac9ab03 100644
--- a/src/dotnet/Modgud.Api.Tests/Positions/StaffingConcurrencyTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Positions/StaffingConcurrencyTests.cs
@@ -248,7 +248,9 @@ private async Task BeginStaffingAsync(StaffingSetup setup, Cancella
var request = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
request.Headers.Add(DpopConstants.HeaderName,
- setup.DeviceKey.CreateProof("POST", "http://localhost/connect/staffing/begin", DateTimeOffset.UtcNow));
+ setup.DeviceKey.CreateProof(
+ "POST", "http://localhost/connect/staffing/begin", DateTimeOffset.UtcNow,
+ setup.EnrollmentAccessToken));
var resp = await Factory.CreateClient().SendAsync(request, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
Assert.True(resp.IsSuccessStatusCode, $"staffing begin failed ({(int)resp.StatusCode}): {body}");
diff --git a/src/dotnet/Modgud.Api.Tests/Positions/StaffingTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/StaffingTests.cs
index 0a68d194..cf47de55 100644
--- a/src/dotnet/Modgud.Api.Tests/Positions/StaffingTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Positions/StaffingTests.cs
@@ -6,15 +6,27 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
+using System.Text.Json.Nodes;
using BuildingBlocks.Helper;
using Marten;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using Modgud.Api.Features.Positions;
using Modgud.Api.Tests.Infrastructure;
using Modgud.Application.DTOs.Positions;
using Modgud.Application.DTOs.User;
using Modgud.Authentication.Domain;
+using Microsoft.AspNetCore.Identity;
using Modgud.Domain.PositionTerminals;
+using Microsoft.Extensions.Options;
+using Modgud.Infrastructure.Email;
using Modgud.Infrastructure.OpenIddict.Dpop;
+using Modgud.Infrastructure.Persistence.Tenancy;
+using Modgud.Infrastructure.Realms;
using Microsoft.Extensions.DependencyInjection;
+using OpenIddict.Abstractions;
+using OpenIddict.Server;
+using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Modgud.Api.Tests.Positions;
@@ -73,6 +85,9 @@ public async Task A_passkey_tap_opens_a_staffing_session()
Assert.Equal(StaffingSessionStatus.Active, staffing.Status);
Assert.Equal(setup.UserId, staffing.ActivatedByUserId);
Assert.Equal(setup.PositionId, staffing.PositionPrincipalId);
+ Assert.Equal("personal-passkey", staffing.Evidence.MethodId);
+ Assert.Equal("dpop", staffing.Evidence.Binding);
+ Assert.Equal(setup.UserId, staffing.Evidence.UserId);
Assert.Equal(setup.DeviceKey.Jkt, staffing.DpopJkt);
Assert.False(string.IsNullOrEmpty(staffing.OAuthAuthorizationId));
Assert.True(staffing.AbsoluteExpiresAt > DateTimeOffset.UtcNow.AddHours(15));
@@ -82,7 +97,9 @@ public async Task A_passkey_tap_opens_a_staffing_session()
// Event-sourced: session stream = started; terminal stream gained the
// activation event (created + enrolled + activated).
- Assert.Single(await session.Events.FetchStreamAsync(staffing.Id, token: ct));
+ var staffingStream = await session.Events.FetchStreamAsync(staffing.Id, token: ct);
+ var started = Assert.IsType(Assert.Single(staffingStream).Data);
+ Assert.Equal(staffing.Evidence, started.Evidence);
Assert.Equal(3, (await session.Events.FetchStreamAsync(setup.TerminalId, token: ct)).Count);
// The ceremony is single-use — a replay of the same ceremony_id fails.
@@ -91,6 +108,576 @@ public async Task A_passkey_tap_opens_a_staffing_session()
Assert.Contains("invalid_grant", await replay.Content.ReadAsStringAsync(ct));
}
+ [Theory]
+ [InlineData(DeviceBindingIds.ClientSecret)]
+ [InlineData(DeviceBindingIds.None)]
+ public async Task Weaker_terminal_bindings_can_staff_and_refresh_without_dpop(string binding)
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var setup = await SetUpEnrolledTerminalWithGrantedUserAsync(
+ $"fn-staff-{binding}", ct, binding);
+
+ using var tokens = await TapAsync(setup, ct);
+ Assert.Equal("Bearer", tokens.RootElement.GetProperty("token_type").GetString());
+ var refreshToken = tokens.RootElement.GetProperty("refresh_token").GetString();
+ Assert.False(string.IsNullOrWhiteSpace(refreshToken));
+
+ var refresh = await PostTokenForBindingAsync(new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refreshToken!,
+ ["client_id"] = setup.ClientId,
+ }, setup);
+ var refreshBody = await refresh.Content.ReadAsStringAsync(ct);
+ Assert.True(refresh.IsSuccessStatusCode,
+ $"{binding} refresh failed ({(int)refresh.StatusCode}): {refreshBody}");
+ using var refreshed = JsonDocument.Parse(refreshBody);
+ Assert.Equal("Bearer", refreshed.RootElement.GetProperty("token_type").GetString());
+
+ using var scope = Factory.Services.CreateScope();
+ var session = scope.ServiceProvider.GetRequiredService();
+ var staffing = Assert.Single(await session.Query()
+ .Where(item => item.TerminalEnrollmentId == setup.TerminalId).ToListAsync(ct));
+ Assert.Equal(binding, staffing.Evidence.Binding);
+ Assert.Null(staffing.DpopJkt);
+ }
+
+ [Fact]
+ public async Task Legacy_v1_control_chain_survives_f4_until_the_terminal_assignment_is_widened()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var setup = await SetUpEnrolledTerminalWithGrantedUserAsync("fn-legacy-control", ct);
+
+ await RewriteAsLegacyControlTokenAsync(setup.EnrollmentAccessToken, setup.PositionId, ct);
+ await RewriteAsLegacyControlTokenAsync(setup.EnrollmentRefreshToken, setup.PositionId, ct);
+
+ // The pre-F4 chain remains usable while its original singleton
+ // position assignment has not changed.
+ var begin = await BeginStaffingAsync(setup, ct);
+ Assert.Equal(RpId, begin.Options.GetProperty("rpId").GetString());
+ var refresh = await PostTokenAsync(new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = setup.EnrollmentRefreshToken,
+ ["client_id"] = setup.ClientId,
+ }, setup.DeviceKey);
+ var refreshBody = await refresh.Content.ReadAsStringAsync(ct);
+ Assert.True(refresh.IsSuccessStatusCode, refreshBody);
+ using var refreshed = JsonDocument.Parse(refreshBody);
+ var refreshedAccessToken = refreshed.RootElement.GetProperty("access_token").GetString()!;
+ var refreshedRefreshToken = refreshed.RootElement.GetProperty("refresh_token").GetString()!;
+ await AssertLegacyControlTokenAsync(refreshedAccessToken, setup.PositionId, setup.TerminalId, ct);
+
+ var secondResponse = await Client.PostAsJsonAsync("/api/position", new
+ {
+ AccountName = "fn-legacy-control-second",
+ TerminalPolicy = new { Enabled = true },
+ }, JsonOptions, ct);
+ Assert.True(secondResponse.IsSuccessStatusCode, await secondResponse.Content.ReadAsStringAsync(ct));
+ var secondPositionId = new ShortGuid(
+ (await secondResponse.Content.ReadFromJsonAsync(JsonOptions, ct))!.Id).Guid;
+
+ // Public administration requires a fresh slot for this operation. The
+ // event simulates an upgraded data set whose assignment has already
+ // been widened, so both legacy-token entry points must still fail shut.
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var docs = scope.ServiceProvider.GetRequiredService();
+ docs.Events.Append(setup.TerminalId, new TerminalAllowedPositionsChanged(
+ setup.TerminalId, [setup.PositionId, secondPositionId], Guid.NewGuid(), DateTimeOffset.UtcNow));
+ await docs.SaveChangesAsync(ct);
+ }
+
+ var rejectedBegin = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin");
+ rejectedBegin.Headers.Authorization = new AuthenticationHeaderValue("Bearer", refreshedAccessToken);
+ rejectedBegin.Headers.Add(DpopConstants.HeaderName, setup.DeviceKey.CreateProof(
+ "POST", BeginEndpoint, DateTimeOffset.UtcNow, refreshedAccessToken));
+ var rejectedBeginResponse = await Factory.CreateClient().SendAsync(rejectedBegin, ct);
+ Assert.Equal(HttpStatusCode.Forbidden, rejectedBeginResponse.StatusCode);
+ Assert.Contains("Staffing.LegacyControlToken",
+ await rejectedBeginResponse.Content.ReadAsStringAsync(ct));
+
+ var rejectedRefresh = await PostTokenAsync(new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refreshedRefreshToken,
+ ["client_id"] = setup.ClientId,
+ }, setup.DeviceKey);
+ Assert.False(rejectedRefresh.IsSuccessStatusCode);
+ Assert.Contains("requires re-enrollment",
+ await rejectedRefresh.Content.ReadAsStringAsync(ct), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Step_up_is_session_bound_action_bound_short_lived_and_cannot_widen_scopes()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var setup = await SetUpEnrolledTerminalWithGrantedUserAsync("fn-step-up", ct);
+
+ using var staffingTokens = await TapAsync(setup, ct, signCount: 1);
+ var staffingAccessToken = staffingTokens.RootElement.GetProperty("access_token").GetString()!;
+
+ const string action = "alarm:acknowledge";
+ const string nonce = "operation-123";
+ var stepUpUrl = $"/connect/staffing/{setup.TerminalId}/step-up";
+ var beginRequest = new HttpRequestMessage(HttpMethod.Post, stepUpUrl)
+ {
+ Content = JsonContent.Create(new
+ {
+ MethodId = ActivationProofMethodIds.PersonalPasskey,
+ Action = action,
+ Nonce = nonce,
+ }),
+ };
+ beginRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", staffingAccessToken);
+ beginRequest.Headers.Add(DpopConstants.HeaderName, setup.DeviceKey.CreateProof(
+ "POST", $"http://localhost{stepUpUrl}", DateTimeOffset.UtcNow, staffingAccessToken));
+ var beginResponse = await Factory.CreateClient().SendAsync(beginRequest, ct);
+ var beginBody = await beginResponse.Content.ReadAsStringAsync(ct);
+ Assert.True(beginResponse.IsSuccessStatusCode, beginBody);
+ using var begin = JsonDocument.Parse(beginBody);
+ var ceremonyId = begin.RootElement.GetProperty("ceremonyId").GetString()!;
+ var publicKey = begin.RootElement.GetProperty("publicKey");
+ var assertion = setup.Authenticator.CreateAssertionJson(
+ publicKey.GetProperty("challenge").GetString()!, RpId, $"https://{RpId}", signCount: 2);
+
+ var form = StaffingForm(setup.ClientId, ceremonyId, assertion);
+ form["step_up"] = "true";
+ // Adversarial input: the exchange must ignore request scopes and use
+ // the scope snapshot pinned from the current staffing session.
+ form["scope"] = Scopes.OfflineAccess;
+ var response = await PostTokenAsync(form, setup.DeviceKey);
+ var body = await response.Content.ReadAsStringAsync(ct);
+ Assert.True(response.IsSuccessStatusCode, body);
+ using var tokens = JsonDocument.Parse(body);
+ Assert.Equal("DPoP", tokens.RootElement.GetProperty("token_type").GetString());
+ Assert.False(tokens.RootElement.TryGetProperty("refresh_token", out _));
+
+ var accessToken = tokens.RootElement.GetProperty("access_token").GetString()!;
+ using var scope = Factory.Services.CreateScope();
+ var manager = scope.ServiceProvider.GetRequiredService();
+ var token = await manager.FindByReferenceIdAsync(accessToken, ct);
+ Assert.NotNull(token);
+ var payload = await manager.GetPayloadAsync(token!, ct);
+ Assert.False(string.IsNullOrWhiteSpace(payload));
+ var jwt = new JsonWebToken(payload);
+ Assert.Equal(PositionTokenUses.StaffingStepUp,
+ jwt.GetClaim(PositionTokenClaimTypes.TokenUse).Value);
+ Assert.Equal(PositionAuthenticationContextReferences.StaffingStepUp,
+ jwt.GetClaim(Claims.AuthenticationContextReference).Value);
+ Assert.Equal(action, jwt.GetClaim(PositionTokenClaimTypes.StepUpAction).Value);
+ Assert.Equal(nonce, jwt.GetClaim(PositionTokenClaimTypes.StepUpNonce).Value);
+ Assert.Equal(setup.PositionId.ToString(), jwt.GetClaim(Claims.Subject).Value);
+ Assert.Equal(setup.TerminalId.ToString(),
+ jwt.GetClaim(PositionTokenClaimTypes.TerminalId).Value);
+ Assert.Equal(ActivationProofMethodIds.PersonalPasskey,
+ jwt.GetClaim(PositionTokenClaimTypes.ActivationProof).Value);
+ Assert.DoesNotContain(jwt.Claims,
+ claim => claim.Type == Claims.Scope && claim.Value.Contains(Scopes.OfflineAccess, StringComparison.Ordinal));
+ var creationDate = await manager.GetCreationDateAsync(token!, ct);
+ var expirationDate = await manager.GetExpirationDateAsync(token!, ct);
+ Assert.NotNull(creationDate);
+ Assert.NotNull(expirationDate);
+ Assert.InRange(expirationDate!.Value - creationDate!.Value,
+ TimeSpan.Zero, TimeSpan.FromSeconds(60));
+ }
+
+ [Fact]
+ public async Task Multi_position_candidates_are_disclosed_only_after_proof_and_selection_is_single_use()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var setup = await SetUpEnrolledTerminalWithGrantedUserAsync("fn-proof-first-a", ct);
+
+ var secondResponse = await Client.PostAsJsonAsync("/api/position", new
+ {
+ AccountName = "fn-proof-first-b",
+ DisplayName = "Proof-only position B",
+ TerminalPolicy = new { Enabled = true },
+ }, JsonOptions, ct);
+ Assert.True(secondResponse.IsSuccessStatusCode, await secondResponse.Content.ReadAsStringAsync(ct));
+ var second = new ShortGuid(
+ (await secondResponse.Content.ReadFromJsonAsync(JsonOptions, ct))!.Id).Guid;
+ var secondGrant = await Client.PostAsJsonAsync($"/api/position/{new ShortGuid(second)}/grants",
+ new { UserId = new ShortGuid(setup.UserId).ToString() }, JsonOptions, ct);
+ Assert.True(secondGrant.IsSuccessStatusCode, await secondGrant.Content.ReadAsStringAsync(ct));
+
+ // This test shortcut projects the assignment as if both positions had
+ // been selected before enrollment. The public API separately verifies
+ // that an active slot cannot be widened without re-enrollment.
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var docs = scope.ServiceProvider.GetRequiredService();
+ docs.Events.Append(setup.TerminalId, new TerminalAllowedPositionsChanged(
+ setup.TerminalId, [setup.PositionId, second], Guid.NewGuid(), DateTimeOffset.UtcNow));
+ await docs.SaveChangesAsync(ct);
+ }
+
+ var bypassRequest = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin")
+ {
+ Content = JsonContent.Create(new { PositionId = new ShortGuid(second).ToString() }),
+ };
+ bypassRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ bypassRequest.Headers.Add(DpopConstants.HeaderName, setup.DeviceKey.CreateProof(
+ "POST", BeginEndpoint, DateTimeOffset.UtcNow, setup.EnrollmentAccessToken));
+ var bypass = await Factory.CreateClient().SendAsync(bypassRequest, ct);
+ Assert.Equal(HttpStatusCode.Forbidden, bypass.StatusCode);
+ Assert.Contains("Staffing.ProofRequiredBeforeSelection", await bypass.Content.ReadAsStringAsync(ct));
+
+ var beginRequest = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin");
+ beginRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ beginRequest.Headers.Add(DpopConstants.HeaderName, setup.DeviceKey.CreateProof(
+ "POST", BeginEndpoint, DateTimeOffset.UtcNow, setup.EnrollmentAccessToken));
+ var beginResponse = await Factory.CreateClient().SendAsync(beginRequest, ct);
+ var beginBody = await beginResponse.Content.ReadAsStringAsync(ct);
+ Assert.True(beginResponse.IsSuccessStatusCode, beginBody);
+ Assert.DoesNotContain(new ShortGuid(setup.PositionId).ToString(), beginBody, StringComparison.Ordinal);
+ Assert.DoesNotContain(new ShortGuid(second).ToString(), beginBody, StringComparison.Ordinal);
+ Assert.DoesNotContain("Proof-only position B", beginBody, StringComparison.Ordinal);
+ Assert.DoesNotContain("selectionRequired", beginBody, StringComparison.OrdinalIgnoreCase);
+ using var begin = JsonDocument.Parse(beginBody);
+ var initialCeremony = begin.RootElement.GetProperty("ceremonyId").GetString()!;
+ var publicKey = begin.RootElement.GetProperty("publicKey");
+ var assertion = setup.Authenticator.CreateAssertionJson(
+ publicKey.GetProperty("challenge").GetString()!, RpId, $"https://{RpId}", signCount: 1);
+
+ var proofResponse = await PostTokenAsync(
+ StaffingForm(setup.ClientId, initialCeremony, assertion), setup.DeviceKey);
+ var proofBody = await proofResponse.Content.ReadAsStringAsync(ct);
+ Assert.True(proofResponse.IsSuccessStatusCode, proofBody);
+ using var proof = JsonDocument.Parse(proofBody);
+ Assert.True(proof.RootElement.TryGetProperty("selectionRequired", out var selectionRequired), proofBody);
+ Assert.True(selectionRequired.GetBoolean());
+ var continuation = proof.RootElement.GetProperty("ceremonyId").GetString()!;
+ var candidates = proof.RootElement.GetProperty("candidates").EnumerateArray().ToArray();
+ Assert.Equal(2, candidates.Length);
+ Assert.Contains(candidates,
+ candidate => candidate.GetProperty("id").GetString() == new ShortGuid(setup.PositionId).ToString());
+ Assert.Contains(candidates,
+ candidate => candidate.GetProperty("id").GetString() == new ShortGuid(second).ToString());
+
+ var selectionForm = new Dictionary
+ {
+ ["grant_type"] = PositionGrantTypes.StaffingSession,
+ ["client_id"] = setup.ClientId,
+ ["ceremony_id"] = continuation,
+ ["position_id"] = new ShortGuid(second).ToString(),
+ };
+ var selection = await PostTokenAsync(selectionForm, setup.DeviceKey);
+ Assert.True(selection.IsSuccessStatusCode, await selection.Content.ReadAsStringAsync(ct));
+
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var query = scope.ServiceProvider.GetRequiredService();
+ var staffing = Assert.Single(await query.Query()
+ .Where(item => item.TerminalEnrollmentId == setup.TerminalId).ToListAsync(ct));
+ Assert.Equal(second, staffing.PositionPrincipalId);
+ Assert.Equal(setup.UserId, staffing.Evidence.UserId);
+ }
+
+ var replay = await PostTokenAsync(selectionForm, setup.DeviceKey);
+ Assert.False(replay.IsSuccessStatusCode);
+ Assert.Contains("invalid_grant", await replay.Content.ReadAsStringAsync(ct));
+ }
+
+ [Fact]
+ public async Task Password_activation_records_method_evidence_and_refresh_revalidates_its_credential_version()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var setup = await SetUpEnrolledTerminalWithGrantedUserAsync("fn-password", ct);
+ const string password = "PositionPass1234!";
+ string accountName;
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = await users.FindByIdAsync(setup.UserId.ToString());
+ Assert.NotNull(user);
+ Assert.True((await users.AddPasswordAsync(user!, password)).Succeeded);
+ accountName = user!.UserName!;
+ }
+
+ var policy = await Client.PutAsJsonAsync($"/api/position/{new ShortGuid(setup.PositionId)}", new
+ {
+ TerminalPolicy = new
+ {
+ AllowedActivationProofs = new[] { ActivationProofMethodIds.PersonalPassword },
+ },
+ }, JsonOptions, ct);
+ Assert.True(policy.IsSuccessStatusCode, await policy.Content.ReadAsStringAsync(ct));
+
+ var begin = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin")
+ {
+ Content = JsonContent.Create(new
+ {
+ MethodId = ActivationProofMethodIds.PersonalPassword,
+ AccountName = accountName,
+ }),
+ };
+ begin.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ begin.Headers.Add(DpopConstants.HeaderName,
+ setup.DeviceKey.CreateProof("POST", BeginEndpoint, DateTimeOffset.UtcNow, setup.EnrollmentAccessToken));
+ var beginResponse = await Factory.CreateClient().SendAsync(begin, ct);
+ var beginBody = await beginResponse.Content.ReadAsStringAsync(ct);
+ Assert.True(beginResponse.IsSuccessStatusCode, beginBody);
+ using var challenge = JsonDocument.Parse(beginBody);
+ Assert.Equal(ActivationProofMethodIds.PersonalPassword,
+ challenge.RootElement.GetProperty("methodId").GetString());
+ var ceremonyId = challenge.RootElement.GetProperty("ceremonyId").GetString()!;
+
+ var redeem = await PostTokenAsync(StaffingForm(
+ setup.ClientId, ceremonyId, JsonSerializer.Serialize(new { password })), setup.DeviceKey);
+ var redeemBody = await redeem.Content.ReadAsStringAsync(ct);
+ Assert.True(redeem.IsSuccessStatusCode, redeemBody);
+ using var tokens = JsonDocument.Parse(redeemBody);
+ var refreshToken = tokens.RootElement.GetProperty("refresh_token").GetString()!;
+
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var session = scope.ServiceProvider.GetRequiredService();
+ var staffing = Assert.Single(await session.Query()
+ .Where(s => s.TerminalEnrollmentId == setup.TerminalId).ToListAsync(ct));
+ Assert.Equal(ActivationProofMethodIds.PersonalPassword, staffing.Evidence.MethodId);
+ Assert.Equal(setup.UserId, staffing.Evidence.UserId);
+ Assert.Equal(new ShortGuid(setup.GrantId).Guid, staffing.Evidence.GrantId);
+ Assert.NotNull(staffing.Evidence.CredentialId);
+ }
+
+ // A password reset/change rotates the security stamp. Even if an
+ // immediate lifecycle hook were missed, refresh must fail closed.
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = await users.FindByIdAsync(setup.UserId.ToString());
+ Assert.True((await users.UpdateSecurityStampAsync(user!)).Succeeded);
+ }
+
+ var staleRefresh = await PostTokenAsync(new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refreshToken,
+ ["client_id"] = setup.ClientId,
+ }, setup.DeviceKey);
+ Assert.False(staleRefresh.IsSuccessStatusCode);
+ await AssertSessionEndedAsync(
+ setup.TerminalId, StaffingSessionEndReason.ActivationCredentialInvalidated, ct);
+ }
+
+ [Fact]
+ public async Task Email_otp_activation_opens_a_session_and_refresh_revalidates_the_method()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ const string accountName = "fn-email-otp";
+ var setup = await SetUpEnrolledTerminalWithGrantedUserAsync(accountName, ct);
+ string loginName;
+ string email;
+
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = await users.FindByIdAsync(setup.UserId.ToString());
+ Assert.NotNull(user);
+ user!.EmailOtpEnabled = true;
+ user.EmailConfirmed = true;
+ Assert.True((await users.UpdateAsync(user)).Succeeded);
+ loginName = user.UserName!;
+ email = user.Email!;
+ }
+
+ var policy = await Client.PutAsJsonAsync($"/api/position/{new ShortGuid(setup.PositionId)}", new
+ {
+ TerminalPolicy = new
+ {
+ AllowedActivationProofs = new[] { ActivationProofMethodIds.PersonalEmailOtp },
+ },
+ }, JsonOptions, ct);
+ Assert.True(policy.IsSuccessStatusCode, await policy.Content.ReadAsStringAsync(ct));
+
+ var begin = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin")
+ {
+ Content = JsonContent.Create(new
+ {
+ MethodId = ActivationProofMethodIds.PersonalEmailOtp,
+ AccountName = loginName,
+ }),
+ };
+ begin.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ begin.Headers.Add(DpopConstants.HeaderName,
+ setup.DeviceKey.CreateProof("POST", BeginEndpoint, DateTimeOffset.UtcNow, setup.EnrollmentAccessToken));
+ var beginResponse = await Factory.CreateClient().SendAsync(begin, ct);
+ var beginBody = await beginResponse.Content.ReadAsStringAsync(ct);
+ Assert.True(beginResponse.IsSuccessStatusCode, beginBody);
+ using var challenge = JsonDocument.Parse(beginBody);
+ Assert.Equal(ActivationProofMethodIds.PersonalEmailOtp,
+ challenge.RootElement.GetProperty("methodId").GetString());
+ var ceremonyId = challenge.RootElement.GetProperty("ceremonyId").GetString()!;
+
+ var mailbox = Factory.Services.GetRequiredService();
+ var message = mailbox.GetLastEmailTo(email);
+ Assert.NotNull(message);
+ var match = System.Text.RegularExpressions.Regex.Match(message!.HtmlBody, @"(\d{6})");
+ Assert.True(match.Success, "No six-digit staffing OTP was found in the captured e-mail.");
+
+ var redeem = await PostTokenAsync(StaffingForm(setup.ClientId, ceremonyId,
+ JsonSerializer.Serialize(new { code = match.Groups[1].Value })), setup.DeviceKey);
+ var redeemBody = await redeem.Content.ReadAsStringAsync(ct);
+ Assert.True(redeem.IsSuccessStatusCode, redeemBody);
+ using var tokens = JsonDocument.Parse(redeemBody);
+ var refreshToken = tokens.RootElement.GetProperty("refresh_token").GetString()!;
+
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var session = scope.ServiceProvider.GetRequiredService();
+ var staffing = Assert.Single(await session.Query()
+ .Where(item => item.TerminalEnrollmentId == setup.TerminalId).ToListAsync(ct));
+ Assert.Equal(ActivationProofMethodIds.PersonalEmailOtp, staffing.Evidence.MethodId);
+ Assert.Equal(setup.UserId, staffing.Evidence.UserId);
+ Assert.Equal(new ShortGuid(setup.GrantId).Guid, staffing.Evidence.GrantId);
+ }
+
+ // Even if an immediate invalidation hook were ever missed, refresh
+ // must fail closed after the user disables this activation method.
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var users = scope.ServiceProvider.GetRequiredService>();
+ var user = await users.FindByIdAsync(setup.UserId.ToString());
+ user!.EmailOtpEnabled = false;
+ Assert.True((await users.UpdateAsync(user)).Succeeded);
+ }
+
+ var staleRefresh = await PostTokenAsync(new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refreshToken,
+ ["client_id"] = setup.ClientId,
+ }, setup.DeviceKey);
+ Assert.False(staleRefresh.IsSuccessStatusCode);
+ await AssertSessionEndedAsync(
+ setup.TerminalId, StaffingSessionEndReason.ActivationCredentialInvalidated, ct);
+ }
+
+ [Fact]
+ public async Task Position_token_registers_staffs_refreshes_and_revocation_cuts_the_chain()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var setup = await SetUpEnrolledTerminalWithGrantedUserAsync("fn-position-token", ct);
+
+ var policy = await Client.PutAsJsonAsync($"/api/position/{new ShortGuid(setup.PositionId)}", new
+ {
+ TerminalPolicy = new
+ {
+ AllowedActivationProofs = new[] { ActivationProofMethodIds.PositionToken },
+ },
+ }, JsonOptions, ct);
+ Assert.True(policy.IsSuccessStatusCode, await policy.Content.ReadAsStringAsync(ct));
+
+ var create = await Client.PostAsJsonAsync(
+ $"/api/position/{new ShortGuid(setup.PositionId)}/activation-tokens",
+ new { Label = "Staffing position key" }, JsonOptions, ct);
+ var createBody = await create.Content.ReadAsStringAsync(ct);
+ Assert.True(create.IsSuccessStatusCode, createBody);
+ var token = JsonSerializer.Deserialize(createBody, JsonOptions)!;
+ var tokenGuid = new ShortGuid(token.Id).Guid;
+
+ using var authenticator = new SoftwareWebAuthnAuthenticator(
+ Encoding.UTF8.GetBytes(tokenGuid.ToString()));
+ var registrationBeginUrl = $"/connect/activation-token/{token.Id}/register/begin";
+ var registrationBegin = new HttpRequestMessage(HttpMethod.Post, registrationBeginUrl);
+ registrationBegin.Headers.Authorization =
+ new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ registrationBegin.Headers.Add(DpopConstants.HeaderName, setup.DeviceKey.CreateProof(
+ "POST", $"http://localhost{registrationBeginUrl}", DateTimeOffset.UtcNow,
+ setup.EnrollmentAccessToken));
+ var registrationBeginResponse = await Factory.CreateClient().SendAsync(registrationBegin, ct);
+ var registrationBeginBody = await registrationBeginResponse.Content.ReadAsStringAsync(ct);
+ Assert.True(registrationBeginResponse.IsSuccessStatusCode, registrationBeginBody);
+ using var registration = JsonDocument.Parse(registrationBeginBody);
+ var registrationCeremonyId = registration.RootElement.GetProperty("ceremonyId").GetString()!;
+ var options = registration.RootElement.GetProperty("options");
+ var attestation = authenticator.CreateAttestationJson(
+ options.GetProperty("challenge").GetString()!, RpId, $"https://{RpId}");
+ using var attestationDocument = JsonDocument.Parse(attestation);
+
+ var registrationCompleteUrl = $"/connect/activation-token/{token.Id}/register";
+ var registrationComplete = new HttpRequestMessage(HttpMethod.Post, registrationCompleteUrl)
+ {
+ Content = JsonContent.Create(new
+ {
+ ceremonyId = registrationCeremonyId,
+ attestation = attestationDocument.RootElement.Clone(),
+ }),
+ };
+ registrationComplete.Headers.Authorization =
+ new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ registrationComplete.Headers.Add(DpopConstants.HeaderName, setup.DeviceKey.CreateProof(
+ "POST", $"http://localhost{registrationCompleteUrl}", DateTimeOffset.UtcNow,
+ setup.EnrollmentAccessToken));
+ var registrationCompleteResponse = await Factory.CreateClient().SendAsync(registrationComplete, ct);
+ Assert.True(registrationCompleteResponse.IsSuccessStatusCode,
+ await registrationCompleteResponse.Content.ReadAsStringAsync(ct));
+
+ var begin = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin")
+ {
+ Content = JsonContent.Create(new { MethodId = ActivationProofMethodIds.PositionToken }),
+ };
+ begin.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ begin.Headers.Add(DpopConstants.HeaderName,
+ setup.DeviceKey.CreateProof("POST", BeginEndpoint, DateTimeOffset.UtcNow, setup.EnrollmentAccessToken));
+ var beginResponse = await Factory.CreateClient().SendAsync(begin, ct);
+ var beginBody = await beginResponse.Content.ReadAsStringAsync(ct);
+ Assert.True(beginResponse.IsSuccessStatusCode, beginBody);
+ using var challenge = JsonDocument.Parse(beginBody);
+ var ceremonyId = challenge.RootElement.GetProperty("ceremonyId").GetString()!;
+ var publicKey = challenge.RootElement.GetProperty("publicKey");
+ var assertion = authenticator.CreateAssertionJson(
+ publicKey.GetProperty("challenge").GetString()!, RpId, $"https://{RpId}");
+
+ var redeem = await PostTokenAsync(
+ StaffingForm(setup.ClientId, ceremonyId, assertion), setup.DeviceKey);
+ var redeemBody = await redeem.Content.ReadAsStringAsync(ct);
+ Assert.True(redeem.IsSuccessStatusCode, redeemBody);
+ using var staffingTokens = JsonDocument.Parse(redeemBody);
+ var refreshToken = staffingTokens.RootElement.GetProperty("refresh_token").GetString()!;
+
+ using (var scope = Factory.Services.CreateScope())
+ {
+ var query = scope.ServiceProvider.GetRequiredService();
+ var staffing = Assert.Single(await query.Query()
+ .Where(item => item.TerminalEnrollmentId == setup.TerminalId).ToListAsync(ct));
+ Assert.Equal(ActivationProofMethodIds.PositionToken, staffing.Evidence.MethodId);
+ Assert.Equal(tokenGuid, staffing.Evidence.ActivationTokenId);
+ Assert.NotNull(staffing.Evidence.CredentialId);
+ Assert.Null(staffing.Evidence.UserId);
+ Assert.Null(staffing.Evidence.GrantId);
+ }
+
+ var validRefresh = await PostTokenAsync(new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refreshToken,
+ ["client_id"] = setup.ClientId,
+ }, setup.DeviceKey);
+ Assert.True(validRefresh.IsSuccessStatusCode, await validRefresh.Content.ReadAsStringAsync(ct));
+
+ var revoke = await Client.PostAsync($"/api/activation-token/{token.Id}/revoke", null, ct);
+ Assert.True(revoke.IsSuccessStatusCode, await revoke.Content.ReadAsStringAsync(ct));
+ await AssertSessionEndedAsync(
+ setup.TerminalId, StaffingSessionEndReason.ActivationTokenRevoked, ct);
+
+ var staleRefresh = await PostTokenAsync(new Dictionary
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refreshToken,
+ ["client_id"] = setup.ClientId,
+ }, setup.DeviceKey);
+ Assert.False(staleRefresh.IsSuccessStatusCode);
+ }
+
[Fact]
public async Task A_second_tap_supersedes_the_active_session()
{
@@ -196,6 +783,33 @@ public async Task Begin_requires_the_flag_and_an_enrollment_token()
Assert.Equal(HttpStatusCode.Forbidden, prooflessResp.StatusCode);
Assert.Contains("DPoP", await prooflessResp.Content.ReadAsStringAsync(ct));
+ // A structurally valid resource proof without ath is still invalid:
+ // the proof has to be bound to this exact reference access token.
+ var noAth = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin");
+ noAth.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ noAth.Headers.Add(DpopConstants.HeaderName,
+ setup.DeviceKey.CreateProof("POST", BeginEndpoint, DateTimeOffset.UtcNow));
+ var noAthResponse = await Factory.CreateClient().SendAsync(noAth, ct);
+ Assert.Equal(HttpStatusCode.Forbidden, noAthResponse.StatusCode);
+
+ // A proof jti is one-shot across the realm, including resource
+ // endpoints (the token endpoint already enforces the same store).
+ var replayProof = setup.DeviceKey.CreateProof(
+ "POST", BeginEndpoint, DateTimeOffset.UtcNow, setup.EnrollmentAccessToken);
+ var firstUse = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin");
+ firstUse.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ firstUse.Headers.Add(DpopConstants.HeaderName, replayProof);
+ var firstUseResponse = await Factory.CreateClient().SendAsync(firstUse, ct);
+ Assert.True(firstUseResponse.IsSuccessStatusCode,
+ await firstUseResponse.Content.ReadAsStringAsync(ct));
+
+ var replay = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin");
+ replay.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
+ replay.Headers.Add(DpopConstants.HeaderName, replayProof);
+ var replayResponse = await Factory.CreateClient().SendAsync(replay, ct);
+ Assert.Equal(HttpStatusCode.Forbidden, replayResponse.StatusCode);
+ Assert.Contains("Staffing.DpopReplay", await replayResponse.Content.ReadAsStringAsync(ct));
+
// Flag off → the surface does not exist.
SetFeatureFlag(false);
try
@@ -540,7 +1154,7 @@ private async Task PostLockAsync(Guid terminalId, string ac
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
request.Headers.Add(DpopConstants.HeaderName,
- key.CreateProof("POST", $"http://localhost{url}", DateTimeOffset.UtcNow));
+ key.CreateProof("POST", $"http://localhost{url}", DateTimeOffset.UtcNow, accessToken));
return await Factory.CreateClient().SendAsync(request, TestContext.Current.CancellationToken);
}
@@ -565,24 +1179,40 @@ private sealed record StaffingSetup(
Guid UserId,
string GrantId,
string EnrollmentAccessToken,
+ string EnrollmentRefreshToken,
DpopProofBuilder DeviceKey,
- SoftwareWebAuthnAuthenticator Authenticator);
+ SoftwareWebAuthnAuthenticator Authenticator,
+ string Binding,
+ string? ClientSecret);
/// Position (policy on) + granted user with a seeded RP-ID
/// passkey + terminal slot enrolled via the full MG-FT-04 device flow.
- private async Task SetUpEnrolledTerminalWithGrantedUserAsync(string accountName, CancellationToken ct)
+ private async Task SetUpEnrolledTerminalWithGrantedUserAsync(
+ string accountName,
+ CancellationToken ct,
+ string binding = DeviceBindingIds.Dpop)
{
// Position + terminal slot via the admin API.
var fnResp = await Client.PostAsJsonAsync("/api/position", new
{
AccountName = accountName,
- TerminalPolicy = new { Enabled = true },
+ TerminalPolicy = new
+ {
+ Enabled = true,
+ AllowedDeviceBindings = new[] { binding },
+ },
}, JsonOptions, ct);
Assert.True(fnResp.IsSuccessStatusCode, await fnResp.Content.ReadAsStringAsync(ct));
var fnId = new ShortGuid((await fnResp.Content.ReadFromJsonAsync(JsonOptions, ct))!.Id).Guid;
var termResp = await Client.PostAsJsonAsync($"/api/position/{new ShortGuid(fnId)}/terminals",
- new { DisplayName = "Staff-Terminal", Location = "Tor 1", WebAuthnRpId = RpId }, JsonOptions, ct);
+ new
+ {
+ DisplayName = "Staff-Terminal",
+ Location = "Tor 1",
+ WebAuthnRpId = RpId,
+ Binding = binding,
+ }, JsonOptions, ct);
Assert.True(termResp.IsSuccessStatusCode, await termResp.Content.ReadAsStringAsync(ct));
var terminal = (await termResp.Content.ReadFromJsonAsync(JsonOptions, ct))!;
var terminalId = new ShortGuid(terminal.Id).Guid;
@@ -627,25 +1257,30 @@ private async Task SetUpEnrolledTerminalWithGrantedUserAsync(stri
// Enroll the terminal via the MG-FT-04 device flow.
var deviceKey = new DpopProofBuilder();
var (deviceCode, userCode) = await RequestDeviceCodeAsync(
- terminal.ClientId, deviceKey.CreateProof("POST", DeviceEndpoint, DateTimeOffset.UtcNow));
+ terminal.ClientId,
+ binding == DeviceBindingIds.Dpop
+ ? deviceKey.CreateProof("POST", DeviceEndpoint, DateTimeOffset.UtcNow)
+ : null,
+ terminal.ClientSecret);
var admin = await CreateAuthenticatedClientAsync("tu", "TestPass1234");
await OpenVerificationAsync(admin, userCode);
var approve = await SubmitDecisionAsync(admin, userCode);
Assert.True((int)approve.StatusCode < 400,
$"approve failed ({(int)approve.StatusCode}): {await approve.Content.ReadAsStringAsync(ct)}");
- var poll = await PostTokenAsync(new Dictionary
+ var poll = await PostTokenForBindingAsync(new Dictionary
{
["grant_type"] = DeviceCodeGrant,
["device_code"] = deviceCode,
["client_id"] = terminal.ClientId,
- }, deviceKey);
+ }, binding, deviceKey, terminal.ClientSecret);
var pollBody = await poll.Content.ReadAsStringAsync(ct);
Assert.True(poll.IsSuccessStatusCode, $"enrollment poll failed ({(int)poll.StatusCode}): {pollBody}");
using var tokens = JsonDocument.Parse(pollBody);
var accessToken = tokens.RootElement.GetProperty("access_token").GetString()!;
+ var refreshToken = tokens.RootElement.GetProperty("refresh_token").GetString()!;
return new StaffingSetup(fnId, terminalId, terminal.ClientId, userId, grantId,
- accessToken, deviceKey, authenticator);
+ accessToken, refreshToken, deviceKey, authenticator, binding, terminal.ClientSecret);
}
// ─── flow helpers ─────────────────────────────────────────────────────
@@ -656,8 +1291,10 @@ private async Task BeginStaffingAsync(StaffingSetup setup, Cancella
{
var request = new HttpRequestMessage(HttpMethod.Post, "/connect/staffing/begin");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", setup.EnrollmentAccessToken);
- request.Headers.Add(DpopConstants.HeaderName,
- setup.DeviceKey.CreateProof("POST", BeginEndpoint, DateTimeOffset.UtcNow));
+ if (setup.Binding == DeviceBindingIds.Dpop)
+ request.Headers.Add(DpopConstants.HeaderName,
+ setup.DeviceKey.CreateProof("POST", BeginEndpoint, DateTimeOffset.UtcNow,
+ setup.EnrollmentAccessToken));
var resp = await Factory.CreateClient().SendAsync(request, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
Assert.True(resp.IsSuccessStatusCode, $"staffing begin failed ({(int)resp.StatusCode}): {body}");
@@ -668,7 +1305,7 @@ private async Task BeginStaffingAsync(StaffingSetup setup, Cancella
}
private Task RedeemStaffingAsync(StaffingSetup setup, string ceremonyId, string assertion) =>
- PostTokenAsync(StaffingForm(setup.ClientId, ceremonyId, assertion), setup.DeviceKey);
+ PostTokenForBindingAsync(StaffingForm(setup.ClientId, ceremonyId, assertion), setup);
private static Dictionary StaffingForm(string clientId, string ceremonyId, string assertion) => new()
{
@@ -690,26 +1327,55 @@ private async Task TapAsync(StaffingSetup setup, CancellationToken
return JsonDocument.Parse(body);
}
- private async Task PostTokenAsync(Dictionary form, DpopProofBuilder key)
+ private Task PostTokenAsync(
+ Dictionary form,
+ DpopProofBuilder key) =>
+ PostTokenForBindingAsync(form, DeviceBindingIds.Dpop, key, clientSecret: null);
+
+ private Task PostTokenForBindingAsync(
+ Dictionary form,
+ StaffingSetup setup) =>
+ PostTokenForBindingAsync(form, setup.Binding, setup.DeviceKey, setup.ClientSecret);
+
+ private async Task PostTokenForBindingAsync(
+ Dictionary form,
+ string binding,
+ DpopProofBuilder key,
+ string? clientSecret)
{
+ var values = form.ToList();
+ if (binding == DeviceBindingIds.ClientSecret)
+ {
+ Assert.False(string.IsNullOrWhiteSpace(clientSecret));
+ values.Add(new KeyValuePair("client_secret", clientSecret!));
+ }
var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
{
- Content = new FormUrlEncodedContent(form),
+ Content = new FormUrlEncodedContent(values),
};
- request.Headers.Add(DpopConstants.HeaderName, key.CreateProof("POST", TokenEndpoint, DateTimeOffset.UtcNow));
+ if (binding == DeviceBindingIds.Dpop)
+ request.Headers.Add(DpopConstants.HeaderName,
+ key.CreateProof("POST", TokenEndpoint, DateTimeOffset.UtcNow));
return await Factory.CreateClient().SendAsync(request, TestContext.Current.CancellationToken);
}
- private async Task<(string DeviceCode, string UserCode)> RequestDeviceCodeAsync(string clientId, string dpopProof)
+ private async Task<(string DeviceCode, string UserCode)> RequestDeviceCodeAsync(
+ string clientId,
+ string? dpopProof,
+ string? clientSecret = null)
{
+ var values = new List>
+ {
+ new("client_id", clientId),
+ };
+ if (clientSecret is not null)
+ values.Add(new KeyValuePair("client_secret", clientSecret));
var request = new HttpRequestMessage(HttpMethod.Post, "/connect/device")
{
- Content = new FormUrlEncodedContent(new List>
- {
- new("client_id", clientId),
- }),
+ Content = new FormUrlEncodedContent(values),
};
- request.Headers.Add(DpopConstants.HeaderName, dpopProof);
+ if (dpopProof is not null)
+ request.Headers.Add(DpopConstants.HeaderName, dpopProof);
var resp = await Factory.CreateClient().SendAsync(request, TestContext.Current.CancellationToken);
var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(resp.IsSuccessStatusCode, $"/connect/device failed ({(int)resp.StatusCode}): {body}");
@@ -733,6 +1399,83 @@ private Task SubmitDecisionAsync(HttpClient cookieClient, s
new("decision", "approve"),
}), TestContext.Current.CancellationToken);
+ private async Task RewriteAsLegacyControlTokenAsync(
+ string referenceToken,
+ Guid positionId,
+ CancellationToken ct)
+ {
+ using var scope = Factory.Services.CreateScope();
+ var manager = scope.ServiceProvider.GetRequiredService();
+ var token = await manager.FindByReferenceIdAsync(referenceToken, ct);
+ Assert.NotNull(token);
+ var descriptor = new OpenIddictTokenDescriptor();
+ await manager.PopulateAsync(descriptor, token!, ct);
+ Assert.False(string.IsNullOrWhiteSpace(descriptor.Payload));
+
+ var current = new JsonWebToken(descriptor.Payload);
+ var keyStore = scope.ServiceProvider.GetRequiredService();
+ var serverOptions = scope.ServiceProvider
+ .GetRequiredService>().CurrentValue;
+ var verificationKeys = (await keyStore.GetVerificationKeysAsync(
+ TenantConstants.SystemTenantId, ct))
+ .Concat(serverOptions.SigningCredentials.Select(item => item.Key))
+ .ToArray();
+ var handler = new JsonWebTokenHandler();
+ var validation = await handler.ValidateTokenAsync(descriptor.Payload, new TokenValidationParameters
+ {
+ IssuerSigningKeys = verificationKeys,
+ TokenDecryptionKeys = serverOptions.EncryptionCredentials.Select(item => item.Key),
+ ValidateIssuer = false,
+ ValidateAudience = false,
+ ValidateLifetime = false,
+ RequireExpirationTime = false,
+ });
+ Assert.True(validation.IsValid, validation.Exception?.ToString());
+ var validated = Assert.IsType(validation.SecurityToken);
+ var inner = validated.InnerToken ?? validated;
+ var payloadJson = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(inner.EncodedPayload));
+ var payload = JsonNode.Parse(payloadJson)!.AsObject();
+ payload[Claims.Subject] = positionId.ToString();
+ payload[PositionTokenClaimTypes.PrincipalType] = PositionPrincipalTypes.Position;
+
+ var signingCredentials = serverOptions.SigningCredentials.FirstOrDefault(
+ item => string.Equals(item.Key.KeyId, inner.Kid, StringComparison.Ordinal))
+ ?? await keyStore.GetActiveSigningCredentialsAsync(TenantConstants.SystemTenantId, ct);
+ var headers = new Dictionary();
+ if (!string.IsNullOrWhiteSpace(current.Typ))
+ headers["typ"] = current.Typ;
+ var innerHeaders = new Dictionary();
+ if (!string.IsNullOrWhiteSpace(inner.Typ))
+ innerHeaders["typ"] = inner.Typ;
+ descriptor.Payload = current.IsEncrypted
+ ? handler.CreateToken(payload.ToJsonString(), signingCredentials,
+ serverOptions.EncryptionCredentials[0], CompressionAlgorithms.Deflate,
+ headers, innerHeaders)
+ : handler.CreateToken(payload.ToJsonString(), signingCredentials, innerHeaders);
+ descriptor.Subject = positionId.ToString();
+ await manager.UpdateAsync(token!, descriptor, ct);
+ }
+
+ private async Task AssertLegacyControlTokenAsync(
+ string referenceToken,
+ Guid positionId,
+ Guid terminalId,
+ CancellationToken ct)
+ {
+ using var scope = Factory.Services.CreateScope();
+ var manager = scope.ServiceProvider.GetRequiredService();
+ var token = await manager.FindByReferenceIdAsync(referenceToken, ct);
+ Assert.NotNull(token);
+ var payload = await manager.GetPayloadAsync(token!, ct);
+ Assert.False(string.IsNullOrWhiteSpace(payload));
+ var jwt = new JsonWebToken(payload);
+ Assert.Equal(positionId.ToString(), jwt.GetClaim(Claims.Subject).Value);
+ Assert.Equal(PositionPrincipalTypes.Position,
+ jwt.GetClaim(PositionTokenClaimTypes.PrincipalType).Value);
+ Assert.Equal(terminalId.ToString(),
+ jwt.GetClaim(PositionTokenClaimTypes.TerminalId).Value);
+ }
+
// Minimal ES256 DPoP proof factory — same shape as the enrollment tests'.
internal sealed class DpopProofBuilder : IDisposable
{
@@ -748,7 +1491,7 @@ public DpopProofBuilder()
Jkt = JwkThumbprint.ForEc("P-256", p.Q.X!, p.Q.Y!);
}
- public string CreateProof(string htm, string htu, DateTimeOffset iat)
+ public string CreateProof(string htm, string htu, DateTimeOffset iat, string? accessToken = null)
{
var p = _ec.ExportParameters(false);
var jwk = new { kty = "EC", crv = "P-256", x = B64(p.Q.X!), y = B64(p.Q.Y!) };
@@ -760,6 +1503,9 @@ public string CreateProof(string htm, string htu, DateTimeOffset iat)
["htu"] = htu,
["iat"] = iat.ToUnixTimeSeconds(),
};
+ if (accessToken is not null)
+ payload["ath"] = Base64Url.EncodeToString(
+ SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
var signingInput = $"{Seg(header)}.{Seg(payload)}";
var sig = _ec.SignData(
Encoding.ASCII.GetBytes(signingInput),
diff --git a/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs
index 526a12ee..c5fb8782 100644
--- a/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs
@@ -58,7 +58,8 @@ public async Task A_staffing_client_with_a_linked_position_creates_the_slot_atom
var resp = await PostClientAsync(new
{
- ClientId = "ignored-by-the-terminal-path",
+ ClientId = "tc-linked-client",
+ DisplayName = "Terminal client: Tor 3",
ClientType = "public",
AllowedGrantTypes = new[] { StaffingGrant },
LinkedPositionPrincipalId = fn,
@@ -70,9 +71,10 @@ public async Task A_staffing_client_with_a_linked_position_creates_the_slot_atom
Assert.True(resp.IsSuccessStatusCode, $"create failed ({(int)resp.StatusCode}): {body}");
var created = JsonSerializer.Deserialize(body);
- // ClientId follows the convention, never the caller's value.
var clientId = created.GetProperty("Client").GetProperty("ClientId").GetString()!;
- Assert.StartsWith("tc-linked.terminal.", clientId);
+ Assert.Equal("tc-linked-client", clientId);
+ Assert.Equal("Terminal client: Tor 3",
+ created.GetProperty("Client").GetProperty("DisplayName").GetString());
// Nulls may be omitted from the payload entirely — assert "absent or null".
Assert.False(created.TryGetProperty("ClientSecret", out var secret) && secret.ValueKind is not JsonValueKind.Null);
Assert.False(created.TryGetProperty("CreatedPosition", out var inlinePosition) && inlinePosition.ValueKind is not JsonValueKind.Null);
@@ -95,7 +97,7 @@ public async Task A_staffing_client_with_a_linked_position_creates_the_slot_atom
var client = (await session.Query()
.Where(c => c.ClientId == clientId).ToListAsync(ct)).Single();
Assert.Equal("public", client.ClientType);
- Assert.Equal(new ShortGuid(fn).Guid, client.LinkedPositionPrincipalId);
+ Assert.Null(client.LinkedPositionPrincipalId);
Assert.Equal(new ShortGuid(terminalId!).Guid, client.ManagedTerminalEnrollmentId);
Assert.Null(client.LinkedServiceAccountId);
Assert.Equal(AccessTokenType.Reference.ToString(), client.Settings[OAuthApplicationSettingKeys.AccessTokenType]);
@@ -138,6 +140,10 @@ public async Task A_staffing_client_with_an_inline_position_creates_position_slo
Assert.Equal(JsonValueKind.Object, position.ValueKind);
var positionId = position.GetProperty("Id").GetString()!;
Assert.Equal("tc-inline", position.GetProperty("AccountName").GetString());
+ Assert.Equal(["personal-passkey"], position.GetProperty("TerminalPolicy")
+ .GetProperty("AllowedActivationProofs").EnumerateArray().Select(x => x.GetString()!).ToArray());
+ Assert.Equal(["dpop"], position.GetProperty("TerminalPolicy")
+ .GetProperty("AllowedDeviceBindings").EnumerateArray().Select(x => x.GetString()!).ToArray());
// The position is real, terminal-enabled, and carries the slot.
var loaded = await Client.GetFromJsonAsync($"/api/position/{positionId}", JsonOptions, ct);
@@ -148,10 +154,39 @@ public async Task A_staffing_client_with_an_inline_position_creates_position_slo
var slots = await Client.GetFromJsonAsync>($"/api/position/{positionId}/terminals", JsonOptions, ct);
var slot = Assert.Single(slots!);
Assert.Equal("Empfang", slot.DisplayName);
- Assert.StartsWith("tc-inline.terminal.", slot.ClientId);
+ Assert.StartsWith("terminal.", slot.ClientId);
Assert.Equal(created.GetProperty("Client").GetProperty("ClientId").GetString(), slot.ClientId);
}
+ [Fact]
+ public async Task An_inline_position_rejects_unknown_policy_ids_like_the_position_endpoint()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+
+ var resp = await PostClientAsync(new
+ {
+ ClientId = "",
+ ClientType = "public",
+ AllowedGrantTypes = new[] { StaffingGrant },
+ NewPosition = new
+ {
+ AccountName = "tc-invalid-policy",
+ TerminalPolicy = new
+ {
+ Enabled = true,
+ AllowedActivationProofs = new[] { "invented-proof" },
+ AllowedDeviceBindings = new[] { "dpop" },
+ },
+ },
+ TerminalDisplayName = "Empfang",
+ WebAuthnRpId = RpId,
+ }, ct);
+
+ Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
+ Assert.Contains("unknown or unavailable", await resp.Content.ReadAsStringAsync(ct));
+ }
+
[Fact]
public async Task An_inline_position_stages_grant_users_in_the_same_save()
{
diff --git a/src/dotnet/Modgud.Api.Tests/Positions/TerminalDeviceEnrollmentTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/TerminalDeviceEnrollmentTests.cs
index 9262a91d..26a93cac 100644
--- a/src/dotnet/Modgud.Api.Tests/Positions/TerminalDeviceEnrollmentTests.cs
+++ b/src/dotnet/Modgud.Api.Tests/Positions/TerminalDeviceEnrollmentTests.cs
@@ -111,6 +111,46 @@ public async Task A_pending_slot_enrolls_via_the_dpop_bound_device_flow()
Assert.False(string.IsNullOrEmpty(refreshed.RootElement.GetProperty("access_token").GetString()));
}
+ [Theory]
+ [InlineData(DeviceBindingIds.ClientSecret)]
+ [InlineData(DeviceBindingIds.None)]
+ public async Task Weaker_bindings_still_require_and_complete_admin_approved_device_flow(string binding)
+ {
+ var ct = TestContext.Current.CancellationToken;
+ SetFeatureFlag(true);
+ var fn = await CreatePositionAsync($"fn-{binding}", terminalEnabled: true, ct,
+ allowedBindings: [binding]);
+ var terminal = await CreateTerminalAsync(fn, $"Terminal {binding}", ct, binding);
+
+ var expectsSecret = binding == DeviceBindingIds.ClientSecret;
+ Assert.Equal(expectsSecret, !string.IsNullOrWhiteSpace(terminal.ClientSecret));
+ var (deviceCode, userCode) = await RequestDeviceCodeAsync(
+ terminal.ClientId, dpopProof: null, terminal.ClientSecret);
+
+ var admin = await CreateAuthenticatedClientAsync("tu", "TestPass1234");
+ var ticket = await OpenVerificationAsync(admin, userCode);
+ var info = await GetVerificationInfoAsync(admin, ticket, ct);
+ var consent = info.GetProperty("Terminal");
+ Assert.Equal(binding, consent.GetProperty("Binding").GetString());
+ Assert.False(consent.TryGetProperty("DpopFingerprint", out var fingerprint) &&
+ fingerprint.ValueKind is not JsonValueKind.Null);
+
+ var approve = await SubmitDecisionAsync(admin, userCode, approve: true);
+ Assert.True((int)approve.StatusCode < 400, await approve.Content.ReadAsStringAsync(ct));
+ var poll = await PollTokenAsync(
+ terminal.ClientId, deviceCode, dpopProof: null, terminal.ClientSecret);
+ var body = await poll.Content.ReadAsStringAsync(ct);
+ Assert.True(poll.IsSuccessStatusCode, body);
+ using var tokens = JsonDocument.Parse(body);
+ Assert.Equal("Bearer", tokens.RootElement.GetProperty("token_type").GetString());
+ Assert.False(string.IsNullOrWhiteSpace(tokens.RootElement.GetProperty("refresh_token").GetString()));
+
+ var slot = await LoadSlotAsync(terminal.Id, ct);
+ Assert.Equal(TerminalEnrollmentStatus.Active, slot.Status);
+ Assert.Null(slot.DpopJkt);
+ Assert.False(string.IsNullOrWhiteSpace(slot.EnrollmentAuthorizationId));
+ }
+
[Fact]
public async Task Approval_is_refused_without_the_enroll_permission()
{
@@ -290,21 +330,31 @@ public async Task Terminal_verification_is_dark_while_the_flag_is_off()
// ─── flow helpers ─────────────────────────────────────────────────────
- private async Task CreatePositionAsync(string accountName, bool terminalEnabled, CancellationToken ct)
+ private async Task CreatePositionAsync(
+ string accountName,
+ bool terminalEnabled,
+ CancellationToken ct,
+ string[]? allowedBindings = null)
{
var resp = await Client.PostAsJsonAsync("/api/position", new
{
AccountName = accountName,
- TerminalPolicy = terminalEnabled ? new { Enabled = true } : null,
+ TerminalPolicy = terminalEnabled
+ ? new { Enabled = true, AllowedDeviceBindings = allowedBindings }
+ : null,
}, JsonOptions, ct);
Assert.True(resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync(ct));
return (await resp.Content.ReadFromJsonAsync(JsonOptions, ct))!.Id;
}
- private async Task CreateTerminalAsync(string positionId, string displayName, CancellationToken ct)
+ private async Task CreateTerminalAsync(
+ string positionId,
+ string displayName,
+ CancellationToken ct,
+ string binding = DeviceBindingIds.Dpop)
{
var resp = await Client.PostAsJsonAsync($"/api/position/{positionId}/terminals",
- new { DisplayName = displayName, Location = "Tor 3", WebAuthnRpId = RpId }, JsonOptions, ct);
+ new { DisplayName = displayName, Location = "Tor 3", WebAuthnRpId = RpId, Binding = binding }, JsonOptions, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
Assert.True(resp.IsSuccessStatusCode, $"terminal create failed ({(int)resp.StatusCode}): {body}");
return (await resp.Content.ReadFromJsonAsync(JsonOptions, ct))!;
@@ -312,15 +362,17 @@ private async Task CreateTerminalAsync(string positionId, string di
/// Terminal clients have no scp permissions — the device request
/// carries no scope; the granted scopes come from the enrollment principal.
- private async Task<(string DeviceCode, string UserCode)> RequestDeviceCodeAsync(string clientId, string? dpopProof)
+ private async Task<(string DeviceCode, string UserCode)> RequestDeviceCodeAsync(
+ string clientId,
+ string? dpopProof,
+ string? clientSecret = null)
{
var client = Factory.CreateClient();
+ var form = new List> { new("client_id", clientId) };
+ if (clientSecret is not null) form.Add(new("client_secret", clientSecret));
var request = new HttpRequestMessage(HttpMethod.Post, "/connect/device")
{
- Content = new FormUrlEncodedContent(new List>
- {
- new("client_id", clientId),
- }),
+ Content = new FormUrlEncodedContent(form),
};
if (dpopProof is not null) request.Headers.Add(DpopConstants.HeaderName, dpopProof);
@@ -363,17 +415,23 @@ private async Task SubmitDecisionAsync(HttpClient cookieCli
return await cookieClient.PostAsync("/connect/verify", new FormUrlEncodedContent(form), TestContext.Current.CancellationToken);
}
- private async Task PollTokenAsync(string clientId, string deviceCode, string? dpopProof)
+ private async Task PollTokenAsync(
+ string clientId,
+ string deviceCode,
+ string? dpopProof,
+ string? clientSecret = null)
{
var client = Factory.CreateClient();
+ var form = new List>
+ {
+ new("grant_type", DeviceCodeGrant),
+ new("device_code", deviceCode),
+ new("client_id", clientId),
+ };
+ if (clientSecret is not null) form.Add(new("client_secret", clientSecret));
var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
{
- Content = new FormUrlEncodedContent(new List>
- {
- new("grant_type", DeviceCodeGrant),
- new("device_code", deviceCode),
- new("client_id", clientId),
- }),
+ Content = new FormUrlEncodedContent(form),
};
if (dpopProof is not null) request.Headers.Add(DpopConstants.HeaderName, dpopProof);
return await client.SendAsync(request, TestContext.Current.CancellationToken);
diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs
index 1eff62e4..a61ef6f0 100644
--- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs
+++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs
@@ -1,7 +1,9 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
+using BuildingBlocks.Helper;
using Modgud.Api.Features.Auth.PositionTerminals;
+using Modgud.Api.Features.Auth.Staffing;
using Modgud.Authentication.Applications;
using Modgud.Authentication.Sessions;
using Modgud.Authentication.Domain;
@@ -23,6 +25,7 @@
using Modgud.Infrastructure.OpenIddict;
using Modgud.Infrastructure.OpenIddict.Cimd;
using Modgud.Infrastructure.OpenIddict.Dpop;
+using Modgud.Infrastructure.PositionTerminals;
using Modgud.Infrastructure.Persistence.Tenancy;
using Fido2NetLib;
using Fido2NetLib.Objects;
@@ -288,10 +291,12 @@ private static async Task ExchangeAsync(
IEmailOtpService emailOtpService,
RealmScopedFido2Factory fido2Factory,
RpIdResolver rpIdResolver,
+ ActivationProofRegistry activationProofs,
IApplicationSettingsResolver applicationSettingsResolver,
IDocumentSession session,
AppSettings settings,
IOAuthGrantRevoker grantRevoker,
+ IStaffingRevoker staffingRevoker,
Wolverine.IMessageBus bus)
{
var request = httpContext.GetOpenIddictServerRequest()
@@ -360,7 +365,7 @@ private static async Task ExchangeAsync(
{
return await ExchangeStaffingRefreshAsync(
httpContext, request, result.Principal!, settings, session,
- userManager, signInManager, scopeManager, permissionService, grantRevoker, bus);
+ scopeManager, permissionService, activationProofs, grantRevoker, staffingRevoker, bus);
}
var user = await userManager.FindByIdAsync(subject);
@@ -588,10 +593,17 @@ await BakeFederatedResourceAccessAsync(
// StaffingSession for the POSITION (plan §13).
if (string.Equals(request.GrantType, PositionGrantTypes.StaffingSession, StringComparison.Ordinal))
{
+ if (string.Equals((string?)request.GetParameter("step_up"), "true", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals((string?)request.GetParameter("step_up"), "1", StringComparison.Ordinal))
+ {
+ return await ExchangeStaffingStepUpAsync(
+ request, httpContext, settings, session, scopeManager,
+ permissionService, activationProofs, httpContext.RequestAborted);
+ }
return await ExchangeStaffingAsync(
- request, httpContext, settings, session, userManager, signInManager,
+ request, httpContext, settings, session,
scopeManager, applicationManager, authorizationManager, permissionService,
- fido2Factory, rpIdResolver, grantRevoker, bus, httpContext.RequestAborted);
+ activationProofs, grantRevoker, bus, httpContext.RequestAborted);
}
throw new InvalidOperationException("The specified grant type is not supported.");
@@ -648,30 +660,60 @@ static IResult Refuse(string description) =>
if (!request.IsDeviceCodeGrantType() && !request.IsRefreshTokenGrantType())
return Refuse("The token is no longer valid.");
- if (!Guid.TryParse(tokenPrincipal.GetClaim(Claims.Subject), out var positionId) ||
- !Guid.TryParse(tokenPrincipal.GetClaim(PositionTokenClaimTypes.TerminalId), out var terminalId))
+ var isControlV2 = string.Equals(
+ tokenPrincipal.GetClaim(PositionTokenClaimTypes.PrincipalType),
+ PositionPrincipalTypes.Terminal,
+ StringComparison.Ordinal);
+ if (!Guid.TryParse(tokenPrincipal.GetClaim(Claims.Subject), out var subjectId) ||
+ !Guid.TryParse(tokenPrincipal.GetClaim(PositionTokenClaimTypes.TerminalId), out var terminalClaimId))
{
return Refuse("The token is no longer valid.");
}
+ var terminalId = isControlV2 ? subjectId : terminalClaimId;
+ var legacyPositionId = isControlV2 ? (Guid?)null : subjectId;
var ct = httpContext.RequestAborted;
- var position = await session.LoadAsync(positionId, ct);
- if (position is null || position.IsDeleted)
- return Refuse("The position no longer exists.");
- if (!position.TerminalPolicy.Enabled)
- return Refuse("Terminal use is disabled for this position.");
+ var terminal = await session.LoadAsync(terminalId, ct);
+ if (terminal is null)
+ return Refuse("The terminal no longer exists.");
+ var allowedPositionIds = terminal.EffectiveAllowedPositionIds;
+ if (legacyPositionId is { } legacy &&
+ (allowedPositionIds.Count != 1 || allowedPositionIds[0] != legacy))
+ return Refuse("This legacy control-token chain requires re-enrollment before the terminal assignment can change.");
+
+ PositionPrincipal? legacyPosition = null;
+ if (legacyPositionId is { } legacyId)
+ {
+ legacyPosition = await session.LoadAsync(legacyId, ct);
+ if (legacyPosition is null || legacyPosition.IsDeleted || !legacyPosition.TerminalPolicy.Enabled)
+ return Refuse("The position no longer exists or terminal use is disabled.");
+ }
+ else
+ {
+ var hasUsablePosition = false;
+ foreach (var allowedId in allowedPositionIds)
+ {
+ var allowed = await session.LoadAsync(allowedId, ct);
+ if (allowed is { IsDeleted: false, IsActive: true } && allowed.TerminalPolicy.Enabled)
+ {
+ hasUsablePosition = true;
+ break;
+ }
+ }
+ if (!hasUsablePosition)
+ return Refuse("The terminal has no usable position assignment.");
+ }
if (request.IsRefreshTokenGrantType())
{
- var terminal = await session.LoadAsync(terminalId, ct);
- if (terminal is null || terminal.PositionPrincipalId != positionId)
- return Refuse("The token is no longer valid.");
if (!string.Equals(request.ClientId, terminal.ClientId, StringComparison.Ordinal))
return Refuse("The client does not own this terminal slot.");
if (terminal.Status != TerminalEnrollmentStatus.Active)
return Refuse("The terminal slot is no longer active.");
- var refreshed = TerminalEnrollmentPrincipal.Create(position, terminal);
+ var refreshed = isControlV2
+ ? TerminalEnrollmentPrincipal.CreateV2(terminal)
+ : TerminalEnrollmentPrincipal.Create(legacyPosition!, terminal);
refreshed.SetAuthorizationId(tokenPrincipal.GetAuthorizationId());
return Results.SignIn(refreshed, properties: null, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
@@ -681,13 +723,16 @@ static IResult Refuse(string description) =>
// racing polls can never both enroll (the loser's SaveChanges throws).
var stream = await session.Events.FetchForWriting(terminalId, ct);
var slot = stream.Aggregate;
- if (slot is null || slot.PositionPrincipalId != positionId)
+ if (slot is null)
return Refuse("The token is no longer valid.");
if (!string.Equals(request.ClientId, slot.ClientId, StringComparison.Ordinal))
return Refuse("The client does not own this terminal slot.");
- if (slot.Status != TerminalEnrollmentStatus.Pending || slot.DpopJkt is not null)
+ if (slot.Status != TerminalEnrollmentStatus.Pending || slot.EnrollmentAuthorizationId is not null)
return Refuse("The terminal slot is not pending enrollment; re-enrollment requires a fresh slot.");
+ string? jkt = null;
+ if (string.Equals(slot.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal))
+ {
// The key to pin — validated HERE, directly from the header. It cannot
// come from the HttpContext.Items stash and the ledger enforcement
// cannot be trusted to have run yet: DpopProofValidationHandler and
@@ -706,7 +751,7 @@ static IResult Refuse(string description) =>
proofHeader.ToString(), httpContext.Request.Method, htu, DateTimeOffset.UtcNow);
if (!proof.IsValid || string.IsNullOrEmpty(proof.Jkt))
return Refuse("A valid DPoP proof is required to enroll a terminal.");
- var jkt = proof.Jkt;
+ jkt = proof.Jkt;
var deviceCode = request.DeviceCode;
var binding = string.IsNullOrEmpty(deviceCode)
@@ -718,8 +763,11 @@ static IResult Refuse(string description) =>
return Refuse("The device code is not DPoP-bound; terminal enrollment requires a device key.");
if (!string.Equals(jkt, binding.Jkt, StringComparison.Ordinal))
return Refuse("The DPoP proof key does not match the key this device code is bound to.");
+ }
- var principal = TerminalEnrollmentPrincipal.Create(position, slot);
+ var principal = isControlV2
+ ? TerminalEnrollmentPrincipal.CreateV2(slot)
+ : TerminalEnrollmentPrincipal.Create(legacyPosition!, slot);
// Durable anchor of every token this terminal will ever hold in the
// enrollment chain — revoking it (slot revoke, §13.4) cuts the device
@@ -732,7 +780,7 @@ static IResult Refuse(string description) =>
?? throw new InvalidOperationException("The application has no id.");
var authorization = await authorizationManager.CreateAsync(
principal: principal,
- subject: position.Id.ToString(),
+ subject: (isControlV2 ? slot.Id : legacyPosition!.Id).ToString(),
client: clientPk,
type: AuthorizationTypes.AdHoc,
scopes: principal.GetScopes(),
@@ -753,8 +801,11 @@ static IResult Refuse(string description) =>
}
// MG-FT-09 (§17) — the slot went Pending → Active.
- await bus.PublishAsync(new PositionTerminalStatusChanged(
- positionId, slot.Id, TerminalEnrollmentStatus.Active, DateTimeOffset.UtcNow));
+ foreach (var allowedPositionId in slot.EffectiveAllowedPositionIds)
+ {
+ await bus.PublishAsync(new PositionTerminalStatusChanged(
+ allowedPositionId, slot.Id, TerminalEnrollmentStatus.Active, DateTimeOffset.UtcNow));
+ }
principal.SetAuthorizationId(authorizationId);
return Results.SignIn(principal, properties: null, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
@@ -762,6 +813,121 @@ await bus.PublishAsync(new PositionTerminalStatusChanged(
// ──────────────────── MG-FT-05 staffing grant (§13) ───────────────────────
+ private static async Task ExchangeStaffingStepUpAsync(
+ OpenIddictRequest request,
+ HttpContext httpContext,
+ AppSettings settings,
+ IDocumentSession session,
+ IOpenIddictScopeManager scopeManager,
+ IPermissionService permissionService,
+ ActivationProofRegistry activationProofs,
+ CancellationToken ct)
+ {
+ static IResult Refuse(string description) => ForbidNativeGrant(Errors.InvalidGrant, description);
+ if (!settings.Features.PositionTerminals || string.IsNullOrEmpty(request.ClientId))
+ return Refuse("Position terminals are not enabled.");
+
+ var state = await session.Query()
+ .FirstOrDefaultAsync(x => x.ClientId == request.ClientId && !x.IsDeleted, ct);
+ if (state?.ManagedTerminalEnrollmentId is not { } terminalId)
+ return Refuse("The client is not a position-terminal client.");
+ var terminal = await session.LoadAsync(terminalId, ct);
+ if (terminal is null || terminal.Status != TerminalEnrollmentStatus.Active ||
+ terminal.OAuthApplicationId != state.Id ||
+ !string.Equals(terminal.ClientId, request.ClientId, StringComparison.Ordinal))
+ return Refuse("The terminal is not active.");
+
+ if (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal))
+ {
+ var header = httpContext.Request.Headers[DpopConstants.HeaderName];
+ if (header.Count != 1) return Refuse("A DPoP proof is required.");
+ var htu = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}{httpContext.Request.Path}";
+ var proof = DpopProofValidator.Validate(
+ header.ToString(), httpContext.Request.Method, htu, DateTimeOffset.UtcNow);
+ if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal))
+ return Refuse("The DPoP proof key does not match this terminal.");
+ }
+
+ if (!Guid.TryParse((string?)request.GetParameter("ceremony_id"), out var ceremonyId))
+ return Refuse("Invalid or expired step-up ceremony.");
+ var ceremony = await session.LoadAsync(ceremonyId, ct);
+ if (ceremony is null || ceremony.IsExpired || ceremony.IsConsumed ||
+ ceremony.StepUpForStaffingSessionId is not { } staffingSessionId ||
+ ceremony.TerminalEnrollmentId != terminal.Id ||
+ !string.Equals(ceremony.ClientId, request.ClientId, StringComparison.Ordinal) ||
+ (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal) &&
+ !string.Equals(ceremony.DpopJkt, terminal.DpopJkt, StringComparison.Ordinal)))
+ return Refuse("Invalid or expired step-up ceremony.");
+
+ var staffing = await session.LoadAsync(staffingSessionId, ct);
+ var now = DateTimeOffset.UtcNow;
+ if (staffing is not { Status: StaffingSessionStatus.Active } ||
+ staffing.AbsoluteExpiresAt <= now ||
+ staffing.TerminalEnrollmentId != terminal.Id ||
+ staffing.PositionPrincipalId != ceremony.PositionPrincipalId ||
+ terminal.ActiveStaffingSessionId != staffing.Id ||
+ !terminal.EffectiveAllowedPositionIds.Contains(staffing.PositionPrincipalId))
+ return Refuse("The staffing session is no longer active.");
+
+ var position = await session.LoadAsync(staffing.PositionPrincipalId, ct);
+ if (position is null || position.IsDeleted || !position.IsActive || !position.TerminalPolicy.Enabled)
+ return Refuse("The position is no longer available.");
+ var methodId = string.IsNullOrWhiteSpace(ceremony.MethodId)
+ ? ActivationProofMethodIds.PersonalPasskey
+ : ceremony.MethodId;
+ var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct);
+ var requiredProof = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None;
+ var requiredBinding = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None;
+ if (!position.TerminalPolicy.AllowedActivationProofs.Contains(methodId, StringComparer.Ordinal) ||
+ !position.TerminalPolicy.AllowedDeviceBindings.Contains(terminal.Binding, StringComparer.Ordinal) ||
+ !PositionTerminalSecurity.ProofMeetsFloor(methodId, requiredProof) ||
+ !PositionTerminalSecurity.BindingMeetsFloor(terminal.Binding, requiredBinding) ||
+ !activationProofs.TryGet(methodId, out var activationProof))
+ return Refuse("The step-up proof or terminal binding is no longer allowed.");
+
+ ceremony.ConsumedAt = now;
+ session.Store(ceremony);
+ try { await session.SaveChangesAsync(ct); }
+ catch (JasperFx.ConcurrencyException) { return Refuse("Invalid or expired step-up ceremony."); }
+
+ var assertion = (string?)request.GetParameter("assertion");
+ if (string.IsNullOrWhiteSpace(assertion)) return Refuse("Step-up proof verification failed.");
+ var result = await activationProof.CompleteAsync(
+ new ActivationContext(position, terminal, ceremony), assertion, ct);
+ if (result.Failure is not null || result.Evidence is not { } evidence)
+ return Refuse(result.Failure?.Message ?? "Step-up proof verification failed.");
+
+ var principal = StaffingPrincipal.Create(position, terminal, staffing.Id, now, evidence.MethodId);
+ principal.SetClaim(PositionTokenClaimTypes.TokenUse, PositionTokenUses.StaffingStepUp);
+ principal.SetClaim(Claims.AuthenticationContextReference, PositionAuthenticationContextReferences.StaffingStepUp);
+ if (ceremony.StepUpAction is not null)
+ principal.SetClaim(PositionTokenClaimTypes.StepUpAction, ceremony.StepUpAction);
+ if (ceremony.StepUpNonce is not null)
+ principal.SetClaim(PositionTokenClaimTypes.StepUpNonce, ceremony.StepUpNonce);
+
+ // A step-up proves freshness for the current session; it must never
+ // widen that session by accepting a new scope set from this request.
+ principal.SetScopes(ceremony.StepUpScopes);
+ var resources = await scopeManager.ListResourcesAsync(principal.GetScopes(), ct).ToListAsync(ct);
+ principal.SetResources(resources);
+ var resourceAccess = await BuildResourceAccessAsync(
+ position.Id, resources, wantsRoles: true, wantsPermissions: true, session, permissionService);
+ if (resourceAccess is not null)
+ principal.SetClaim("resource_access", JsonSerializer.SerializeToElement(resourceAccess));
+
+ // StaffingPrincipal assigned destinations before the step-up-specific
+ // claims above replaced/added claims. Re-apply them here so token_use,
+ // acr and optional action/nonce are actually present on the wire.
+ principal.SetDestinations(_ => [Destinations.AccessToken]);
+
+ var lifetime = staffing.AbsoluteExpiresAt - now;
+ if (lifetime > TimeSpan.FromSeconds(60)) lifetime = TimeSpan.FromSeconds(60);
+ if (lifetime <= TimeSpan.Zero) return Refuse("The staffing session is no longer active.");
+ principal.SetAccessTokenLifetime(lifetime);
+ principal.SetAuthorizationId(staffing.OAuthAuthorizationId);
+ return Results.SignIn(principal, properties: null, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
+ }
+
///
/// Redeems a passkey tap on an enrolled terminal into a
/// (plan §13.3, steps in order). Everything
@@ -778,14 +944,11 @@ private static async Task ExchangeStaffingAsync(
HttpContext httpContext,
AppSettings settings,
IDocumentSession session,
- UserManager userManager,
- SignInManager signInManager,
IOpenIddictScopeManager scopeManager,
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IPermissionService permissionService,
- RealmScopedFido2Factory fido2Factory,
- RpIdResolver rpIdResolver,
+ ActivationProofRegistry activationProofs,
IOAuthGrantRevoker grantRevoker,
Wolverine.IMessageBus bus,
CancellationToken ct)
@@ -805,27 +968,23 @@ static IResult Refuse(string description) =>
var state = await session.Query()
.FirstOrDefaultAsync(x => x.ClientId == request.ClientId && !x.IsDeleted, ct);
- if (state?.ManagedTerminalEnrollmentId is not { } terminalId ||
- state.LinkedPositionPrincipalId is not { } positionId)
+ if (state?.ManagedTerminalEnrollmentId is not { } terminalId)
{
return Refuse("The client is not a position-terminal client.");
}
var terminal = await session.LoadAsync(terminalId, ct);
if (terminal is null || terminal.OAuthApplicationId != state.Id ||
- terminal.PositionPrincipalId != positionId ||
!string.Equals(terminal.ClientId, request.ClientId, StringComparison.Ordinal))
{
return Refuse("The client is not linked to a valid terminal slot.");
}
- if (terminal.Status != TerminalEnrollmentStatus.Active || string.IsNullOrEmpty(terminal.DpopJkt))
+ if (terminal.Status != TerminalEnrollmentStatus.Active)
return Refuse("The terminal is not active.");
- var position = await session.LoadAsync(positionId, ct);
- if (position is null || position.IsDeleted || !position.TerminalPolicy.Enabled)
- return Refuse("Terminal use is disabled for this position.");
-
+ if (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal))
+ {
// Proof-of-possession for THIS request, validated in-endpoint (same
// rationale as the enrollment exchange: the DPoP pipeline handlers run
// only after SignIn). The proof key must be the slot's enrolled key.
@@ -837,6 +996,7 @@ static IResult Refuse(string description) =>
proofHeader.ToString(), httpContext.Request.Method, htu, DateTimeOffset.UtcNow);
if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal))
return Refuse("The DPoP proof key is not this terminal's enrolled key.");
+ }
// §13.3 steps 4–5 — load + pin-check the ceremony.
var assertionJson = (string?)request.GetParameter("assertion");
@@ -856,93 +1016,165 @@ static IResult Refuse(string description) =>
if (!string.Equals(ceremony.ClientId, request.ClientId, StringComparison.Ordinal) ||
ceremony.TerminalEnrollmentId != terminal.Id ||
- ceremony.PositionPrincipalId != positionId ||
- !string.Equals(ceremony.DpopJkt, terminal.DpopJkt, StringComparison.Ordinal))
+ (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal) &&
+ !string.Equals(ceremony.DpopJkt, terminal.DpopJkt, StringComparison.Ordinal)))
{
return Refuse("Invalid or expired staffing ceremony.");
}
- // §13.3 step 6 — consume BEFORE the verify: a version-checked Store of
- // the ConsumedAt marker (not a Delete — deletes aren't version-checked),
- // so a captured ceremony_id can never be replayed and of two racing
- // redeems the loser's save throws. Mirrors ExchangeNativePasskeyAsync.
- ceremony.ConsumedAt = DateTimeOffset.UtcNow;
- session.Store(ceremony);
- try
- {
- await session.SaveChangesAsync(ct);
- }
- catch (JasperFx.ConcurrencyException)
- {
- return Refuse("Invalid or expired staffing ceremony.");
- }
-
- if (string.IsNullOrWhiteSpace(assertionJson))
- return Refuse("Invalid or expired staffing ceremony.");
-
- // §13.3 steps 7–9 — verify the tap against the ceremony-pinned RP-ID.
- string[]? presentedOrigins = null;
- try
- {
- var assertion = JsonSerializer.Deserialize(
- assertionJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
- if (RealmFido2.TryGetClientDataOrigin(assertion?.Response?.ClientDataJson) is { } origin)
- presentedOrigins = [origin];
- }
- catch (JsonException) { /* leave null — the verifier fails closed */ }
+ var evidenceMethodId = string.IsNullOrWhiteSpace(ceremony.MethodId)
+ ? ActivationProofMethodIds.PersonalPasskey
+ : ceremony.MethodId;
+ var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct);
+ var requiredProof = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None;
+ var requiredBinding = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None;
+ if (!PositionTerminalSecurity.ProofMeetsFloor(evidenceMethodId, requiredProof) ||
+ !PositionTerminalSecurity.BindingMeetsFloor(terminal.Binding, requiredBinding) ||
+ !activationProofs.TryGet(evidenceMethodId, out var activationProof))
+ return Refuse("The activation proof or terminal binding is no longer allowed.");
+
+ bool PositionIsCurrentlyEligible(PositionPrincipal candidate) =>
+ terminal.EffectiveAllowedPositionIds.Contains(candidate.Id) &&
+ !candidate.IsDeleted && candidate.IsActive && candidate.TerminalPolicy.Enabled &&
+ candidate.TerminalPolicy.AllowedActivationProofs.Contains(evidenceMethodId, StringComparer.Ordinal) &&
+ candidate.TerminalPolicy.AllowedDeviceBindings.Contains(terminal.Binding, StringComparer.Ordinal);
+
+ async Task ConsumeCeremonyAsync()
+ {
+ // Consume BEFORE proof verification/selection. The optimistic
+ // update makes both proof tickets and selection continuations
+ // single-use under concurrent redemption.
+ ceremony.ConsumedAt = DateTimeOffset.UtcNow;
+ session.Store(ceremony);
+ try { await session.SaveChangesAsync(ct); return true; }
+ catch (JasperFx.ConcurrencyException) { return false; }
+ }
+
+ Guid positionId;
+ PositionPrincipal position;
+ ActivationEvidence evidence;
+ if (ceremony.VerifiedCandidates.Length > 0)
+ {
+ var requestedPosition = (string?)request.GetParameter("position_id");
+ StaffingCandidateEvidence selected;
+ if (ceremony.VerifiedCandidates.Length == 1 && string.IsNullOrWhiteSpace(requestedPosition))
+ selected = ceremony.VerifiedCandidates[0];
+ else
+ {
+ if (string.IsNullOrWhiteSpace(requestedPosition) ||
+ !ShortGuid.TryParse(requestedPosition, out Guid selectedId))
+ return Refuse("The selected position was not established by the activation proof.");
+ var found = ceremony.VerifiedCandidates.FirstOrDefault(candidate =>
+ candidate.PositionPrincipalId == selectedId);
+ if (found is null)
+ return Refuse("The selected position was not established by the activation proof.");
+ selected = found;
+ }
- var primaryDomain = await rpIdResolver.GetPrimaryDomainAsync(ct);
- IFido2 fido2;
- try
- {
- fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: ceremony.RpId, additionalOrigins: presentedOrigins);
+ positionId = selected.PositionPrincipalId;
+ var selectedPosition = await session.LoadAsync(positionId, ct);
+ if (selectedPosition is null || !PositionIsCurrentlyEligible(selectedPosition) ||
+ !await activationProof.RevalidateAsync(selected.Evidence, selectedPosition, ct))
+ return Refuse("The selected position is no longer available for this activation proof.");
+ position = selectedPosition;
+ if (!await ConsumeCeremonyAsync())
+ return Refuse("Invalid or expired staffing ceremony.");
+ evidence = selected.Evidence;
}
- catch (RelyingPartyUnavailableException)
+ else if (ceremony.CandidatePositionIds.Length > 0)
{
- return Refuse("Staffing is not available for this realm.");
- }
+ var positions = new List();
+ foreach (var candidateId in ceremony.CandidatePositionIds.Distinct())
+ {
+ if (!terminal.EffectiveAllowedPositionIds.Contains(candidateId)) continue;
+ if (await session.LoadAsync(candidateId, ct) is { } candidate &&
+ PositionIsCurrentlyEligible(candidate))
+ positions.Add(candidate);
+ }
+ if (positions.Count == 0)
+ return Refuse("No position is currently available for this activation proof.");
+ if (!await ConsumeCeremonyAsync())
+ return Refuse("Invalid or expired staffing ceremony.");
+ if (string.IsNullOrWhiteSpace(assertionJson))
+ return Refuse("Invalid or expired staffing ceremony.");
+
+ var activation = await activationProof.CompleteCandidatesAsync(
+ ceremony, assertionJson, positions, terminal, ct);
+ if (activation.Failure is not null || activation.Candidates.Count == 0)
+ return Refuse(activation.Failure?.Message ?? "Activation proof verification failed.");
+ var eligibleIds = positions.Select(candidate => candidate.Id).ToHashSet();
+ var verified = activation.Candidates
+ .Where(candidate => eligibleIds.Contains(candidate.PositionPrincipalId))
+ .GroupBy(candidate => candidate.PositionPrincipalId)
+ .Select(group => group.First())
+ .ToArray();
+ if (verified.Length == 0)
+ return Refuse("The activation proof does not authorize a position on this terminal.");
+ if (verified.Length > 1)
+ {
+ var continuationNow = DateTimeOffset.UtcNow;
+ var continuation = new StaffingCeremony
+ {
+ Id = Guid.NewGuid(),
+ PositionPrincipalId = Guid.Empty,
+ CandidatePositionIds = verified.Select(candidate => candidate.PositionPrincipalId).ToArray(),
+ VerifiedCandidates = verified,
+ TerminalEnrollmentId = terminal.Id,
+ ClientId = terminal.ClientId,
+ DpopJkt = terminal.DpopJkt ?? string.Empty,
+ MethodId = evidenceMethodId,
+ CreatedAt = continuationNow,
+ ExpiresAt = continuationNow.AddMinutes(5),
+ };
+ session.Store(continuation);
+ await session.SaveChangesAsync(ct);
+ var names = positions.ToDictionary(candidate => candidate.Id, candidate => candidate.DisplayName);
+ return Results.Ok(new
+ {
+ selectionRequired = true,
+ ceremonyId = continuation.Id,
+ candidates = verified.Select(candidate => new
+ {
+ id = new ShortGuid(candidate.PositionPrincipalId).ToString(),
+ displayName = names[candidate.PositionPrincipalId],
+ }),
+ });
+ }
- AssertionOptions options;
- try
- {
- options = AssertionOptions.FromJson(ceremony.OptionsJson);
+ positionId = verified[0].PositionPrincipalId;
+ position = positions.Single(candidate => candidate.Id == positionId);
+ evidence = verified[0].Evidence;
}
- catch
+ else
{
- return Refuse("Invalid or expired staffing ceremony.");
+ positionId = ceremony.PositionPrincipalId;
+ if (!terminal.EffectiveAllowedPositionIds.Contains(positionId) ||
+ (state.LinkedPositionPrincipalId is { } legacyPositionId &&
+ (terminal.EffectiveAllowedPositionIds.Count != 1 || legacyPositionId != positionId)))
+ return Refuse("The selected position is not allowed on this terminal.");
+ var selectedPosition = await session.LoadAsync(positionId, ct);
+ if (selectedPosition is null || !PositionIsCurrentlyEligible(selectedPosition))
+ return Refuse("Terminal use is disabled for this position.");
+ position = selectedPosition;
+ if (!await ConsumeCeremonyAsync())
+ return Refuse("Invalid or expired staffing ceremony.");
+ if (string.IsNullOrWhiteSpace(assertionJson))
+ return Refuse("Invalid or expired staffing ceremony.");
+
+ var activation = await activationProof.CompleteAsync(
+ new ActivationContext(position, terminal, ceremony), assertionJson, ct);
+ if (activation.Failure is not null || activation.Evidence is not { } completedEvidence)
+ return Refuse(activation.Failure?.Message ?? "Activation proof verification failed.");
+ evidence = completedEvidence;
}
- // The shared verifier commits its own save (counter advance) — safe
- // here: the ceremony consume above already committed and nothing else
- // is staged on the session yet.
- var storedCredential = await PasskeyAssertionVerifier.VerifyAsync(
- fido2, options, assertionJson, session, ceremony.RpId, primaryDomain, ct);
- if (storedCredential is null)
- return Refuse("Passkey verification failed.");
-
- // §13.3 steps 10–13 — the activating person: alive + allowed to sign
- // in, the passkey belongs to the ceremony RP-ID, and an ACTIVE grant
- // authorizes them for this position (Suspended does not).
- var user = await userManager.FindByIdAsync(storedCredential.UserId.ToString());
- if (user is null || !await signInManager.CanSignInAsync(user) || !user.IsActive || user.IsDeleted)
- return Refuse("Passkey verification failed.");
- if (!string.Equals(storedCredential.RpId ?? primaryDomain, ceremony.RpId, StringComparison.OrdinalIgnoreCase))
- return Refuse("Passkey verification failed.");
-
- var grant = (await session.Query()
- .Where(g => g.PositionPrincipalId == positionId && g.UserId == user.Id &&
- g.Status == PositionGrantStatus.Active)
- .ToListAsync(ct)).FirstOrDefault();
- if (grant is null)
- return Refuse("The user is not authorized to staff this position.");
-
// §13.3 step 16 — scopes: offline_access keeps the shift refreshable;
// requested scopes passed the client/app restriction gates at the top
// of ExchangeAsync. Audiences resolve from the granted scopes; the
// position's own roles + permissions are embedded per audience (§7.3).
var now = DateTimeOffset.UtcNow;
var sessionId = Guid.NewGuid();
- var principal = StaffingPrincipal.Create(position, terminal, sessionId, now);
+ var principal = StaffingPrincipal.Create(position, terminal, sessionId, now, evidence.MethodId);
var scopes = request.GetScopes();
if (!scopes.Contains(Scopes.OfflineAccess)) scopes = scopes.Add(Scopes.OfflineAccess);
@@ -1006,10 +1238,9 @@ static IResult Refuse(string description) =>
var authorizationId = await authorizationManager.GetIdAsync(authorization, ct)
?? throw new InvalidOperationException("The staffing authorization has no id.");
- session.Events.StartStream(sessionId, new StaffingSessionStarted(
- sessionId, positionId, terminal.Id,
- user.Id, storedCredential.Id, grant.Id,
- terminal.DpopJkt!, authorizationId, now, absoluteExpiresAt));
+ session.Events.StartStream(sessionId, new StaffingSessionStartedV2(
+ sessionId, positionId, terminal.Id, evidence,
+ terminal.DpopJkt, authorizationId, now, absoluteExpiresAt));
terminalStream.AppendOne(new TerminalStaffingSessionActivated(terminal.Id, sessionId, now));
try
{
@@ -1063,11 +1294,11 @@ private static async Task ExchangeStaffingRefreshAsync(
ClaimsPrincipal tokenPrincipal,
AppSettings settings,
IDocumentSession session,
- UserManager userManager,
- SignInManager signInManager,
IOpenIddictScopeManager scopeManager,
IPermissionService permissionService,
+ ActivationProofRegistry activationProofs,
IOAuthGrantRevoker grantRevoker,
+ IStaffingRevoker staffingRevoker,
Wolverine.IMessageBus bus)
{
static IResult Refuse(string description) =>
@@ -1139,26 +1370,55 @@ await bus.PublishAsync(new PositionStaffingSessionEnded(
// and still owned by this session.
var position = await session.LoadAsync(positionId, ct);
if (position is null || position.IsDeleted || !position.TerminalPolicy.Enabled)
+ {
+ await staffingRevoker.EndSessionAsync(
+ staffing.Id, StaffingSessionEndReason.PositionDisabled, ct);
return RequireStaffing();
+ }
var terminal = await session.LoadAsync(terminalId, ct);
if (terminal is null || terminal.Status != TerminalEnrollmentStatus.Active)
+ {
+ await staffingRevoker.EndSessionAsync(
+ staffing.Id, StaffingSessionEndReason.TerminalDisabled, ct);
return RequireStaffing();
+ }
if (terminal.ActiveStaffingSessionId != staffing.Id)
return RequireStaffing();
+ var evidence = staffing.GetActivationEvidence();
+ var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct);
+ var requiredProof = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None;
+ var requiredBinding = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None;
+ activationProofs.TryGet(evidence.MethodId, out var activationProof);
+ var policyValid =
+ terminal.EffectiveAllowedPositionIds.Contains(positionId) &&
+ position.TerminalPolicy.AllowedActivationProofs.Contains(evidence.MethodId, StringComparer.Ordinal) &&
+ position.TerminalPolicy.AllowedDeviceBindings.Contains(evidence.Binding, StringComparer.Ordinal) &&
+ string.Equals(terminal.Binding, evidence.Binding, StringComparison.Ordinal) &&
+ PositionTerminalSecurity.ProofMeetsFloor(evidence.MethodId, requiredProof) &&
+ PositionTerminalSecurity.BindingMeetsFloor(evidence.Binding, requiredBinding) &&
+ activationProof is not null;
+ if (!policyValid)
+ {
+ await staffingRevoker.EndSessionAsync(
+ staffing.Id, StaffingSessionEndReason.PolicyTightened, ct);
+ return RequireStaffing();
+ }
+
// §14.3 check 8 — the client is still the slot's own, fully linked.
var state = await session.Query()
.FirstOrDefaultAsync(x => x.ClientId == request.ClientId && !x.IsDeleted, ct);
if (state is null ||
state.ManagedTerminalEnrollmentId != terminal.Id ||
- state.LinkedPositionPrincipalId != positionId ||
terminal.OAuthApplicationId != state.Id ||
!string.Equals(terminal.ClientId, request.ClientId, StringComparison.Ordinal))
{
return Refuse("The client does not own this terminal slot.");
}
+ if (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal))
+ {
// §14.3 check 9 — proof-of-possession for THIS request, in-endpoint
// (pipeline handlers run only after SignIn): proof key ≡ session key
// ≡ terminal key.
@@ -1174,24 +1434,23 @@ await bus.PublishAsync(new PositionStaffingSessionEnded(
{
return Refuse("The DPoP proof key does not match this staffing session's key.");
}
+ }
- // §14.3 checks 10–12 — the activating person, their passkey, and
- // their grant must all still authorize this shift.
- var user = await userManager.FindByIdAsync(staffing.ActivatedByUserId.ToString());
- if (user is null || !await signInManager.CanSignInAsync(user) || !user.IsActive || user.IsDeleted)
- return RequireStaffing();
- if (await session.LoadAsync(staffing.ActivatedByPasskeyCredentialId, ct) is null)
- return RequireStaffing();
- var grant = await session.LoadAsync(staffing.PositionGrantId, ct);
- if (grant is null || grant.Status != PositionGrantStatus.Active)
+ // Proof-specific credential/grant validity is adapter-owned. This is
+ // the durable fail-closed backstop for a missed best-effort cascade.
+ if (!await activationProof!.RevalidateAsync(evidence, position, ct))
+ {
+ await staffingRevoker.EndSessionAsync(
+ staffing.Id, StaffingSessionEndReason.ActivationCredentialInvalidated, ct);
return RequireStaffing();
+ }
// §14.3 check 13 + §14.4 — re-issue the SAME session identity with
// freshly computed scopes/permissions; auth_time is the ORIGINAL tap.
var authTime = long.TryParse(tokenPrincipal.GetClaim(Claims.AuthenticationTime), out var unix)
? DateTimeOffset.FromUnixTimeSeconds(unix)
: staffing.StartedAt;
- var principal = StaffingPrincipal.Create(position, terminal, staffing.Id, authTime);
+ var principal = StaffingPrincipal.Create(position, terminal, staffing.Id, authTime, evidence.MethodId);
var scopes = tokenPrincipal.GetScopes();
principal.SetScopes(scopes);
diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DeviceVerificationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DeviceVerificationEndpoints.cs
index 5bb9bdaf..2327be8d 100644
--- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DeviceVerificationEndpoints.cs
+++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DeviceVerificationEndpoints.cs
@@ -334,9 +334,13 @@ private static async Task SubmitCodeAsync(
return null;
}
- var binding = await session.Query()
- .Where(b => b.UserCodeHash == DeviceCodeDpopBindingKeyForVerification(userCode))
- .FirstOrDefaultAsync(cancellationToken);
+ DeviceCodeDpopBinding? dpopBinding = null;
+ if (string.Equals(target.Terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal))
+ {
+ dpopBinding = await session.Query()
+ .Where(b => b.UserCodeHash == DeviceCodeDpopBindingKeyForVerification(userCode))
+ .FirstOrDefaultAsync(cancellationToken);
+ }
terminalInfo = new TerminalConsentInfo
{
@@ -344,7 +348,8 @@ private static async Task SubmitCodeAsync(
TerminalName = target.Terminal.DisplayName,
Location = target.Terminal.Location,
ClientId = target.Terminal.ClientId,
- DpopFingerprint = binding is { } b && b.ExpiresAt > DateTimeOffset.UtcNow
+ Binding = target.Terminal.Binding,
+ DpopFingerprint = dpopBinding is { } b && b.ExpiresAt > DateTimeOffset.UtcNow
? TerminalEnrollmentPrincipal.Fingerprint(b.Jkt)
: null,
};
@@ -449,13 +454,18 @@ static IResult Refuse(string description) => Results.Forbid(
if (!target.Position.TerminalPolicy.Enabled)
return Refuse("Terminal use is disabled for this position.");
- // Check 7 — the initial device request must have been DPoP-proofed:
- // without a bound key there is nothing to pin the enrollment to.
- var binding = await session.Query()
- .Where(b => b.UserCodeHash == DeviceCodeDpopBindingKeyForVerification(normalizedUserCode))
- .FirstOrDefaultAsync(cancellationToken);
- if (binding is null || binding.ExpiresAt <= DateTimeOffset.UtcNow)
- return Refuse("The device request was not DPoP-bound; terminal enrollment requires a device key.");
+ // Check 7 is binding-dependent. DPoP enrollment must prove the key in
+ // the initial device request; client-secret authenticates the client at
+ // both protocol endpoints, while `none` intentionally relies on this
+ // explicit administrator approval only.
+ if (string.Equals(target.Terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal))
+ {
+ var dpopBinding = await session.Query()
+ .Where(b => b.UserCodeHash == DeviceCodeDpopBindingKeyForVerification(normalizedUserCode))
+ .FirstOrDefaultAsync(cancellationToken);
+ if (dpopBinding is null || dpopBinding.ExpiresAt <= DateTimeOffset.UtcNow)
+ return Refuse("The device request was not DPoP-bound; terminal enrollment requires a device key.");
+ }
// Check 8 — the user code still resolves to a redeemable device grant.
var status = await tokenManager.GetStatusAsync(userCodeToken, cancellationToken);
@@ -486,7 +496,7 @@ static IResult Refuse(string description) => Results.Forbid(
// the terminal's poll then reaches the token-endpoint enrollment
// exchange (§11.6) with token_use=terminal_enrollment.
return Results.SignIn(
- TerminalEnrollmentPrincipal.Create(target.Position, target.Terminal),
+ TerminalEnrollmentPrincipal.CreateV2(target.Terminal),
properties: null,
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
@@ -597,6 +607,7 @@ public record TerminalConsentInfo
public required string TerminalName { get; init; }
public string? Location { get; init; }
public required string ClientId { get; init; }
+ public required string Binding { get; init; }
/// Null when the device request carried no DPoP proof — approval
/// is refused in that case (rule 7).
public string? DpopFingerprint { get; init; }
diff --git a/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/StaffingPrincipal.cs b/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/StaffingPrincipal.cs
index 6827627c..e9fdf56a 100644
--- a/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/StaffingPrincipal.cs
+++ b/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/StaffingPrincipal.cs
@@ -20,7 +20,8 @@ public static ClaimsPrincipal Create(
PositionPrincipal position,
TerminalEnrollment terminal,
Guid staffingSessionId,
- DateTimeOffset authTime)
+ DateTimeOffset authTime,
+ string activationProof)
{
var identity = new ClaimsIdentity(
authenticationType: "Bearer",
@@ -33,8 +34,16 @@ public static ClaimsPrincipal Create(
identity.SetClaim(PositionTokenClaimTypes.TokenUse, PositionTokenUses.StaffingSession);
identity.SetClaim(PositionTokenClaimTypes.TerminalId, terminal.Id.ToString());
identity.SetClaim(PositionTokenClaimTypes.StaffingSessionId, staffingSessionId.ToString());
+ identity.SetClaim(PositionTokenClaimTypes.ActivationProof, activationProof);
+ identity.SetClaim(PositionTokenClaimTypes.TerminalBinding, terminal.Binding);
identity.SetClaim(Claims.AuthenticationTime, authTime.ToUnixTimeSeconds());
- identity.SetClaims(Claims.AuthenticationMethodReference, ["webauthn"]);
+ identity.SetClaims(Claims.AuthenticationMethodReference,
+ [activationProof switch
+ {
+ ActivationProofMethodIds.PersonalPassword => "pwd",
+ ActivationProofMethodIds.PersonalEmailOtp => "otp",
+ _ => "webauthn",
+ }]);
var principal = new ClaimsPrincipal(identity);
// Scopes/resources are applied by the exchange (they depend on the
diff --git a/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/TerminalEnrollmentPrincipal.cs b/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/TerminalEnrollmentPrincipal.cs
index ad456e87..7eb35bcf 100644
--- a/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/TerminalEnrollmentPrincipal.cs
+++ b/src/dotnet/Modgud.Api/Features/Auth/PositionTerminals/TerminalEnrollmentPrincipal.cs
@@ -10,14 +10,40 @@ namespace Modgud.Api.Features.Auth.PositionTerminals;
///
/// Builds the claims principal for terminal-ENROLLMENT tokens (MG-FT-04, plan
-/// §11.5). Deliberately NOT CreateClaimsPrincipalAsync: the subject is
-/// the POSITION, not a person — no user claims, no security stamp, no group
-/// bake. The token authorizes exactly one thing: driving the terminal-control
-/// surface (begin a staffing ceremony, MG-FT-05). It carries no business
-/// audience and no business scopes (MG-FT-04 done criterion).
+/// §11.5). Deliberately NOT CreateClaimsPrincipalAsync: V2 makes the
+/// terminal the subject, while refresh chains issued by V1 retain the original
+/// position subject during the compatibility window. Neither form represents
+/// a person, so neither carries user claims, security stamps, or group grants.
+/// The token authorizes only the terminal-control surface and carries no
+/// business audience or business scopes (MG-FT-04 done criterion).
///
public static class TerminalEnrollmentPrincipal
{
+ /// Control-plane V2: the terminal is the subject. Business
+ /// position selection is deferred to the staffing ceremony.
+ public static ClaimsPrincipal CreateV2(TerminalEnrollment terminal)
+ {
+ var identity = new ClaimsIdentity(
+ authenticationType: "Bearer",
+ nameType: Claims.Name,
+ roleType: Claims.Role);
+
+ identity.SetClaim(Claims.Subject, terminal.Id.ToString());
+ identity.SetClaim(Claims.Name, terminal.DisplayName);
+ identity.SetClaim(PositionTokenClaimTypes.PrincipalType, PositionPrincipalTypes.Terminal);
+ identity.SetClaim(PositionTokenClaimTypes.TokenUse, PositionTokenUses.TerminalEnrollment);
+ identity.SetClaim(PositionTokenClaimTypes.TerminalId, terminal.Id.ToString());
+ identity.SetClaim(PositionTokenClaimTypes.TerminalBinding, terminal.Binding);
+
+ var principal = new ClaimsPrincipal(identity);
+ principal.SetScopes(Scopes.OfflineAccess, PositionTerminalControl.Scope);
+ principal.SetResources(PositionTerminalControl.Audience);
+ principal.SetDestinations(_ => [Destinations.AccessToken]);
+ return principal;
+ }
+
+ /// Legacy Control-plane V1; retained for refresh chains issued
+ /// before F4 while the slot still has exactly its original position.
public static ClaimsPrincipal Create(PositionPrincipal position, TerminalEnrollment terminal)
{
var identity = new ClaimsIdentity(
@@ -30,6 +56,7 @@ public static ClaimsPrincipal Create(PositionPrincipal position, TerminalEnrollm
identity.SetClaim(PositionTokenClaimTypes.PrincipalType, PositionPrincipalTypes.Position);
identity.SetClaim(PositionTokenClaimTypes.TokenUse, PositionTokenUses.TerminalEnrollment);
identity.SetClaim(PositionTokenClaimTypes.TerminalId, terminal.Id.ToString());
+ identity.SetClaim(PositionTokenClaimTypes.TerminalBinding, terminal.Binding);
var principal = new ClaimsPrincipal(identity);
// offline_access keeps the enrollment chain refreshable (the terminal
diff --git a/src/dotnet/Modgud.Api/Features/Auth/Staffing/ActivationProofs.cs b/src/dotnet/Modgud.Api/Features/Auth/Staffing/ActivationProofs.cs
new file mode 100644
index 00000000..4f0ffa13
--- /dev/null
+++ b/src/dotnet/Modgud.Api/Features/Auth/Staffing/ActivationProofs.cs
@@ -0,0 +1,1302 @@
+using System.Text.Json;
+using System.Security.Cryptography;
+using System.Text;
+using Fido2NetLib;
+using Fido2NetLib.Objects;
+using Marten;
+using Microsoft.AspNetCore.Identity;
+using Modgud.Authentication.Domain;
+using Modgud.Authentication.Identity;
+using Modgud.Authorization.Principals;
+using Modgud.Domain.PositionTerminals;
+
+namespace Modgud.Api.Features.Auth.Staffing;
+
+/// The staffing-specific seam around concrete credential systems.
+/// Method IDs and capability metadata are immutable once shipped.
+public interface IActivationProof
+{
+ string MethodId { get; }
+ ProofCapability Capabilities { get; }
+ ActivationProofOwnerKind OwnerKind { get; }
+
+ Task BeginAsync(ActivationContext context, CancellationToken ct);
+ Task CompleteAsync(ActivationContext context, string response, CancellationToken ct);
+ Task BeginCandidatesAsync(
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ ActivationBeginInput input,
+ CancellationToken ct);
+ Task CompleteCandidatesAsync(
+ StaffingCeremony ceremony,
+ string response,
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ CancellationToken ct);
+ Task RevalidateAsync(ActivationEvidence evidence, PositionPrincipal position, CancellationToken ct);
+ void RegisterInvalidationHooks(IActivationInvalidationRegistry registry);
+}
+
+public sealed record ActivationContext(
+ PositionPrincipal Position,
+ TerminalEnrollment Terminal,
+ StaffingCeremony? Ceremony = null,
+ ActivationBeginInput? BeginInput = null);
+
+public sealed record ActivationBeginInput(string? MethodId, string? AccountName, string? PositionId = null);
+
+public sealed record ActivationChallenge(
+ StaffingCeremony? Ceremony,
+ string? OptionsJson,
+ ActivationProofFailure? Failure,
+ string ResponseProperty = "publicKey")
+{
+ public static ActivationChallenge Failed(string code, string message) =>
+ new(null, null, new ActivationProofFailure(code, message));
+}
+
+public sealed class PersonalPasswordActivationProof(
+ IDocumentSession session,
+ UserManager userManager,
+ SignInManager signInManager) : IActivationProof
+{
+ public string MethodId => ActivationProofMethodIds.PersonalPassword;
+ public ProofCapability Capabilities => ProofCapability.IdentifiedActor;
+ public ActivationProofOwnerKind OwnerKind => ActivationProofOwnerKind.Personal;
+
+ public async Task BeginAsync(ActivationContext context, CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ResolveSubjectAsync(
+ context, session, userManager, signInManager, ct);
+ if (subject.Failure is not null) return subject.Failure;
+
+ var ceremony = await PersonalTextProofSupport.CreateCeremonyAsync(
+ context, MethodId, subject.User!, subject.Grant!, session, ct);
+ return new ActivationChallenge(
+ ceremony,
+ JsonSerializer.Serialize(new { Fields = new[] { "password" } }),
+ null,
+ "challenge");
+ }
+
+ public async Task CompleteAsync(
+ ActivationContext context, string response, CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ReloadSubjectAsync(
+ context, session, userManager, signInManager, ct);
+ if (subject.Failure is not null)
+ return ActivationResult.Failed(subject.Failure.Failure!.Code, subject.Failure.Failure.Message);
+
+ var password = PersonalTextProofSupport.ReadSecret(response, "password");
+ if (string.IsNullOrEmpty(password) ||
+ !await userManager.HasPasswordAsync(subject.User!) ||
+ !await userManager.CheckPasswordAsync(subject.User!, password))
+ {
+ await PersonalTextProofSupport.RecordFailureAsync(session, subject.Grant!, ct);
+ return ActivationResult.Failed("Staffing.PasswordFailed", "Password verification failed.");
+ }
+
+ await PersonalTextProofSupport.RecordSuccessAsync(session, subject.Grant!, ct);
+ var stamp = await userManager.GetSecurityStampAsync(subject.User!);
+ return new ActivationResult(new ActivationEvidence
+ {
+ MethodId = MethodId,
+ UserId = subject.User!.Id,
+ GrantId = subject.Grant!.Id,
+ CredentialId = PersonalTextProofSupport.PasswordCredentialVersion(stamp),
+ Binding = context.Terminal.Binding,
+ }, null);
+ }
+
+ public async Task BeginCandidatesAsync(
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ ActivationBeginInput input,
+ CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ResolveCandidateSubjectAsync(
+ positions, terminal, input, session, userManager, signInManager, ct);
+ if (subject.Failure is not null) return subject.Failure;
+ var ceremony = await PersonalTextProofSupport.CreateCandidateCeremonyAsync(
+ positions, terminal, MethodId, subject.User!, session, ct);
+ return new ActivationChallenge(
+ ceremony,
+ JsonSerializer.Serialize(new { Fields = new[] { "password" } }),
+ null,
+ "challenge");
+ }
+
+ public async Task CompleteCandidatesAsync(
+ StaffingCeremony ceremony,
+ string response,
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ReloadCandidateSubjectAsync(
+ ceremony, positions, session, userManager, signInManager, ct);
+ if (subject.Failure is not null)
+ return CandidateActivationResult.Failed(
+ subject.Failure.Failure!.Code, subject.Failure.Failure.Message);
+
+ var password = PersonalTextProofSupport.ReadSecret(response, "password");
+ if (string.IsNullOrEmpty(password) ||
+ !await userManager.HasPasswordAsync(subject.User!) ||
+ !await userManager.CheckPasswordAsync(subject.User!, password))
+ {
+ await PersonalTextProofSupport.RecordFailuresAsync(session, subject.Grants, ct);
+ return CandidateActivationResult.Failed(
+ "Staffing.PasswordFailed", "Password verification failed.");
+ }
+
+ await PersonalTextProofSupport.RecordSuccessesAsync(session, subject.Grants, ct);
+ var credentialVersion = PersonalTextProofSupport.PasswordCredentialVersion(
+ await userManager.GetSecurityStampAsync(subject.User!));
+ return new CandidateActivationResult(subject.Grants.Select(grant =>
+ new StaffingCandidateEvidence(grant.PositionPrincipalId, new ActivationEvidence
+ {
+ MethodId = MethodId,
+ UserId = subject.User!.Id,
+ GrantId = grant.Id,
+ CredentialId = credentialVersion,
+ Binding = terminal.Binding,
+ })).ToArray(), null);
+ }
+
+ public async Task RevalidateAsync(ActivationEvidence evidence, PositionPrincipal position, CancellationToken ct)
+ {
+ if (evidence.UserId is not { } userId || evidence.GrantId is not { } grantId ||
+ evidence.CredentialId is not { } credentialVersion)
+ return false;
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null || !await signInManager.CanSignInAsync(user) || !await userManager.HasPasswordAsync(user))
+ return false;
+ var stamp = await userManager.GetSecurityStampAsync(user);
+ if (PersonalTextProofSupport.PasswordCredentialVersion(stamp) != credentialVersion)
+ return false;
+ return await PersonalTextProofSupport.GrantIsActiveAsync(session, grantId, userId, ct);
+ }
+
+ public void RegisterInvalidationHooks(IActivationInvalidationRegistry registry)
+ {
+ registry.Register("user-disabled", MethodId);
+ registry.Register("password-changed", MethodId);
+ registry.Register("position-grant-suspended", MethodId);
+ registry.Register("position-grant-revoked", MethodId);
+ }
+}
+
+public sealed class PersonalEmailOtpActivationProof(
+ IDocumentSession session,
+ UserManager userManager,
+ SignInManager signInManager,
+ IEmailOtpService emailOtpService) : IActivationProof
+{
+ public string MethodId => ActivationProofMethodIds.PersonalEmailOtp;
+ public ProofCapability Capabilities => ProofCapability.IdentifiedActor;
+ public ActivationProofOwnerKind OwnerKind => ActivationProofOwnerKind.Personal;
+
+ public async Task BeginAsync(ActivationContext context, CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ResolveSubjectAsync(
+ context, session, userManager, signInManager, ct);
+ if (subject.Failure is not null) return subject.Failure;
+ if (!subject.User!.EmailOtpEnabled || !subject.User.EmailConfirmed || string.IsNullOrWhiteSpace(subject.User.Email))
+ return ActivationChallenge.Failed(
+ "Staffing.EmailOtpUnavailable", "Email OTP is not available for this account.");
+
+ var issue = await emailOtpService.RequestOtpAsync(subject.User.Id, ct);
+ if (issue.IsError)
+ return ActivationChallenge.Failed(
+ "Staffing.EmailOtpUnavailable", "Email OTP could not be issued.");
+
+ var ceremony = await PersonalTextProofSupport.CreateCeremonyAsync(
+ context, MethodId, subject.User, subject.Grant!, session, ct);
+ return new ActivationChallenge(
+ ceremony,
+ JsonSerializer.Serialize(new { Delivery = "email", Fields = new[] { "code" } }),
+ null,
+ "challenge");
+ }
+
+ public async Task CompleteAsync(
+ ActivationContext context, string response, CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ReloadSubjectAsync(
+ context, session, userManager, signInManager, ct);
+ if (subject.Failure is not null)
+ return ActivationResult.Failed(subject.Failure.Failure!.Code, subject.Failure.Failure.Message);
+ if (!subject.User!.EmailOtpEnabled)
+ return ActivationResult.Failed("Staffing.EmailOtpFailed", "Email OTP verification failed.");
+
+ var code = PersonalTextProofSupport.ReadSecret(response, "code");
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ await PersonalTextProofSupport.RecordFailureAsync(session, subject.Grant!, ct);
+ return ActivationResult.Failed("Staffing.EmailOtpFailed", "Email OTP verification failed.");
+ }
+ var verified = await emailOtpService.VerifyOtpAsync(subject.User.Id, code, ct);
+ if (verified.IsError)
+ {
+ await PersonalTextProofSupport.RecordFailureAsync(session, subject.Grant!, ct);
+ return ActivationResult.Failed("Staffing.EmailOtpFailed", "Email OTP verification failed.");
+ }
+
+ await PersonalTextProofSupport.RecordSuccessAsync(session, subject.Grant!, ct);
+ return new ActivationResult(new ActivationEvidence
+ {
+ MethodId = MethodId,
+ UserId = subject.User.Id,
+ GrantId = subject.Grant!.Id,
+ Binding = context.Terminal.Binding,
+ }, null);
+ }
+
+ public async Task BeginCandidatesAsync(
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ ActivationBeginInput input,
+ CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ResolveCandidateSubjectAsync(
+ positions, terminal, input, session, userManager, signInManager, ct);
+ if (subject.Failure is not null) return subject.Failure;
+ if (!subject.User!.EmailOtpEnabled || !subject.User.EmailConfirmed ||
+ string.IsNullOrWhiteSpace(subject.User.Email))
+ return ActivationChallenge.Failed(
+ "Staffing.EmailOtpUnavailable", "Email OTP is not available for this account.");
+
+ var issue = await emailOtpService.RequestOtpAsync(subject.User.Id, ct);
+ if (issue.IsError)
+ return ActivationChallenge.Failed(
+ "Staffing.EmailOtpUnavailable", "Email OTP could not be issued.");
+ var ceremony = await PersonalTextProofSupport.CreateCandidateCeremonyAsync(
+ positions, terminal, MethodId, subject.User, session, ct);
+ return new ActivationChallenge(
+ ceremony,
+ JsonSerializer.Serialize(new { Delivery = "email", Fields = new[] { "code" } }),
+ null,
+ "challenge");
+ }
+
+ public async Task CompleteCandidatesAsync(
+ StaffingCeremony ceremony,
+ string response,
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ CancellationToken ct)
+ {
+ var subject = await PersonalTextProofSupport.ReloadCandidateSubjectAsync(
+ ceremony, positions, session, userManager, signInManager, ct);
+ if (subject.Failure is not null)
+ return CandidateActivationResult.Failed(
+ subject.Failure.Failure!.Code, subject.Failure.Failure.Message);
+ if (!subject.User!.EmailOtpEnabled)
+ return CandidateActivationResult.Failed(
+ "Staffing.EmailOtpFailed", "Email OTP verification failed.");
+
+ var code = PersonalTextProofSupport.ReadSecret(response, "code");
+ if (string.IsNullOrWhiteSpace(code) ||
+ (await emailOtpService.VerifyOtpAsync(subject.User.Id, code, ct)).IsError)
+ {
+ await PersonalTextProofSupport.RecordFailuresAsync(session, subject.Grants, ct);
+ return CandidateActivationResult.Failed(
+ "Staffing.EmailOtpFailed", "Email OTP verification failed.");
+ }
+
+ await PersonalTextProofSupport.RecordSuccessesAsync(session, subject.Grants, ct);
+ return new CandidateActivationResult(subject.Grants.Select(grant =>
+ new StaffingCandidateEvidence(grant.PositionPrincipalId, new ActivationEvidence
+ {
+ MethodId = MethodId,
+ UserId = subject.User.Id,
+ GrantId = grant.Id,
+ Binding = terminal.Binding,
+ })).ToArray(), null);
+ }
+
+ public async Task RevalidateAsync(ActivationEvidence evidence, PositionPrincipal position, CancellationToken ct)
+ {
+ if (evidence.UserId is not { } userId || evidence.GrantId is not { } grantId)
+ return false;
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ return user is { EmailOtpEnabled: true, EmailConfirmed: true } &&
+ await signInManager.CanSignInAsync(user) &&
+ await PersonalTextProofSupport.GrantIsActiveAsync(session, grantId, userId, ct);
+ }
+
+ public void RegisterInvalidationHooks(IActivationInvalidationRegistry registry)
+ {
+ registry.Register("user-disabled", MethodId);
+ registry.Register("email-otp-disabled", MethodId);
+ registry.Register("position-grant-suspended", MethodId);
+ registry.Register("position-grant-revoked", MethodId);
+ }
+}
+
+internal static class PersonalTextProofSupport
+{
+ private const int MaxFailedAttempts = 5;
+ private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
+
+ internal sealed record SubjectResult(
+ ApplicationUser? User,
+ PositionGrant? Grant,
+ ActivationChallenge? Failure);
+
+ internal sealed record CandidateSubjectResult(
+ ApplicationUser? User,
+ IReadOnlyList Grants,
+ ActivationChallenge? Failure);
+
+ public static async Task ResolveSubjectAsync(
+ ActivationContext context,
+ IDocumentSession session,
+ UserManager userManager,
+ SignInManager signInManager,
+ CancellationToken ct)
+ {
+ var accountName = context.BeginInput?.AccountName?.Trim();
+ if (string.IsNullOrWhiteSpace(accountName))
+ return Failed("Staffing.AccountRequired", "An account name is required for this activation method.");
+
+ var user = await userManager.FindByNameAsync(accountName);
+ if (user is null && accountName.Contains('@'))
+ user = await userManager.FindByEmailAsync(accountName);
+ if (user is null || !user.IsActive || user.IsDeleted || !await signInManager.CanSignInAsync(user))
+ return Failed("Staffing.ActivationFailed", "The account cannot activate this position.");
+
+ var grant = (await session.Query()
+ .Where(g => g.PositionPrincipalId == context.Position.Id &&
+ g.UserId == user.Id && g.Status == PositionGrantStatus.Active)
+ .ToListAsync(ct))
+ .FirstOrDefault();
+ if (grant is null)
+ return Failed("Staffing.ActivationFailed", "The account cannot activate this position.");
+ if (grant.IsActivationLockedOut(DateTimeOffset.UtcNow))
+ return Failed("Staffing.GrantLocked", "Too many failed attempts; this staffing grant is temporarily locked.");
+ return new SubjectResult(user, grant, null);
+ }
+
+ public static async Task ReloadSubjectAsync(
+ ActivationContext context,
+ IDocumentSession session,
+ UserManager userManager,
+ SignInManager signInManager,
+ CancellationToken ct)
+ {
+ if (context.Ceremony?.SubjectUserId is not { } userId ||
+ context.Ceremony.SubjectGrantId is not { } grantId)
+ return Failed("Staffing.InvalidCeremony", "Invalid or expired staffing ceremony.");
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ var grant = await session.LoadAsync(grantId, ct);
+ if (user is null || grant is not { Status: PositionGrantStatus.Active } ||
+ grant.UserId != userId || grant.PositionPrincipalId != context.Position.Id ||
+ !user.IsActive || user.IsDeleted || !await signInManager.CanSignInAsync(user))
+ return Failed("Staffing.ActivationFailed", "The account cannot activate this position.");
+ if (grant.IsActivationLockedOut(DateTimeOffset.UtcNow))
+ return Failed("Staffing.GrantLocked", "Too many failed attempts; this staffing grant is temporarily locked.");
+ return new SubjectResult(user, grant, null);
+ }
+
+ public static async Task ResolveCandidateSubjectAsync(
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ ActivationBeginInput input,
+ IDocumentSession session,
+ UserManager userManager,
+ SignInManager signInManager,
+ CancellationToken ct)
+ {
+ var accountName = input.AccountName?.Trim();
+ if (string.IsNullOrWhiteSpace(accountName))
+ return CandidateFailed(
+ "Staffing.AccountRequired", "An account name is required for this activation method.");
+
+ var user = await userManager.FindByNameAsync(accountName);
+ if (user is null && accountName.Contains('@'))
+ user = await userManager.FindByEmailAsync(accountName);
+ if (user is null || !user.IsActive || user.IsDeleted || !await signInManager.CanSignInAsync(user))
+ return CandidateFailed(
+ "Staffing.ActivationFailed", "The account cannot activate a position on this terminal.");
+
+ var positionIds = positions.Select(position => position.Id).ToArray();
+ var grants = (await session.Query()
+ .Where(grant => grant.UserId == user.Id && grant.Status == PositionGrantStatus.Active)
+ .ToListAsync(ct))
+ .Where(grant => positionIds.Contains(grant.PositionPrincipalId) &&
+ !grant.IsActivationLockedOut(DateTimeOffset.UtcNow))
+ .GroupBy(grant => grant.PositionPrincipalId)
+ .Select(group => group.First())
+ .ToArray();
+ if (grants.Length == 0)
+ return CandidateFailed(
+ "Staffing.ActivationFailed", "The account cannot activate a position on this terminal.");
+ return new CandidateSubjectResult(user, grants, null);
+ }
+
+ public static async Task ReloadCandidateSubjectAsync(
+ StaffingCeremony ceremony,
+ IReadOnlyList positions,
+ IDocumentSession session,
+ UserManager userManager,
+ SignInManager signInManager,
+ CancellationToken ct)
+ {
+ if (ceremony.SubjectUserId is not { } userId)
+ return CandidateFailed(
+ "Staffing.InvalidCeremony", "Invalid or expired staffing ceremony.");
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null || !user.IsActive || user.IsDeleted || !await signInManager.CanSignInAsync(user))
+ return CandidateFailed(
+ "Staffing.ActivationFailed", "The account cannot activate a position on this terminal.");
+
+ var positionIds = positions.Select(position => position.Id).ToArray();
+ var grants = (await session.Query()
+ .Where(grant => grant.UserId == userId && grant.Status == PositionGrantStatus.Active)
+ .ToListAsync(ct))
+ .Where(grant => positionIds.Contains(grant.PositionPrincipalId) &&
+ !grant.IsActivationLockedOut(DateTimeOffset.UtcNow))
+ .GroupBy(grant => grant.PositionPrincipalId)
+ .Select(group => group.First())
+ .ToArray();
+ if (grants.Length == 0)
+ return CandidateFailed(
+ "Staffing.ActivationFailed", "The account cannot activate a position on this terminal.");
+ return new CandidateSubjectResult(user, grants, null);
+ }
+
+ public static async Task CreateCeremonyAsync(
+ ActivationContext context,
+ string methodId,
+ ApplicationUser user,
+ PositionGrant grant,
+ IDocumentSession session,
+ CancellationToken ct)
+ {
+ var now = DateTimeOffset.UtcNow;
+ session.DeleteWhere(c => c.ExpiresAt < now);
+ var ceremony = new StaffingCeremony
+ {
+ Id = Guid.NewGuid(),
+ PositionPrincipalId = context.Position.Id,
+ TerminalEnrollmentId = context.Terminal.Id,
+ ClientId = context.Terminal.ClientId,
+ DpopJkt = context.Terminal.DpopJkt ?? string.Empty,
+ MethodId = methodId,
+ SubjectUserId = user.Id,
+ SubjectGrantId = grant.Id,
+ OptionsJson = "{}",
+ CreatedAt = now,
+ ExpiresAt = now.AddMinutes(5),
+ };
+ session.Store(ceremony);
+ await session.SaveChangesAsync(ct);
+ return ceremony;
+ }
+
+ public static async Task CreateCandidateCeremonyAsync(
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ string methodId,
+ ApplicationUser user,
+ IDocumentSession session,
+ CancellationToken ct)
+ {
+ var now = DateTimeOffset.UtcNow;
+ session.DeleteWhere(c => c.ExpiresAt < now);
+ var ceremony = new StaffingCeremony
+ {
+ Id = Guid.NewGuid(),
+ PositionPrincipalId = Guid.Empty,
+ CandidatePositionIds = positions.Select(position => position.Id).Distinct().ToArray(),
+ TerminalEnrollmentId = terminal.Id,
+ ClientId = terminal.ClientId,
+ DpopJkt = terminal.DpopJkt ?? string.Empty,
+ MethodId = methodId,
+ SubjectUserId = user.Id,
+ OptionsJson = "{}",
+ CreatedAt = now,
+ ExpiresAt = now.AddMinutes(5),
+ };
+ session.Store(ceremony);
+ await session.SaveChangesAsync(ct);
+ return ceremony;
+ }
+
+ public static string? ReadSecret(string response, string property)
+ {
+ try
+ {
+ using var json = JsonDocument.Parse(response);
+ return json.RootElement.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ public static async Task RecordFailureAsync(
+ IDocumentSession session, PositionGrant grant, CancellationToken ct)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var lockedUntil = grant.ActivationFailedCount + 1 >= MaxFailedAttempts
+ ? now + LockoutDuration
+ : (DateTimeOffset?)null;
+ session.Events.Append(grant.Id, new PositionGrantActivationFailed(grant.Id, now, lockedUntil));
+ await session.SaveChangesAsync(ct);
+ }
+
+ public static async Task RecordSuccessAsync(
+ IDocumentSession session, PositionGrant grant, CancellationToken ct)
+ {
+ if (grant.ActivationFailedCount == 0 && grant.ActivationLockoutEnd is null) return;
+ session.Events.Append(grant.Id, new PositionGrantActivationSucceeded(grant.Id, DateTimeOffset.UtcNow));
+ await session.SaveChangesAsync(ct);
+ }
+
+ public static async Task RecordFailuresAsync(
+ IDocumentSession session,
+ IReadOnlyList grants,
+ CancellationToken ct)
+ {
+ var now = DateTimeOffset.UtcNow;
+ foreach (var grant in grants)
+ {
+ var lockedUntil = grant.ActivationFailedCount + 1 >= MaxFailedAttempts
+ ? now + LockoutDuration
+ : (DateTimeOffset?)null;
+ session.Events.Append(grant.Id,
+ new PositionGrantActivationFailed(grant.Id, now, lockedUntil));
+ }
+ await session.SaveChangesAsync(ct);
+ }
+
+ public static async Task RecordSuccessesAsync(
+ IDocumentSession session,
+ IReadOnlyList grants,
+ CancellationToken ct)
+ {
+ var reset = grants.Where(grant =>
+ grant.ActivationFailedCount != 0 || grant.ActivationLockoutEnd is not null).ToArray();
+ if (reset.Length == 0) return;
+ var now = DateTimeOffset.UtcNow;
+ foreach (var grant in reset)
+ session.Events.Append(grant.Id, new PositionGrantActivationSucceeded(grant.Id, now));
+ await session.SaveChangesAsync(ct);
+ }
+
+ public static async Task GrantIsActiveAsync(
+ IDocumentSession session, Guid grantId, Guid userId, CancellationToken ct)
+ {
+ var grant = await session.LoadAsync(grantId, ct);
+ return grant is { Status: PositionGrantStatus.Active } && grant.UserId == userId;
+ }
+
+ public static Guid PasswordCredentialVersion(string? securityStamp)
+ {
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(securityStamp ?? string.Empty));
+ return new Guid(hash.AsSpan(0, 16));
+ }
+
+ private static SubjectResult Failed(string code, string message) =>
+ new(null, null, ActivationChallenge.Failed(code, message));
+
+ private static CandidateSubjectResult CandidateFailed(string code, string message) =>
+ new(null, [], ActivationChallenge.Failed(code, message));
+}
+
+public sealed record ActivationResult(ActivationEvidence? Evidence, ActivationProofFailure? Failure)
+{
+ public static ActivationResult Failed(string code, string message) =>
+ new(null, new ActivationProofFailure(code, message));
+}
+
+public sealed record CandidateActivationResult(
+ IReadOnlyList Candidates,
+ ActivationProofFailure? Failure)
+{
+ public static CandidateActivationResult Failed(string code, string message) =>
+ new([], new ActivationProofFailure(code, message));
+}
+
+public sealed record ActivationProofFailure(string Code, string Message);
+
+public interface IActivationInvalidationRegistry
+{
+ void Register(string lifecycleEvent, string methodId);
+}
+
+public sealed class ActivationInvalidationRegistry : IActivationInvalidationRegistry
+{
+ private readonly Dictionary> _methodsByEvent = new(StringComparer.Ordinal);
+
+ public IReadOnlyCollection MethodsFor(string lifecycleEvent) =>
+ _methodsByEvent.TryGetValue(lifecycleEvent, out var methods) ? methods : [];
+
+ public void Register(string lifecycleEvent, string methodId)
+ {
+ if (!_methodsByEvent.TryGetValue(lifecycleEvent, out var methods))
+ _methodsByEvent[lifecycleEvent] = methods = new HashSet(StringComparer.Ordinal);
+ methods.Add(methodId);
+ }
+}
+
+public sealed class ActivationProofRegistry
+{
+ private readonly IReadOnlyDictionary _proofs;
+
+ public ActivationProofRegistry(
+ IEnumerable proofs,
+ ActivationInvalidationRegistry invalidations)
+ {
+ _proofs = proofs.ToDictionary(p => p.MethodId, StringComparer.Ordinal);
+ foreach (var proof in _proofs.Values)
+ {
+ if (!ActivationProofMethodIds.Known.TryGetValue(proof.MethodId, out var descriptor) ||
+ !descriptor.IsAvailable ||
+ descriptor.Capabilities != proof.Capabilities ||
+ descriptor.OwnerKind != proof.OwnerKind)
+ {
+ throw new InvalidOperationException(
+ $"Activation proof '{proof.MethodId}' does not match its immutable security descriptor.");
+ }
+ proof.RegisterInvalidationHooks(invalidations);
+ }
+ }
+
+ public bool TryGet(string methodId, out IActivationProof proof) =>
+ _proofs.TryGetValue(methodId, out proof!);
+}
+
+/// WebAuthn proof owned by a logical position token. Unlike a person
+/// passkey it establishes possession of an assigned team credential and never
+/// invents a human actor.
+public sealed class PositionTokenActivationProof(
+ IDocumentSession session,
+ RealmScopedFido2Factory fido2Factory) : IActivationProof
+{
+ public string MethodId => ActivationProofMethodIds.PositionToken;
+ public ProofCapability Capabilities =>
+ ProofCapability.PhishingResistant | ProofCapability.IndividuallyRevocable;
+ public ActivationProofOwnerKind OwnerKind => ActivationProofOwnerKind.PositionCredential;
+
+ public async Task BeginAsync(ActivationContext context, CancellationToken ct)
+ {
+ var tokens = (await session.Query()
+ .Where(t => t.Status == ActivationTokenStatus.Active)
+ .ToListAsync(ct))
+ .Where(t => t.AssignedPositionIds.Contains(context.Position.Id))
+ .Select(t => t.Id)
+ .ToHashSet();
+ var credentials = (await session.Query()
+ .Where(c => c.RpId == context.Terminal.WebAuthnRpId)
+ .ToListAsync(ct))
+ .Where(c => tokens.Contains(c.ActivationTokenId))
+ .ToList();
+ if (credentials.Count == 0)
+ return ActivationChallenge.Failed(
+ "Staffing.NoEligiblePositionTokens",
+ "No assigned position token is registered for this terminal's relying party.");
+
+ IFido2 fido2;
+ try
+ {
+ fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: context.Terminal.WebAuthnRpId);
+ }
+ catch (RelyingPartyUnavailableException)
+ {
+ return ActivationChallenge.Failed(
+ "Staffing.RelyingPartyUnavailable", "The terminal's relying party is not available.");
+ }
+
+ var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
+ {
+ AllowedCredentials = credentials
+ .Select(c => new PublicKeyCredentialDescriptor(c.CredentialId))
+ .ToList(),
+ UserVerification = UserVerificationRequirement.Preferred,
+ });
+ var optionsJson = options.ToJson();
+ var now = DateTimeOffset.UtcNow;
+ session.DeleteWhere(c => c.ExpiresAt < now);
+ var ceremony = new StaffingCeremony
+ {
+ Id = Guid.NewGuid(),
+ PositionPrincipalId = context.Position.Id,
+ TerminalEnrollmentId = context.Terminal.Id,
+ ClientId = context.Terminal.ClientId,
+ DpopJkt = context.Terminal.DpopJkt ?? string.Empty,
+ MethodId = MethodId,
+ RpId = context.Terminal.WebAuthnRpId,
+ OptionsJson = optionsJson,
+ CreatedAt = now,
+ ExpiresAt = now.AddMinutes(5),
+ };
+ session.Store(ceremony);
+ await session.SaveChangesAsync(ct);
+ return new ActivationChallenge(ceremony, optionsJson, null);
+ }
+
+ public async Task CompleteAsync(
+ ActivationContext context, string response, CancellationToken ct)
+ {
+ if (context.Ceremony is not { } ceremony)
+ return ActivationResult.Failed("Staffing.InvalidCeremony", "Invalid or expired staffing ceremony.");
+
+ string[]? presentedOrigins = null;
+ try
+ {
+ var assertion = JsonSerializer.Deserialize(
+ response, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ if (RealmFido2.TryGetClientDataOrigin(assertion?.Response?.ClientDataJson) is { } origin)
+ presentedOrigins = [origin];
+ }
+ catch (JsonException) { }
+
+ IFido2 fido2;
+ try
+ {
+ fido2 = await fido2Factory.CreateAsync(
+ ct, rpIdOverride: ceremony.RpId, additionalOrigins: presentedOrigins);
+ }
+ catch (RelyingPartyUnavailableException)
+ {
+ return ActivationResult.Failed(
+ "Staffing.RelyingPartyUnavailable", "Staffing is not available for this realm.");
+ }
+
+ AssertionOptions options;
+ try { options = AssertionOptions.FromJson(ceremony.OptionsJson); }
+ catch { return ActivationResult.Failed("Staffing.InvalidCeremony", "Invalid or expired staffing ceremony."); }
+
+ var credential = await ActivationTokenAssertionVerifier.VerifyAsync(
+ fido2, options, response, session, ceremony.RpId, ct);
+ if (credential is null)
+ return ActivationResult.Failed("Staffing.PositionTokenFailed", "Position token verification failed.");
+
+ var token = await session.LoadAsync(credential.ActivationTokenId, ct);
+ if (token is not { Status: ActivationTokenStatus.Active } ||
+ !token.AssignedPositionIds.Contains(context.Position.Id))
+ return ActivationResult.Failed("Staffing.PositionTokenFailed", "Position token verification failed.");
+
+ return new ActivationResult(new ActivationEvidence
+ {
+ MethodId = MethodId,
+ CredentialId = credential.Id,
+ ActivationTokenId = token.Id,
+ Binding = context.Terminal.Binding,
+ }, null);
+ }
+
+ public async Task BeginCandidatesAsync(
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ ActivationBeginInput input,
+ CancellationToken ct)
+ {
+ var positionIds = positions.Select(position => position.Id).ToArray();
+ var tokens = (await session.Query()
+ .Where(token => token.Status == ActivationTokenStatus.Active)
+ .ToListAsync(ct))
+ .Where(token => token.AssignedPositionIds.Any(positionIds.Contains))
+ .ToArray();
+ var tokenIds = tokens.Select(token => token.Id).ToHashSet();
+ var credentials = (await session.Query()
+ .Where(credential => credential.RpId == terminal.WebAuthnRpId)
+ .ToListAsync(ct))
+ .Where(credential => tokenIds.Contains(credential.ActivationTokenId))
+ .ToList();
+ if (credentials.Count == 0)
+ return ActivationChallenge.Failed(
+ "Staffing.NoEligiblePositionTokens",
+ "No assigned position token is registered for this terminal's relying party.");
+
+ IFido2 fido2;
+ try { fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: terminal.WebAuthnRpId); }
+ catch (RelyingPartyUnavailableException)
+ {
+ return ActivationChallenge.Failed(
+ "Staffing.RelyingPartyUnavailable", "The terminal's relying party is not available.");
+ }
+ var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
+ {
+ AllowedCredentials = credentials
+ .Select(credential => new PublicKeyCredentialDescriptor(credential.CredentialId))
+ .ToList(),
+ UserVerification = UserVerificationRequirement.Preferred,
+ });
+ var optionsJson = options.ToJson();
+ var now = DateTimeOffset.UtcNow;
+ session.DeleteWhere(ceremony => ceremony.ExpiresAt < now);
+ var ceremony = new StaffingCeremony
+ {
+ Id = Guid.NewGuid(),
+ PositionPrincipalId = Guid.Empty,
+ CandidatePositionIds = positions.Select(position => position.Id).Distinct().ToArray(),
+ TerminalEnrollmentId = terminal.Id,
+ ClientId = terminal.ClientId,
+ DpopJkt = terminal.DpopJkt ?? string.Empty,
+ MethodId = MethodId,
+ RpId = terminal.WebAuthnRpId,
+ OptionsJson = optionsJson,
+ CreatedAt = now,
+ ExpiresAt = now.AddMinutes(5),
+ };
+ session.Store(ceremony);
+ await session.SaveChangesAsync(ct);
+ return new ActivationChallenge(ceremony, optionsJson, null);
+ }
+
+ public async Task CompleteCandidatesAsync(
+ StaffingCeremony ceremony,
+ string response,
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ CancellationToken ct)
+ {
+ string[]? presentedOrigins = null;
+ try
+ {
+ var assertion = JsonSerializer.Deserialize(
+ response, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ if (RealmFido2.TryGetClientDataOrigin(assertion?.Response?.ClientDataJson) is { } origin)
+ presentedOrigins = [origin];
+ }
+ catch (JsonException) { }
+
+ IFido2 fido2;
+ try
+ {
+ fido2 = await fido2Factory.CreateAsync(
+ ct, rpIdOverride: ceremony.RpId, additionalOrigins: presentedOrigins);
+ }
+ catch (RelyingPartyUnavailableException)
+ {
+ return CandidateActivationResult.Failed(
+ "Staffing.RelyingPartyUnavailable", "Staffing is not available for this realm.");
+ }
+ AssertionOptions options;
+ try { options = AssertionOptions.FromJson(ceremony.OptionsJson); }
+ catch
+ {
+ return CandidateActivationResult.Failed(
+ "Staffing.InvalidCeremony", "Invalid or expired staffing ceremony.");
+ }
+ var credential = await ActivationTokenAssertionVerifier.VerifyAsync(
+ fido2, options, response, session, ceremony.RpId, ct);
+ if (credential is null)
+ return CandidateActivationResult.Failed(
+ "Staffing.PositionTokenFailed", "Position token verification failed.");
+ var token = await session.LoadAsync(credential.ActivationTokenId, ct);
+ if (token is not { Status: ActivationTokenStatus.Active })
+ return CandidateActivationResult.Failed(
+ "Staffing.PositionTokenFailed", "Position token verification failed.");
+
+ var allowed = positions.Select(position => position.Id).ToHashSet();
+ var candidates = token.AssignedPositionIds
+ .Where(allowed.Contains)
+ .Distinct()
+ .Select(positionId => new StaffingCandidateEvidence(positionId, new ActivationEvidence
+ {
+ MethodId = MethodId,
+ CredentialId = credential.Id,
+ ActivationTokenId = token.Id,
+ Binding = terminal.Binding,
+ }))
+ .ToArray();
+ return candidates.Length == 0
+ ? CandidateActivationResult.Failed(
+ "Staffing.PositionTokenFailed", "Position token verification failed.")
+ : new CandidateActivationResult(candidates, null);
+ }
+
+ public async Task RevalidateAsync(
+ ActivationEvidence evidence, PositionPrincipal position, CancellationToken ct)
+ {
+ if (evidence.ActivationTokenId is not { } tokenId || evidence.CredentialId is not { } credentialId)
+ return false;
+ var token = await session.LoadAsync(tokenId, ct);
+ if (token is not { Status: ActivationTokenStatus.Active } ||
+ !token.AssignedPositionIds.Contains(position.Id))
+ return false;
+ var credential = await session.LoadAsync(credentialId, ct);
+ return credential?.ActivationTokenId == tokenId;
+ }
+
+ public void RegisterInvalidationHooks(IActivationInvalidationRegistry registry)
+ {
+ registry.Register("activation-token-revoked", MethodId);
+ registry.Register("activation-token-unassigned", MethodId);
+ }
+}
+
+internal static class ActivationTokenAssertionVerifier
+{
+ public static async Task VerifyAsync(
+ IFido2 fido2,
+ AssertionOptions originalOptions,
+ string assertionJson,
+ IDocumentSession session,
+ string activeRpId,
+ CancellationToken ct)
+ {
+ AuthenticatorAssertionRawResponse? assertion;
+ try
+ {
+ assertion = JsonSerializer.Deserialize(
+ assertionJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ catch (JsonException) { return null; }
+ if (assertion is null || string.IsNullOrEmpty(assertion.Id) || assertion.Response is null) return null;
+
+ byte[] credentialId;
+ try
+ {
+ credentialId = Convert.FromBase64String(assertion.Id.Replace('-', '+').Replace('_', '/')
+ .PadRight(assertion.Id.Length + (4 - assertion.Id.Length % 4) % 4, '='));
+ }
+ catch (FormatException) { return null; }
+
+ var candidates = await session.Query()
+ .Where(c => c.RpId == activeRpId)
+ .ToListAsync(ct);
+ var stored = candidates.FirstOrDefault(c => c.CredentialId.SequenceEqual(credentialId));
+ if (stored is null) return null;
+
+ VerifyAssertionResult verified;
+ try
+ {
+ verified = await fido2.MakeAssertionAsync(new MakeAssertionParams
+ {
+ AssertionResponse = assertion,
+ OriginalOptions = originalOptions,
+ StoredPublicKey = stored.PublicKey,
+ StoredSignatureCounter = stored.SignatureCount,
+ IsUserHandleOwnerOfCredentialIdCallback = (args, _) => Task.FromResult(
+ candidates.Any(c => c.CredentialId.SequenceEqual(args.CredentialId) &&
+ c.UserHandle.SequenceEqual(args.UserHandle))),
+ }, ct);
+ }
+ catch { return null; }
+
+ stored.SignatureCount = verified.SignCount;
+ stored.LastUsedAt = DateTimeOffset.UtcNow;
+ session.Store(stored);
+ await session.SaveChangesAsync(ct);
+ return stored;
+ }
+}
+
+/// Reference adapter for the existing staffing passkey flow. This is
+/// intentionally a refactoring of the prior inline branch, not a new login
+/// implementation.
+public sealed class PersonalPasskeyActivationProof(
+ IDocumentSession session,
+ RealmScopedFido2Factory fido2Factory,
+ RpIdResolver rpIdResolver,
+ UserManager userManager,
+ SignInManager signInManager) : IActivationProof
+{
+ public string MethodId => ActivationProofMethodIds.PersonalPasskey;
+ public ProofCapability Capabilities =>
+ ProofCapability.IdentifiedActor |
+ ProofCapability.PhishingResistant |
+ ProofCapability.IndividuallyRevocable;
+ public ActivationProofOwnerKind OwnerKind => ActivationProofOwnerKind.Personal;
+
+ public async Task BeginAsync(ActivationContext context, CancellationToken ct)
+ {
+ var grantedUserIds = (await session.Query()
+ .Where(g => g.PositionPrincipalId == context.Position.Id &&
+ g.Status == PositionGrantStatus.Active)
+ .ToListAsync(ct))
+ .Select(g => g.UserId)
+ .Distinct()
+ .ToList();
+ if (grantedUserIds.Count == 0)
+ return ActivationChallenge.Failed(
+ "Staffing.NoActiveGrants", "No user is authorized to staff this position.");
+
+ var primaryDomain = await rpIdResolver.GetPrimaryDomainAsync(ct);
+ var allowedCredentials = (await session.Query()
+ .Where(c => grantedUserIds.Contains(c.UserId))
+ .ToListAsync(ct))
+ .Where(c => string.Equals(c.RpId ?? primaryDomain, context.Terminal.WebAuthnRpId,
+ StringComparison.OrdinalIgnoreCase))
+ .Select(c => new PublicKeyCredentialDescriptor(c.CredentialId))
+ .ToList();
+ if (allowedCredentials.Count == 0)
+ return ActivationChallenge.Failed(
+ "Staffing.NoEligiblePasskeys", "No authorized user has a passkey for this terminal.");
+
+ IFido2 fido2;
+ try
+ {
+ fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: context.Terminal.WebAuthnRpId);
+ }
+ catch (RelyingPartyUnavailableException)
+ {
+ return ActivationChallenge.Failed(
+ "Staffing.RelyingPartyUnavailable", "The terminal's relying party is not available.");
+ }
+
+ var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
+ {
+ AllowedCredentials = allowedCredentials,
+ UserVerification = UserVerificationRequirement.Preferred,
+ });
+ var optionsJson = options.ToJson();
+
+ session.DeleteWhere(c => c.ExpiresAt < DateTimeOffset.UtcNow);
+ var ceremony = new StaffingCeremony
+ {
+ Id = Guid.NewGuid(),
+ PositionPrincipalId = context.Position.Id,
+ TerminalEnrollmentId = context.Terminal.Id,
+ ClientId = context.Terminal.ClientId,
+ DpopJkt = context.Terminal.DpopJkt ?? string.Empty,
+ MethodId = MethodId,
+ RpId = context.Terminal.WebAuthnRpId,
+ OptionsJson = optionsJson,
+ CreatedAt = DateTimeOffset.UtcNow,
+ ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
+ };
+ session.Store(ceremony);
+ await session.SaveChangesAsync(ct);
+ return new ActivationChallenge(ceremony, optionsJson, null);
+ }
+
+ public async Task CompleteAsync(
+ ActivationContext context, string response, CancellationToken ct)
+ {
+ if (context.Ceremony is not { } ceremony)
+ return ActivationResult.Failed("Staffing.InvalidCeremony", "Invalid or expired staffing ceremony.");
+
+ string[]? presentedOrigins = null;
+ try
+ {
+ var assertion = JsonSerializer.Deserialize(
+ response, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ if (RealmFido2.TryGetClientDataOrigin(assertion?.Response?.ClientDataJson) is { } origin)
+ presentedOrigins = [origin];
+ }
+ catch (JsonException) { }
+
+ var primaryDomain = await rpIdResolver.GetPrimaryDomainAsync(ct);
+ IFido2 fido2;
+ try
+ {
+ fido2 = await fido2Factory.CreateAsync(
+ ct, rpIdOverride: ceremony.RpId, additionalOrigins: presentedOrigins);
+ }
+ catch (RelyingPartyUnavailableException)
+ {
+ return ActivationResult.Failed(
+ "Staffing.RelyingPartyUnavailable", "Staffing is not available for this realm.");
+ }
+
+ AssertionOptions options;
+ try
+ {
+ options = AssertionOptions.FromJson(ceremony.OptionsJson);
+ }
+ catch
+ {
+ return ActivationResult.Failed("Staffing.InvalidCeremony", "Invalid or expired staffing ceremony.");
+ }
+
+ var storedCredential = await PasskeyAssertionVerifier.VerifyAsync(
+ fido2, options, response, session, ceremony.RpId, primaryDomain, ct);
+ if (storedCredential is null)
+ return ActivationResult.Failed("Staffing.PasskeyFailed", "Passkey verification failed.");
+
+ var user = await userManager.FindByIdAsync(storedCredential.UserId.ToString());
+ if (user is null || !await signInManager.CanSignInAsync(user) || !user.IsActive || user.IsDeleted ||
+ !string.Equals(storedCredential.RpId ?? primaryDomain, ceremony.RpId,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return ActivationResult.Failed("Staffing.PasskeyFailed", "Passkey verification failed.");
+ }
+
+ var grant = (await session.Query()
+ .Where(g => g.PositionPrincipalId == context.Position.Id && g.UserId == user.Id &&
+ g.Status == PositionGrantStatus.Active)
+ .ToListAsync(ct))
+ .FirstOrDefault();
+ if (grant is null)
+ return ActivationResult.Failed(
+ "Staffing.GrantRequired", "The user is not authorized to staff this position.");
+
+ return new ActivationResult(new ActivationEvidence
+ {
+ MethodId = MethodId,
+ UserId = user.Id,
+ GrantId = grant.Id,
+ CredentialId = storedCredential.Id,
+ Binding = context.Terminal.Binding,
+ }, null);
+ }
+
+ public async Task BeginCandidatesAsync(
+ IReadOnlyList positions,
+ TerminalEnrollment terminal,
+ ActivationBeginInput input,
+ CancellationToken ct)
+ {
+ var positionIds = positions.Select(position => position.Id).ToArray();
+ var grantedUserIds = (await session.Query()
+ .Where(grant => grant.Status == PositionGrantStatus.Active)
+ .ToListAsync(ct))
+ .Where(grant => positionIds.Contains(grant.PositionPrincipalId))
+ .Select(grant => grant.UserId)
+ .Distinct()
+ .ToList();
+ if (grantedUserIds.Count == 0)
+ return ActivationChallenge.Failed(
+ "Staffing.NoActiveGrants", "No user is authorized to staff a position on this terminal.");
+
+ var primaryDomain = await rpIdResolver.GetPrimaryDomainAsync(ct);
+ var credentials = (await session.Query