From ab116a6c0a58801b84650b90b0f466167b387893 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 17 Aug 2026 10:40:46 +0200 Subject: [PATCH] feat(positions): add flexible shared-terminal staffing Add configurable activation proofs, device bindings, realm security floors, multi-position enrollment and lifecycle handling. Extend the admin UI, operator and integration documentation, and automated coverage. PositionTerminals remains disabled by default and can be enabled explicitly via feature flag. --- AGENTS.md | 14 + CHANGELOG.md | 14 +- docs/admin/positions-concepts.md | 72 +- docs/admin/positions.md | 121 +- docs/integrate/position-terminals.md | 300 ++-- docs/operate/feature-flags.md | 3 + docs/roadmap.md | 22 + ...sitionTerminalsFeatureFlagContractTests.cs | 14 + .../Positions/ActivationTokenTests.cs | 138 ++ .../Positions/PositionCrudTests.cs | 67 +- .../Positions/PositionTerminalTests.cs | 67 +- .../Positions/StaffingConcurrencyTests.cs | 4 +- .../Positions/StaffingTests.cs | 792 +++++++++- .../TerminalClientFromClientSideTests.cs | 45 +- .../TerminalDeviceEnrollmentTests.cs | 90 +- .../Auth/OAuth/AuthorizationEndpoints.cs | 497 +++++-- .../Auth/OAuth/DeviceVerificationEndpoints.cs | 35 +- .../PositionTerminals/StaffingPrincipal.cs | 13 +- .../TerminalEnrollmentPrincipal.cs | 37 +- .../Auth/Staffing/ActivationProofs.cs | 1302 +++++++++++++++++ .../Auth/Staffing/StaffingEndpoints.cs | 445 ++++-- .../Positions/ActivationTokenEndpoints.cs | 453 ++++++ .../Positions/PositionTerminalsEndpoints.cs | 150 +- .../Features/Positions/PositionsEndpoints.cs | 338 ++++- src/dotnet/Modgud.Api/Program.cs | 11 + .../DTOs/OAuth/OAuthClientDtos.cs | 3 + .../DTOs/Positions/PositionPrincipalDtos.cs | 17 + .../DTOs/Positions/TerminalDtos.cs | 16 + .../PositionSecuritySettingsDtos.cs | 29 + .../DTOs/RealmSettings/RealmSettingsDtos.cs | 3 + .../Services/OAuthAdminMapping.cs | 27 +- .../Services/OAuthAdminService.Terminals.cs | 145 +- .../Api/Account/AccountEndpoints.cs | 5 + .../Api/Account/EmailOtpEndpoints.cs | 5 + .../Api/Account/PasswordResetEndpoints.cs | 4 + .../Api/Admin/RealmSettingsEndpoints.cs | 8 + .../RealmSettings/RealmSettingsService.cs | 110 +- .../Principals/PositionPrincipal.cs | 10 + .../Principals/PositionTerminalSecurity.cs | 184 +++ .../PositionTerminals/ActivationToken.cs | 57 + .../PositionTerminals/PositionGrant.cs | 8 + .../PositionTerminals/PositionGrantEvents.cs | 9 + .../PositionTokenConstants.cs | 11 + .../PositionTerminals/StaffingCeremony.cs | 19 + .../PositionTerminals/StaffingSession.cs | 44 +- .../StaffingSessionEvents.cs | 14 +- .../PositionTerminals/TerminalEnrollment.cs | 13 +- .../TerminalEnrollmentEvents.cs | 12 +- .../RealmSettings/PositionSecuritySettings.cs | 14 + .../RealmSettings/RealmSettings.cs | 4 + .../PositionGrantProjection.cs | 12 + .../StaffingSessionProjection.cs | 24 + .../TerminalEnrollmentProjection.cs | 9 +- .../PositionTerminals/IStaffingRevoker.cs | 2 + .../PositionTerminalsMartenSetup.cs | 23 + .../PositionTerminals/StaffingRevoker.cs | 6 + .../PositionTerminalSecurityContractTests.cs | 94 ++ .../e2e/81-staffing-client-ui.spec.ts | 88 ++ src/frontend-vue/public/i18n/de.json | 152 +- src/frontend-vue/src/main.ts | 1 + src/frontend-vue/src/models/device.ts | 1 + src/frontend-vue/src/models/oauth.ts | 2 + src/frontend-vue/src/models/position.ts | 33 +- src/frontend-vue/src/models/realmSettings.ts | 28 + .../src/stores/realmSettings.store.ts | 11 +- .../src/views/admin/RealmSettingsView.vue | 143 ++ .../src/views/admin/oauth/ClientDetails.vue | 481 +++++- .../src/views/admin/oauth/ClientList.vue | 7 +- .../views/admin/position/PositionDetails.vue | 462 +++++- .../src/views/auth/DeviceVerifyView.vue | 16 +- 70 files changed, 6777 insertions(+), 633 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/dotnet/Modgud.Api.Tests/Authorization/PositionTerminalsFeatureFlagContractTests.cs create mode 100644 src/dotnet/Modgud.Api.Tests/Positions/ActivationTokenTests.cs create mode 100644 src/dotnet/Modgud.Api/Features/Auth/Staffing/ActivationProofs.cs create mode 100644 src/dotnet/Modgud.Api/Features/Positions/ActivationTokenEndpoints.cs create mode 100644 src/dotnet/Modgud.Application/DTOs/RealmSettings/PositionSecuritySettingsDtos.cs create mode 100644 src/dotnet/Modgud.Authorization/Principals/PositionTerminalSecurity.cs create mode 100644 src/dotnet/Modgud.Domain/PositionTerminals/ActivationToken.cs create mode 100644 src/dotnet/Modgud.Domain/RealmSettings/PositionSecuritySettings.cs create mode 100644 src/dotnet/Modgud.Tests.Unit/Authorization/PositionTerminalSecurityContractTests.cs create mode 100644 src/frontend-vue/e2e/81-staffing-client-ui.spec.ts 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() + .Where(credential => grantedUserIds.Contains(credential.UserId)) + .ToListAsync(ct)) + .Where(credential => string.Equals( + credential.RpId ?? primaryDomain, terminal.WebAuthnRpId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (credentials.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: 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) { } + + var primaryDomain = await rpIdResolver.GetPrimaryDomainAsync(ct); + 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 PasskeyAssertionVerifier.VerifyAsync( + fido2, options, response, session, ceremony.RpId, primaryDomain, ct); + if (credential is null) + return CandidateActivationResult.Failed( + "Staffing.PasskeyFailed", "Passkey verification failed."); + var user = await userManager.FindByIdAsync(credential.UserId.ToString()); + if (user is null || !await signInManager.CanSignInAsync(user) || !user.IsActive || user.IsDeleted) + return CandidateActivationResult.Failed( + "Staffing.PasskeyFailed", "Passkey verification failed."); + + 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)) + .GroupBy(grant => grant.PositionPrincipalId) + .Select(group => group.First()) + .ToArray(); + if (grants.Length == 0) + return CandidateActivationResult.Failed( + "Staffing.GrantRequired", "The user is not authorized to staff a position on this terminal."); + return new CandidateActivationResult(grants.Select(grant => + new StaffingCandidateEvidence(grant.PositionPrincipalId, new ActivationEvidence + { + MethodId = MethodId, + UserId = user.Id, + GrantId = grant.Id, + CredentialId = credential.Id, + Binding = terminal.Binding, + })).ToArray(), null); + } + + public async Task RevalidateAsync(ActivationEvidence evidence, PositionPrincipal position, CancellationToken ct) + { + if (evidence.UserId is not { } userId || evidence.CredentialId is not { } credentialId || + evidence.GrantId is not { } grantId) + return false; + + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null || !await signInManager.CanSignInAsync(user) || !user.IsActive || user.IsDeleted) + return false; + + var credential = await session.LoadAsync(credentialId, ct); + if (credential is null || credential.UserId != userId) return false; + + var grant = await session.LoadAsync(grantId, ct); + return grant is { Status: PositionGrantStatus.Active, UserId: var grantUserId } + && grantUserId == userId; + } + + public void RegisterInvalidationHooks(IActivationInvalidationRegistry registry) + { + registry.Register("user-disabled", MethodId); + registry.Register("passkey-deleted", MethodId); + registry.Register("position-grant-suspended", MethodId); + registry.Register("position-grant-revoked", MethodId); + } +} diff --git a/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs index 75c3b7bb..c0db45a1 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs @@ -1,6 +1,5 @@ using BuildingBlocks.Helper; -using Fido2NetLib; -using Fido2NetLib.Objects; +using System.Text.Json; using Marten; using Microsoft.AspNetCore.Authorization; using Modgud.Api.Features.Auth.PositionTerminals; @@ -15,6 +14,7 @@ using OpenIddict.Abstractions; using OpenIddict.Validation.AspNetCore; using static OpenIddict.Abstractions.OpenIddictConstants; +using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Api.Features.Auth.Staffing; @@ -23,16 +23,17 @@ namespace Modgud.Api.Features.Auth.Staffing; /// terminal, holding its enrollment access token, asks for WebAuthn assertion /// options so a person can tap their passkey and open a /// (redeemed via the custom staffing grant at -/// the token endpoint, §13). Position and terminal are derived from the -/// validated token — the request chooses nothing. +/// the token endpoint, §13). The validated control token fixes the terminal; +/// V1 also fixes its singleton position, while V2 resolves an allowed position +/// only after the activation proof has succeeded. /// /// Contract note (documented deviation from §12's /// Authorization: DPoP sketch): the API's OpenIddict validation /// pipeline extracts Bearer tokens only, so the enrollment token travels as -/// Authorization: Bearer while the DPoP proof-of-possession is -/// enforced explicitly here — the DPoP header is mandatory and its -/// key must be the slot's enrolled key (§12.2 check 7). Full -/// DPoP-scheme-extraction on the resource side is a hardening follow-up. +/// Authorization: Bearer. DPoP-bound slots additionally enforce the +/// proof explicitly here; ClientSecret and None slots rely on their respective +/// enrollment/token-endpoint profile. Full DPoP-scheme extraction on the +/// resource side remains a hardening follow-up. /// public static class StaffingEndpoints { @@ -45,7 +46,8 @@ public static WebApplication MapStaffingEndpoints(this WebApplication applicatio .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, - }); + }) + .RequireRateLimiting("passkey-begin"); // §15.2 — the terminal-facing local lock. Authenticated by either // position token of the SAME terminal (staffing token of the active @@ -62,6 +64,16 @@ public static WebApplication MapStaffingEndpoints(this WebApplication applicatio AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, }); + application.MapPost("/connect/staffing/{terminalId:guid}/step-up", StepUpBeginAsync) + .WithName("Staffing_StepUpBegin") + .WithTags("Position Staffing") + .DisableAntiforgery() + .RequireAuthorization(new AuthorizeAttribute + { + AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, + }) + .RequireRateLimiting("passkey-begin"); + // §15.3 — admin surface (cookie-auth like every /api endpoint). var admin = application.MapGroup("/api") .WithTags("Position Staffing") @@ -88,6 +100,93 @@ public static WebApplication MapStaffingEndpoints(this WebApplication applicatio return application; } + private static async Task StepUpBeginAsync( + Guid terminalId, + StepUpBeginInput input, + HttpContext context, + AppSettings settings, + IDocumentSession session, + ActivationProofRegistry activationProofs, + IDpopReplayStore replayStore, + CancellationToken ct) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + var principal = context.User; + if (!string.Equals(principal.GetClaim(PositionTokenClaimTypes.TokenUse), + PositionTokenUses.StaffingSession, StringComparison.Ordinal) || + !Guid.TryParse(principal.GetClaim(Claims.Subject), out var positionId) || + !Guid.TryParse(principal.GetClaim(PositionTokenClaimTypes.TerminalId), out var tokenTerminalId) || + tokenTerminalId != terminalId || + !Guid.TryParse(principal.GetClaim(PositionTokenClaimTypes.StaffingSessionId), out var staffingSessionId)) + return Forbidden("Staffing.InvalidToken", "An active staffing access token is required."); + + if ((input.Action is null) != (input.Nonce is null) || + input.Action is { Length: > 200 } || input.Nonce is { Length: > 200 } || + string.IsNullOrWhiteSpace(input.Action) != string.IsNullOrWhiteSpace(input.Nonce)) + return Results.BadRequest(new + { + Error = "Staffing.InvalidStepUpBinding", + Message = "Action and nonce must be supplied together and be at most 200 characters." + }); + + var staffing = await session.LoadAsync(staffingSessionId, ct); + var terminal = await session.LoadAsync(terminalId, ct); + var position = await session.LoadAsync(positionId, ct); + if (staffing is not { Status: StaffingSessionStatus.Active } || + staffing.AbsoluteExpiresAt <= DateTimeOffset.UtcNow || + staffing.TerminalEnrollmentId != terminalId || staffing.PositionPrincipalId != positionId || + terminal is not { Status: TerminalEnrollmentStatus.Active } || + terminal.ActiveStaffingSessionId != staffing.Id || + !terminal.EffectiveAllowedPositionIds.Contains(positionId) || + position is null || position.IsDeleted || !position.IsActive || !position.TerminalPolicy.Enabled) + return Forbidden("Staffing.SessionUnavailable", "The staffing session is no longer active."); + + if (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal)) + { + var proofHeader = context.Request.Headers[DpopConstants.HeaderName]; + if (proofHeader.Count != 1 || !TryGetPresentedAccessToken(context.Request, out var accessToken)) + return Forbidden("Staffing.DpopRequired", "A DPoP proof bound to the staffing access token is required."); + var now = DateTimeOffset.UtcNow; + var htu = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; + var proof = DpopProofValidator.Validate( + proofHeader.ToString(), context.Request.Method, htu, now, accessToken); + if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal)) + return Forbidden("Staffing.DpopMismatch", "The DPoP proof key does not match this terminal."); + if (!await RecordDpopProofAsync(replayStore, proof, now, ct)) + return Forbidden("Staffing.DpopReplay", "The DPoP proof has already been used."); + } + + var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var requiredProof = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None; + var methodId = string.IsNullOrWhiteSpace(input.MethodId) + ? position.TerminalPolicy.AllowedActivationProofs.FirstOrDefault(m => + PositionTerminalSecurity.ProofMeetsFloor(m, requiredProof) && activationProofs.TryGet(m, out _)) + : input.MethodId.Trim(); + if (methodId is null || + !position.TerminalPolicy.AllowedActivationProofs.Contains(methodId, StringComparer.Ordinal) || + !PositionTerminalSecurity.ProofMeetsFloor(methodId, requiredProof) || + !activationProofs.TryGet(methodId, out var activationProof)) + return Forbidden("Staffing.ActivationProofUnavailable", "The requested step-up proof is unavailable."); + + var challenge = await activationProof.BeginAsync( + new ActivationContext(position, terminal, + BeginInput: new ActivationBeginInput(methodId, input.AccountName, new ShortGuid(position.Id).ToString())), ct); + if (challenge.Failure is { } failure) return Forbidden(failure.Code, failure.Message); + var ceremony = challenge.Ceremony!; + ceremony.StepUpForStaffingSessionId = staffing.Id; + ceremony.StepUpAction = string.IsNullOrWhiteSpace(input.Action) ? null : input.Action; + ceremony.StepUpNonce = string.IsNullOrWhiteSpace(input.Nonce) ? null : input.Nonce; + ceremony.StepUpScopes = principal.GetScopes() + .Where(scope => !string.Equals(scope, Scopes.OfflineAccess, StringComparison.Ordinal)) + .ToArray(); + session.Store(ceremony); + await session.SaveChangesAsync(ct); + + return Results.Content( + $"{{\"ceremonyId\":\"{ceremony.Id}\",\"methodId\":{JsonSerializer.Serialize(methodId)}," + + $"\"{challenge.ResponseProperty}\":{challenge.OptionsJson}}}", "application/json"); + } + private static async Task ForceLockAsync(AppSettings settings, Func> end) { if (!settings.Features.PositionTerminals) return Results.NotFound(); @@ -115,16 +214,26 @@ private static async Task ListSessionsAsync( // ActivatedBy* stays admin-only security metadata (plan §4.5) — it is // shown HERE for audit purposes and never travels in tokens. - return Results.Ok(sessions.Select(s => new + return Results.Ok(sessions.Select(s => { - Id = new ShortGuid(s.Id).ToString(), - TerminalId = new ShortGuid(s.TerminalEnrollmentId).ToString(), - ActivatedByUserId = new ShortGuid(s.ActivatedByUserId).ToString(), - s.Status, - s.StartedAt, - s.AbsoluteExpiresAt, - s.EndedAt, - s.EndReason, + var evidence = s.GetActivationEvidence(); + return new + { + Id = new ShortGuid(s.Id).ToString(), + TerminalId = new ShortGuid(s.TerminalEnrollmentId).ToString(), + ActivatedByUserId = evidence.UserId is { } userId + ? new ShortGuid(userId).ToString() + : null, + ActivationProof = evidence.MethodId, + ActivationTokenId = evidence.ActivationTokenId is { } tokenId + ? new ShortGuid(tokenId).ToString() + : null, + s.Status, + s.StartedAt, + s.AbsoluteExpiresAt, + s.EndedAt, + s.EndReason, + }; })); } @@ -133,6 +242,7 @@ private static async Task LockAsync( HttpContext context, AppSettings settings, IDocumentSession session, + IDpopReplayStore replayStore, IStaffingRevoker revoker, CancellationToken ct) { @@ -153,18 +263,26 @@ private static async Task LockAsync( } var terminal = await session.LoadAsync(terminalId, ct); - if (terminal is null || string.IsNullOrEmpty(terminal.DpopJkt)) + if (terminal is null) return Forbidden("Staffing.InvalidToken", "A position terminal token is required."); - // Same device key — a lock from another machine is not a local lock. - var proofHeader = context.Request.Headers[DpopConstants.HeaderName]; - if (proofHeader.Count != 1) - return Forbidden("Staffing.DpopRequired", "A DPoP proof is required."); - var htu = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; - var proof = DpopProofValidator.Validate( - proofHeader.ToString(), context.Request.Method, htu, DateTimeOffset.UtcNow); - if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal)) - return Forbidden("Staffing.DpopMismatch", "The DPoP proof key is not this terminal's enrolled key."); + if (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal)) + { + // Same device key — a lock from another machine is not a local lock. + var proofHeader = context.Request.Headers[DpopConstants.HeaderName]; + if (proofHeader.Count != 1) + return Forbidden("Staffing.DpopRequired", "A DPoP proof is required."); + if (!TryGetPresentedAccessToken(context.Request, out var accessToken)) + return Forbidden("Staffing.InvalidToken", "The presented access token cannot be bound to the DPoP proof."); + var now = DateTimeOffset.UtcNow; + var htu = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; + var proof = DpopProofValidator.Validate( + proofHeader.ToString(), context.Request.Method, htu, now, accessToken); + if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal)) + return Forbidden("Staffing.DpopMismatch", "The DPoP proof key is not this terminal's enrolled key."); + if (!await RecordDpopProofAsync(replayStore, proof, now, ct)) + return Forbidden("Staffing.DpopReplay", "The DPoP proof has already been used."); + } // A staffing token may only lock ITS OWN (still-active) session — a // superseded shift's token cannot kill the successor. @@ -185,8 +303,8 @@ private static async Task BeginAsync( HttpContext context, AppSettings settings, IDocumentSession session, - RealmScopedFido2Factory fido2Factory, - RpIdResolver rpIdResolver, + ActivationProofRegistry activationProofs, + IDpopReplayStore replayStore, CancellationToken ct) { if (!settings.Features.PositionTerminals) return Results.NotFound(); @@ -201,7 +319,11 @@ private static async Task BeginAsync( return Forbidden("Staffing.InvalidToken", "A terminal enrollment token is required."); } - if (!Guid.TryParse(principal.GetClaim(Claims.Subject), out var positionId) || + var isControlV2 = string.Equals( + principal.GetClaim(PositionTokenClaimTypes.PrincipalType), + PositionPrincipalTypes.Terminal, + StringComparison.Ordinal); + if (!Guid.TryParse(principal.GetClaim(Claims.Subject), out var subjectId) || !Guid.TryParse(principal.GetClaim(PositionTokenClaimTypes.TerminalId), out var terminalId)) { return Forbidden("Staffing.InvalidToken", "A terminal enrollment token is required."); @@ -210,9 +332,13 @@ private static async Task BeginAsync( // §12.2 checks 3+4 — the slot exists and its client link is intact in // both directions; the token's client (when stamped) must be the // slot's own client. + if (isControlV2) terminalId = subjectId; var terminal = await session.LoadAsync(terminalId, ct); - if (terminal is null || terminal.PositionPrincipalId != positionId) + if (terminal is null) return Forbidden("Staffing.InvalidToken", "A terminal enrollment token is required."); + if (!isControlV2 && + (terminal.EffectiveAllowedPositionIds.Count != 1 || terminal.EffectiveAllowedPositionIds[0] != subjectId)) + return Forbidden("Staffing.LegacyControlToken", "This terminal assignment requires a V2 control token and re-enrollment."); var state = await session.LoadAsync(terminal.OAuthApplicationId, ct); if (state is null || state.IsDeleted || state.ManagedTerminalEnrollmentId != terminal.Id) @@ -222,90 +348,213 @@ private static async Task BeginAsync( if (tokenClientId is not null && !string.Equals(tokenClientId, terminal.ClientId, StringComparison.Ordinal)) return Forbidden("Staffing.ClientMismatch", "The token was not issued to this terminal's client."); - // §12.2 checks 5+6 — position alive + policy on, slot Active. - var position = await session.LoadAsync(positionId, ct); - if (position is null || position.IsDeleted || !position.TerminalPolicy.Enabled) - return Forbidden("Staffing.PositionUnavailable", "Terminal use is disabled for this position."); + // §12.2 check 6 — the slot itself must be active before either listing + // V2 candidates or beginning a proof. if (terminal.Status != TerminalEnrollmentStatus.Active) return Forbidden("Staffing.TerminalNotActive", "The terminal is not active."); - // §12.2 check 7 — proof-of-possession: the DPoP proof presented with - // THIS request must be signed by the slot's enrolled key. - var proofHeader = context.Request.Headers[DpopConstants.HeaderName]; - if (proofHeader.Count != 1) - return Forbidden("Staffing.DpopRequired", "A DPoP proof is required."); - var htu = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; - var proof = DpopProofValidator.Validate( - proofHeader.ToString(), context.Request.Method, htu, DateTimeOffset.UtcNow); - if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal)) - return Forbidden("Staffing.DpopMismatch", "The DPoP proof key is not this terminal's enrolled key."); - - // §12.2 check 8 — at least one ACTIVE user→position grant (Suspended - // does not authorize staffing). - var grantedUserIds = (await session.Query() - .Where(g => g.PositionPrincipalId == positionId && g.Status == PositionGrantStatus.Active) - .ToListAsync(ct)) - .Select(g => g.UserId) - .Distinct() - .ToList(); - if (grantedUserIds.Count == 0) - return Forbidden("Staffing.NoActiveGrants", "No user is authorized to staff this position."); - - // §12.2 check 9 + §12.3 — allowCredentials restricted to the granted - // users' passkeys under the terminal's RP-ID (legacy RpId == null - // credentials count for the realm's primary domain). - 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, terminal.WebAuthnRpId, StringComparison.OrdinalIgnoreCase)) - .Select(c => new PublicKeyCredentialDescriptor(c.CredentialId)) - .ToList(); - if (allowedCredentials.Count == 0) - return Forbidden("Staffing.NoEligiblePasskeys", "No authorized user has a passkey for this terminal."); - - IFido2 fido2; + // Sender constraint protects candidate discovery as well as ceremony + // creation. Other bindings rely on their token-endpoint client mode. + if (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal)) + { + if (string.IsNullOrEmpty(terminal.DpopJkt)) + return Forbidden("Staffing.BindingUnavailable", "The terminal has no enrolled device key."); + var proofHeader = context.Request.Headers[DpopConstants.HeaderName]; + if (proofHeader.Count != 1) + return Forbidden("Staffing.DpopRequired", "A DPoP proof is required."); + if (!TryGetPresentedAccessToken(context.Request, out var accessToken)) + return Forbidden("Staffing.InvalidToken", "The presented access token cannot be bound to the DPoP proof."); + var now = DateTimeOffset.UtcNow; + var htu = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; + var proof = DpopProofValidator.Validate( + proofHeader.ToString(), context.Request.Method, htu, now, accessToken); + if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal)) + return Forbidden("Staffing.DpopMismatch", "The DPoP proof key is not this terminal's enrolled key."); + if (!await RecordDpopProofAsync(replayStore, proof, now, ct)) + return Forbidden("Staffing.DpopReplay", "The DPoP proof has already been used."); + } + + ActivationBeginInput beginInput; try { - fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: terminal.WebAuthnRpId); + beginInput = context.Request.ContentLength is > 0 + ? await JsonSerializer.DeserializeAsync( + context.Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct) + ?? new ActivationBeginInput(null, null) + : new ActivationBeginInput(null, null); } - catch (RelyingPartyUnavailableException) + catch (JsonException) { - return Forbidden("Staffing.RelyingPartyUnavailable", "The terminal's relying party is not available."); + return Results.BadRequest(new + { + Error = "Staffing.InvalidBeginRequest", + Message = "The staffing begin request is not valid JSON." + }); } - var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams + var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var requiredProof = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None; + var requiredBinding = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None; + if (!PositionTerminalSecurity.BindingMeetsFloor(terminal.Binding, requiredBinding)) + return Forbidden("Staffing.BindingBelowRealmFloor", "The terminal binding does not meet the realm security floor."); + + Guid positionId; + if (isControlV2) { - AllowedCredentials = allowedCredentials, - UserVerification = UserVerificationRequirement.Preferred, - }); - var optionsJson = options.ToJson(); - - // Amortized cleanup of lapsed ceremonies (plan §5.3), then persist the - // pinned ceremony — the token request may re-choose NOTHING of this. - session.DeleteWhere(c => c.ExpiresAt < DateTimeOffset.UtcNow); - var ceremony = new StaffingCeremony + if (string.IsNullOrWhiteSpace(beginInput.PositionId)) + { + var candidates = new List(); + foreach (var allowedId in terminal.EffectiveAllowedPositionIds) + { + var candidate = await session.LoadAsync(allowedId, ct); + if (candidate is null || candidate.IsDeleted || !candidate.IsActive || + !candidate.TerminalPolicy.Enabled || + !candidate.TerminalPolicy.AllowedDeviceBindings.Contains(terminal.Binding, StringComparer.Ordinal)) + continue; + candidates.Add(candidate); + } + + var requestedMethod = beginInput.MethodId?.Trim(); + IActivationProof? candidateProof = null; + if (!string.IsNullOrWhiteSpace(requestedMethod)) + { + if (!PositionTerminalSecurity.ProofMeetsFloor(requestedMethod, requiredProof) || + !activationProofs.TryGet(requestedMethod, out candidateProof)) + return Forbidden("Staffing.ActivationProofUnavailable", + "The requested activation proof is not currently available."); + } + else + { + requestedMethod = candidates + .SelectMany(candidate => candidate.TerminalPolicy.AllowedActivationProofs) + .FirstOrDefault(method => + PositionTerminalSecurity.ProofMeetsFloor(method, requiredProof) && + activationProofs.TryGet(method, out _)); + if (requestedMethod is not null) + activationProofs.TryGet(requestedMethod, out candidateProof); + } + if (candidateProof is null || requestedMethod is null) + return Forbidden("Staffing.ActivationProofUnavailable", + "No allowed activation proof is currently available."); + + candidates = candidates + .Where(candidate => candidate.TerminalPolicy.AllowedActivationProofs + .Contains(requestedMethod, StringComparer.Ordinal)) + .ToList(); + if (candidates.Count == 0) + return Forbidden("Staffing.ActivationProofNotAllowed", + "The requested activation proof is not allowed on this terminal."); + + if (candidates.Count > 1) + { + // Proof first: the challenge contains the union of eligible + // credentials, but position names/ids stay server-side. The + // redeem identifies the actor/token and only then returns + // the intersected candidates when an actual choice remains. + var candidateChallenge = await candidateProof.BeginCandidatesAsync( + candidates, terminal, beginInput with { MethodId = requestedMethod }, ct); + if (candidateChallenge.Failure is { } candidateFailure) + return Forbidden(candidateFailure.Code, candidateFailure.Message); + return ChallengeResult(candidateProof, candidateChallenge); + } + positionId = candidates[0].Id; + } + else if (terminal.EffectiveAllowedPositionIds.Count > 1) + return Forbidden("Staffing.ProofRequiredBeforeSelection", + "A multi-position terminal can select a position only after activation proof verification."); + else if (!ShortGuid.TryParse(beginInput.PositionId, out positionId) || + !terminal.EffectiveAllowedPositionIds.Contains(positionId)) + return Forbidden("Staffing.PositionNotAllowed", "The selected position is not allowed on this terminal."); + } + else { - Id = Guid.NewGuid(), - PositionPrincipalId = position.Id, - TerminalEnrollmentId = terminal.Id, - ClientId = terminal.ClientId, - DpopJkt = terminal.DpopJkt!, - RpId = terminal.WebAuthnRpId, - OptionsJson = optionsJson, - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), - }; - session.Store(ceremony); - await session.SaveChangesAsync(ct); + positionId = subjectId; + if (!string.IsNullOrWhiteSpace(beginInput.PositionId) && + (!ShortGuid.TryParse(beginInput.PositionId, out Guid requestedId) || requestedId != positionId)) + return Forbidden("Staffing.PositionNotAllowed", "A legacy control token cannot select another position."); + } + + // §12.2 check 5 — selected position alive + compatible policy. + var position = await session.LoadAsync(positionId, ct); + if (position is null || position.IsDeleted || !position.IsActive || !position.TerminalPolicy.Enabled) + return Forbidden("Staffing.PositionUnavailable", "Terminal use is disabled for this position."); + + // Current policy and realm floors are checked at execution time as the + // fail-closed half of read-preserve/write-reject for open IDs. + if (!position.TerminalPolicy.AllowedDeviceBindings.Contains(terminal.Binding, StringComparer.Ordinal)) + return Forbidden("Staffing.BindingNotAllowed", "The terminal binding is no longer allowed by this position."); + IActivationProof? activationProof = null; + if (!string.IsNullOrWhiteSpace(beginInput.MethodId)) + { + var requestedMethod = beginInput.MethodId.Trim(); + if (!position.TerminalPolicy.AllowedActivationProofs.Contains(requestedMethod, StringComparer.Ordinal)) + return Forbidden("Staffing.ActivationProofNotAllowed", "The requested activation proof is not allowed by this position."); + if (!PositionTerminalSecurity.ProofMeetsFloor(requestedMethod, requiredProof) || + !activationProofs.TryGet(requestedMethod, out activationProof)) + return Forbidden("Staffing.ActivationProofUnavailable", "The requested activation proof is not currently available."); + } + else foreach (var methodId in position.TerminalPolicy.AllowedActivationProofs) + { + if (PositionTerminalSecurity.ProofMeetsFloor(methodId, requiredProof) && + activationProofs.TryGet(methodId, out activationProof)) + break; + activationProof = null; + } + if (activationProof is null) + return Forbidden("Staffing.ActivationProofUnavailable", "No allowed activation proof is currently available."); + + var challenge = await activationProof.BeginAsync( + new ActivationContext(position, terminal, BeginInput: beginInput), ct); + if (challenge.Failure is { } failure) + return Forbidden(failure.Code, failure.Message); + return ChallengeResult(activationProof, challenge); + } + private static IResult ChallengeResult(IActivationProof proof, ActivationChallenge challenge) + { + var ceremony = challenge.Ceremony!; // Options JSON must reach the authenticator verbatim (same rationale as // the native passkey begin) — no re-serialization. return Results.Content( - $"{{\"ceremonyId\":\"{ceremony.Id}\",\"publicKey\":{optionsJson}}}", + $"{{\"ceremonyId\":\"{ceremony.Id}\",\"methodId\":{JsonSerializer.Serialize(proof.MethodId)}," + + $"\"{challenge.ResponseProperty}\":{challenge.OptionsJson}}}", "application/json"); } private static IResult Forbidden(string error, string message) => Results.Json(new { Error = error, Message = message }, statusCode: StatusCodes.Status403Forbidden); + + private static bool TryGetPresentedAccessToken(HttpRequest request, out string accessToken) + { + accessToken = string.Empty; + var authorization = request.Headers.Authorization; + if (authorization.Count != 1) return false; + var raw = authorization.ToString(); + var separator = raw.IndexOf(' '); + if (separator <= 0 || separator == raw.Length - 1) return false; + if (!raw.AsSpan(0, separator).Equals("Bearer", StringComparison.OrdinalIgnoreCase) && + !raw.AsSpan(0, separator).Equals("DPoP", StringComparison.OrdinalIgnoreCase)) + return false; + accessToken = raw[(separator + 1)..].Trim(); + return accessToken.Length > 0; + } + + private static Task RecordDpopProofAsync( + IDpopReplayStore replayStore, + DpopValidationResult proof, + DateTimeOffset now, + CancellationToken ct) + { + var expiresAt = proof.IssuedAt!.Value + + DpopProofValidator.DefaultMaxAge + + DpopProofValidator.DefaultClockSkew; + return replayStore.TryRecordAsync(proof.Jti!, expiresAt, now, ct); + } } + +public sealed record StepUpBeginInput( + string? MethodId, + string? AccountName, + string? Action, + string? Nonce); diff --git a/src/dotnet/Modgud.Api/Features/Positions/ActivationTokenEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/ActivationTokenEndpoints.cs new file mode 100644 index 00000000..09b4b674 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Positions/ActivationTokenEndpoints.cs @@ -0,0 +1,453 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using BuildingBlocks.Helper; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Marten; +using Microsoft.AspNetCore.Authorization; +using Modgud.Authentication.Domain; +using Modgud.Authentication.Identity; +using Modgud.Authorization.AspNetCore; +using Modgud.Authorization.Principals; +using Modgud.Domain.PositionTerminals; +using Modgud.Infrastructure.OpenIddict.Dpop; +using Modgud.Infrastructure.PositionTerminals; +using OpenIddict.Abstractions; +using OpenIddict.Validation.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Modgud.Api.Features.Positions; + +/// Lifecycle and RP-bound registration of position-owned WebAuthn +/// activation tokens (F2). Administration assigns the logical token; an +/// enrolled terminal performs attestation under its application's RP origin. +public static class ActivationTokenEndpoints +{ + public static WebApplication MapActivationTokenEndpoints(this WebApplication app) + { + var admin = app.MapGroup("/api") + .WithTags("Position Activation Tokens") + .RequireAuthorization(); + + admin.MapGet("position/{positionId}/activation-tokens", ListAsync) + .WithName("V2_PositionActivationTokens_List") + .RequiresPermission("position:read"); + admin.MapPost("position/{positionId}/activation-tokens", CreateAsync) + .WithName("V2_PositionActivationTokens_Create") + .RequiresPermission("position:write"); + admin.MapPost("position/{positionId}/activation-tokens/{tokenId}/assign", AssignAsync) + .WithName("V2_PositionActivationTokens_Assign") + .RequiresPermission("position:write"); + admin.MapDelete("position/{positionId}/activation-tokens/{tokenId}", UnassignAsync) + .WithName("V2_PositionActivationTokens_Unassign") + .RequiresPermission("position:write"); + admin.MapPost("activation-token/{tokenId}/disable", DisableAsync) + .WithName("V2_ActivationTokens_Disable") + .RequiresPermission("position:write"); + admin.MapPost("activation-token/{tokenId}/reactivate", ReactivateAsync) + .WithName("V2_ActivationTokens_Reactivate") + .RequiresPermission("position:write"); + admin.MapPost("activation-token/{tokenId}/revoke", RevokeAsync) + .WithName("V2_ActivationTokens_Revoke") + .RequiresPermission("position:write"); + + app.MapPost("/connect/activation-token/{tokenId}/register/begin", RegistrationBeginAsync) + .WithName("ActivationToken_RegisterBegin") + .WithTags("Position Staffing") + .DisableAntiforgery() + .RequireAuthorization(new AuthorizeAttribute + { + AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, + }) + .RequireRateLimiting("passkey-begin"); + app.MapPost("/connect/activation-token/{tokenId}/register", RegistrationCompleteAsync) + .WithName("ActivationToken_Register") + .WithTags("Position Staffing") + .DisableAntiforgery() + .RequireAuthorization(new AuthorizeAttribute + { + AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, + }) + .RequireRateLimiting("passkey-begin"); + + return app; + } + + private static async Task ListAsync( + ShortGuid positionId, AppSettings settings, IDocumentSession session, CancellationToken ct) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + if (await session.LoadAsync(positionId.Guid, ct) is not { IsDeleted: false }) + return Results.NotFound(); + var tokens = (await session.Query().ToListAsync(ct)) + .Where(t => t.AssignedPositionIds.Contains(positionId.Guid)) + .OrderBy(t => t.Label) + .ToList(); + return Results.Ok(await ToDtosAsync(tokens, session, ct)); + } + + private static async Task CreateAsync( + ShortGuid positionId, + ActivationTokenCreateDto dto, + AppSettings settings, + IDocumentSession session, + HttpContext context, + CancellationToken ct) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + if (await session.LoadAsync(positionId.Guid, ct) is not { IsDeleted: false }) + return Results.NotFound(); + var label = dto.Label?.Trim(); + if (string.IsNullOrWhiteSpace(label)) + return Results.BadRequest(new { Error = "ActivationToken.LabelRequired", Message = "A label is required." }); + + var token = new ActivationToken + { + Id = Guid.CreateVersion7(), + Label = label, + AssignedPositionIds = [positionId.Guid], + CreatedByUserId = PositionGrantsEndpoints.RequireActor(context), + CreatedAt = DateTimeOffset.UtcNow, + }; + session.Store(token); + await session.SaveChangesAsync(ct); + return Results.Ok((await ToDtosAsync([token], session, ct))[0]); + } + + private static Task AssignAsync( + ShortGuid positionId, ShortGuid tokenId, AppSettings settings, + IDocumentSession session, CancellationToken ct) => + ChangeAssignmentAsync(positionId.Guid, tokenId.Guid, assign: true, settings, session, null, ct); + + private static Task UnassignAsync( + ShortGuid positionId, ShortGuid tokenId, AppSettings settings, + IDocumentSession session, IStaffingRevoker revoker, CancellationToken ct) => + ChangeAssignmentAsync(positionId.Guid, tokenId.Guid, assign: false, settings, session, revoker, ct); + + private static async Task ChangeAssignmentAsync( + Guid positionId, Guid tokenId, bool assign, AppSettings settings, + IDocumentSession session, IStaffingRevoker? revoker, CancellationToken ct) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + if (await session.LoadAsync(positionId, ct) is not { IsDeleted: false }) + return Results.NotFound(); + var token = await session.LoadAsync(tokenId, ct); + if (token is null || token.Status == ActivationTokenStatus.Revoked) return Results.NotFound(); + + if (assign) + { + if (!token.AssignedPositionIds.Contains(positionId)) token.AssignedPositionIds.Add(positionId); + } + else + { + token.AssignedPositionIds.Remove(positionId); + } + session.Store(token); + await session.SaveChangesAsync(ct); + if (!assign && revoker is not null) + await revoker.EndAllForActivationTokenAndPositionAsync( + token.Id, positionId, StaffingSessionEndReason.ActivationTokenUnassigned, ct); + return Results.Ok((await ToDtosAsync([token], session, ct))[0]); + } + + private static Task DisableAsync( + ShortGuid tokenId, AppSettings settings, IDocumentSession session, + IStaffingRevoker revoker, CancellationToken ct) => + ChangeStatusAsync(tokenId.Guid, ActivationTokenStatus.Disabled, settings, session, revoker, ct); + + private static Task ReactivateAsync( + ShortGuid tokenId, AppSettings settings, IDocumentSession session, + IStaffingRevoker revoker, CancellationToken ct) => + ChangeStatusAsync(tokenId.Guid, ActivationTokenStatus.Active, settings, session, revoker, ct); + + private static async Task RevokeAsync( + ShortGuid tokenId, AppSettings settings, IDocumentSession session, + IStaffingRevoker revoker, HttpContext context, CancellationToken ct) + { + // Keep the feature-off contract side-effect free. ChangeStatusAsync + // also guards the flag, but RevokeAsync writes the actor/timestamp in + // a second step and must not perform that write after a 404 result. + if (!settings.Features.PositionTerminals) return Results.NotFound(); + + var result = await ChangeStatusAsync( + tokenId.Guid, ActivationTokenStatus.Revoked, settings, session, revoker, ct); + if (await session.LoadAsync(tokenId.Guid, ct) is { } token && token.RevokedAt is null) + { + token.RevokedAt = DateTimeOffset.UtcNow; + token.RevokedByUserId = PositionGrantsEndpoints.RequireActor(context); + session.Store(token); + await session.SaveChangesAsync(ct); + } + return result; + } + + private static async Task ChangeStatusAsync( + Guid tokenId, ActivationTokenStatus status, AppSettings settings, + IDocumentSession session, IStaffingRevoker revoker, CancellationToken ct) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + var token = await session.LoadAsync(tokenId, ct); + if (token is null) return Results.NotFound(); + if (token.Status == ActivationTokenStatus.Revoked && status != ActivationTokenStatus.Revoked) + return Results.BadRequest(new { Error = "ActivationToken.Revoked", Message = "A revoked token cannot be reactivated." }); + if (status == ActivationTokenStatus.Active) + { + var hasCredential = await session.Query() + .AnyAsync(c => c.ActivationTokenId == token.Id, ct); + if (!hasCredential) + return Results.BadRequest(new { Error = "ActivationToken.NotRegistered", Message = "Register a credential before reactivating the token." }); + } + if (token.Status != status) + { + token.Status = status; + session.Store(token); + await session.SaveChangesAsync(ct); + if (status is ActivationTokenStatus.Disabled or ActivationTokenStatus.Revoked) + await revoker.EndAllForActivationTokenAsync( + token.Id, StaffingSessionEndReason.ActivationTokenRevoked, ct); + } + return Results.Ok((await ToDtosAsync([token], session, ct))[0]); + } + + private static async Task RegistrationBeginAsync( + ShortGuid tokenId, + HttpContext context, + AppSettings settings, + IDocumentSession session, + RealmScopedFido2Factory fido2Factory, + IDpopReplayStore replayStore, + CancellationToken ct) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + var target = await ResolveTerminalAsync(context, session, replayStore, ct); + if (target.Error is not null) return target.Error; + var token = await session.LoadAsync(tokenId.Guid, ct); + if (token is null || token.Status is ActivationTokenStatus.Revoked or ActivationTokenStatus.Disabled || + !token.AssignedPositionIds.Intersect(target.Terminal!.EffectiveAllowedPositionIds).Any()) + return Forbidden("ActivationToken.Unavailable", "The activation token is not assigned to this position."); + + IFido2 fido2; + try { fido2 = await fido2Factory.CreateAsync(ct, rpIdOverride: target.Terminal!.WebAuthnRpId); } + catch (RelyingPartyUnavailableException) + { + return Forbidden("ActivationToken.RelyingPartyUnavailable", "The terminal's relying party is unavailable."); + } + + var existing = await session.Query() + .Where(c => c.ActivationTokenId == token.Id && c.RpId == target.Terminal.WebAuthnRpId) + .ToListAsync(ct); + var fidoUser = new Fido2User + { + Id = Encoding.UTF8.GetBytes(token.Id.ToString()), + Name = $"position-token-{new ShortGuid(token.Id)}", + DisplayName = token.Label, + }; + var options = fido2.RequestNewCredential(new RequestNewCredentialParams + { + User = fidoUser, + ExcludeCredentials = existing.Select(c => new PublicKeyCredentialDescriptor(c.CredentialId)).ToList(), + AuthenticatorSelection = new AuthenticatorSelection + { + ResidentKey = ResidentKeyRequirement.Discouraged, + UserVerification = UserVerificationRequirement.Preferred, + }, + AttestationPreference = AttestationConveyancePreference.None, + }); + var now = DateTimeOffset.UtcNow; + session.DeleteWhere(c => c.ExpiresAt < now); + var ceremony = new ActivationTokenRegistrationCeremony + { + Id = Guid.NewGuid(), + ActivationTokenId = token.Id, + TerminalEnrollmentId = target.Terminal.Id, + ClientId = target.Terminal.ClientId, + RpId = target.Terminal.WebAuthnRpId, + OptionsJson = options.ToJson(), + CreatedAt = now, + ExpiresAt = now.AddMinutes(5), + }; + session.Store(ceremony); + await session.SaveChangesAsync(ct); + return Results.Content( + $"{{\"ceremonyId\":\"{ceremony.Id}\",\"options\":{ceremony.OptionsJson}}}", "application/json"); + } + + private static async Task RegistrationCompleteAsync( + ShortGuid tokenId, + JsonElement body, + HttpContext context, + AppSettings settings, + IDocumentSession session, + RealmScopedFido2Factory fido2Factory, + IDpopReplayStore replayStore, + CancellationToken ct) + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + var target = await ResolveTerminalAsync(context, session, replayStore, ct); + if (target.Error is not null) return target.Error; + if (!body.TryGetProperty("ceremonyId", out var idElement) || + !Guid.TryParse(idElement.GetString(), out var ceremonyId) || + !body.TryGetProperty("attestation", out var attestationElement)) + return Results.BadRequest(new { Error = "ActivationToken.InvalidRegistration", Message = "Invalid registration request." }); + + var ceremony = await session.LoadAsync(ceremonyId, ct); + if (ceremony is null || ceremony.IsExpired || ceremony.ActivationTokenId != tokenId.Guid || + ceremony.TerminalEnrollmentId != target.Terminal!.Id || + !string.Equals(ceremony.ClientId, target.Terminal.ClientId, StringComparison.Ordinal)) + return Results.BadRequest(new { Error = "ActivationToken.RegistrationExpired", Message = "Registration expired." }); + session.Delete(ceremony); + await session.SaveChangesAsync(ct); + + var token = await session.LoadAsync(tokenId.Guid, ct); + if (token is null || token.Status is ActivationTokenStatus.Revoked or ActivationTokenStatus.Disabled || + !token.AssignedPositionIds.Intersect(target.Terminal!.EffectiveAllowedPositionIds).Any()) + return Forbidden("ActivationToken.Unavailable", "The activation token is not assigned to this position."); + + AuthenticatorAttestationRawResponse? attestation; + try + { + attestation = JsonSerializer.Deserialize( + attestationElement.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) { attestation = null; } + if (attestation is null) + return Results.BadRequest(new { Error = "ActivationToken.AttestationFailed", Message = "Registration failed." }); + + IFido2 fido2; + try + { + var origin = RealmFido2.TryGetClientDataOrigin(attestation.Response?.ClientDataJson); + fido2 = await fido2Factory.CreateAsync( + ct, rpIdOverride: ceremony.RpId, additionalOrigins: origin is null ? null : [origin]); + } + catch (RelyingPartyUnavailableException) + { + return Results.BadRequest(new { Error = "ActivationToken.AttestationFailed", Message = "Registration failed." }); + } + + RegisteredPublicKeyCredential created; + try + { + var options = CredentialCreateOptions.FromJson(ceremony.OptionsJson); + created = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = attestation, + OriginalOptions = options, + IsCredentialIdUniqueToUserCallback = async (args, innerCt) => + { + var positionCredentials = await session.Query().ToListAsync(innerCt); + var personCredentials = await session.Query().ToListAsync(innerCt); + return !positionCredentials.Any(c => c.CredentialId.SequenceEqual(args.CredentialId)) && + !personCredentials.Any(c => c.CredentialId.SequenceEqual(args.CredentialId)); + }, + }, ct); + } + catch + { + return Results.BadRequest(new { Error = "ActivationToken.AttestationFailed", Message = "Registration failed." }); + } + + var credential = new ActivationTokenCredential + { + Id = Guid.CreateVersion7(), + ActivationTokenId = token.Id, + CredentialId = created.Id, + PublicKey = created.PublicKey, + UserHandle = created.User.Id, + SignatureCount = created.SignCount, + AaGuid = created.AaGuid, + RpId = ceremony.RpId, + CreatedAt = DateTimeOffset.UtcNow, + }; + token.Status = ActivationTokenStatus.Active; + session.Store(credential); + session.Store(token); + await session.SaveChangesAsync(ct); + return Results.Ok(new { CredentialId = new ShortGuid(credential.Id).ToString(), credential.RpId }); + } + + private static async Task ResolveTerminalAsync( + HttpContext context, IDocumentSession session, IDpopReplayStore replayStore, CancellationToken ct) + { + var principal = context.User; + if (!string.Equals(principal.GetClaim(PositionTokenClaimTypes.TokenUse), + PositionTokenUses.TerminalEnrollment, StringComparison.Ordinal) || + !principal.GetAudiences().Contains(PositionTerminalControl.Audience) || + !Guid.TryParse(principal.GetClaim(PositionTokenClaimTypes.TerminalId), out var terminalId)) + return new(null, null, Forbidden("ActivationToken.InvalidToken", "A terminal enrollment token is required.")); + + var terminal = await session.LoadAsync(terminalId, ct); + PositionPrincipal? position = null; + if (terminal is not null) + { + foreach (var positionId in terminal.EffectiveAllowedPositionIds) + { + var candidate = await session.LoadAsync(positionId, ct); + if (candidate is { IsDeleted: false, IsActive: true } && candidate.TerminalPolicy.Enabled) + { + position = candidate; + break; + } + } + } + if (terminal is null || position is null || position.IsDeleted || + terminal.Status != TerminalEnrollmentStatus.Active || !position.TerminalPolicy.Enabled) + return new(null, null, Forbidden("ActivationToken.TerminalUnavailable", "The terminal is not active.")); + + if (string.Equals(terminal.Binding, DeviceBindingIds.Dpop, StringComparison.Ordinal)) + { + var header = context.Request.Headers[DpopConstants.HeaderName]; + var authorization = context.Request.Headers.Authorization.ToString(); + var separator = authorization.IndexOf(' '); + if (header.Count != 1 || separator <= 0 || separator == authorization.Length - 1) + return new(null, null, Forbidden("ActivationToken.DpopRequired", "A DPoP proof is required.")); + var accessToken = authorization[(separator + 1)..].Trim(); + var now = DateTimeOffset.UtcNow; + var htu = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"; + var proof = DpopProofValidator.Validate(header.ToString(), context.Request.Method, htu, now, accessToken); + if (!proof.IsValid || !string.Equals(proof.Jkt, terminal.DpopJkt, StringComparison.Ordinal)) + return new(null, null, Forbidden("ActivationToken.DpopMismatch", "The DPoP proof key does not match this terminal.")); + var expires = proof.IssuedAt!.Value + DpopProofValidator.DefaultMaxAge + DpopProofValidator.DefaultClockSkew; + if (!await replayStore.TryRecordAsync(proof.Jti!, expires, now, ct)) + return new(null, null, Forbidden("ActivationToken.DpopReplay", "The DPoP proof has already been used.")); + } + return new(terminal, position, null); + } + + private static async Task> ToDtosAsync( + IReadOnlyList tokens, IDocumentSession session, CancellationToken ct) + { + var credentials = await session.Query().ToListAsync(ct); + return tokens.Select(t => new ActivationTokenDto + { + Id = new ShortGuid(t.Id).ToString(), + Label = t.Label, + Status = t.Status, + AssignedPositionIds = t.AssignedPositionIds.Select(ShortGuid.Encode).ToArray(), + RegisteredRpIds = credentials.Where(c => c.ActivationTokenId == t.Id) + .Select(c => c.RpId).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToArray(), + CreatedAt = t.CreatedAt, + }).ToList(); + } + + private static IResult Forbidden(string error, string message) => + Results.Json(new { Error = error, Message = message }, statusCode: StatusCodes.Status403Forbidden); + + private sealed record TerminalTarget(TerminalEnrollment? Terminal, PositionPrincipal? Position, IResult? Error); +} + +public sealed class ActivationTokenCreateDto +{ + public string? Label { get; set; } +} + +public sealed class ActivationTokenDto +{ + public required string Id { get; init; } + public required string Label { get; init; } + public ActivationTokenStatus Status { get; init; } + public IReadOnlyList AssignedPositionIds { get; init; } = []; + public IReadOnlyList RegisteredRpIds { get; init; } = []; + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs index 041b6a16..5b8ffcdc 100644 --- a/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionTerminalsEndpoints.cs @@ -8,6 +8,7 @@ using Modgud.Infrastructure.PositionTerminals; using Modgud.Infrastructure.OpenIddict; using Marten; +using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Api.Features.Positions; @@ -36,10 +37,9 @@ public static WebApplication MapPositionTerminalsEndpoints(this WebApplication a if (await LoadPositionAsync(settings, session, positionId.Guid, ct) is not { } _) return Results.NotFound(); - var terminals = await session.Query() - .Where(x => x.PositionPrincipalId == positionId.Guid) - .OrderBy(x => x.DisplayName) - .ToListAsync(ct); + var terminals = (await session.Query().ToListAsync(ct)) + .Where(x => x.EffectiveAllowedPositionIds.Contains(positionId.Guid)) + .OrderBy(x => x.DisplayName); return Results.Ok(terminals.Select(ToDto)); }) .WithName("V2_PositionTerminals_List") @@ -61,25 +61,70 @@ public static WebApplication MapPositionTerminalsEndpoints(this WebApplication a // Plan §4.1 — slots exist only while the position is opted into // terminal use. if (!fn.TerminalPolicy.Enabled) - return Results.BadRequest(new { Error = "Terminal.TerminalPolicyDisabled", - Message = "Enable terminal use on the position before creating terminal slots." }); + return Results.BadRequest(new + { + Error = "Terminal.TerminalPolicyDisabled", + Message = "Enable terminal use on the position before creating terminal slots." + }); var displayName = dto.DisplayName?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(displayName)) - return Results.BadRequest(new { Error = "Terminal.DisplayNameRequired", - Message = "A display name is required." }); + return Results.BadRequest(new + { + Error = "Terminal.DisplayNameRequired", + Message = "A display name is required." + }); + + var binding = string.IsNullOrWhiteSpace(dto.Binding) ? DeviceBindingIds.Dpop : dto.Binding; + if (!PositionTerminalSecurity.TryGetWritableBinding(binding, out _)) + return Results.BadRequest(new + { + Error = "Terminal.UnknownDeviceBinding", + Message = $"Device binding '{binding}' is unknown or unavailable." + }); + if (!fn.TerminalPolicy.AllowedDeviceBindings.Contains(binding, StringComparer.Ordinal)) + return Results.BadRequest(new + { + Error = "Terminal.DeviceBindingNotAllowed", + Message = $"Device binding '{binding}' is not allowed by the position policy." + }); + var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var requiredBinding = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None; + if (!PositionTerminalSecurity.BindingMeetsFloor(binding, requiredBinding)) + return Results.BadRequest(new + { + Error = "Terminal.DeviceBindingBelowRealmFloor", + Message = $"Device binding '{binding}' does not meet the realm capability floor." + }); + + var allowedPositionIds = new HashSet { positionId.Guid }; + foreach (var rawId in dto.AllowedPositionIds ?? []) + { + if (!ShortGuid.TryParse(rawId, out Guid allowedId)) + return Results.BadRequest(new { Error = "Terminal.InvalidPositionId", Message = $"Position id '{rawId}' is invalid." }); + allowedPositionIds.Add(allowedId); + } + foreach (var allowedId in allowedPositionIds) + { + var allowed = await session.LoadAsync(allowedId, ct); + if (allowed is null || allowed.IsDeleted || !allowed.IsActive || !allowed.TerminalPolicy.Enabled) + return Results.BadRequest(new { Error = "Terminal.PositionUnavailable", Message = $"Position '{ShortGuid.Encode(allowedId)}' is unavailable for terminal use." }); + if (!allowed.TerminalPolicy.AllowedDeviceBindings.Contains(binding, StringComparer.Ordinal) || + !PositionTerminalSecurity.BindingMeetsFloor(binding, requiredBinding)) + return Results.BadRequest(new { Error = "Terminal.DeviceBindingNotAllowed", Message = $"Device binding '{binding}' is not allowed for every assigned position." }); + } var enrollmentId = Guid.NewGuid(); var applicationId = Guid.NewGuid(); // Same convention as SA credentials: {owner}.{kind}.{8-char id} — // unique, and the audit log reads the owning position off it. - var clientId = $"{fn.AccountName}.terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; + var clientId = $"terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; // Stage the terminal-managed client (validated against the fixed // profile) ... var clientError = oauth.StageCreateTerminalClient( applicationId, clientId, $"{fn.DisplayName} — {displayName}", - positionId.Guid, enrollmentId, dto.WebAuthnRpId); + positionId.Guid, enrollmentId, dto.WebAuthnRpId, binding, out var clientSecret); if (clientError is not null) return Results.BadRequest(new { Error = clientError.Value.Code, Message = clientError.Value.Description }); @@ -89,10 +134,12 @@ public static WebApplication MapPositionTerminalsEndpoints(this WebApplication a enrollmentId, positionId.Guid, displayName, string.IsNullOrWhiteSpace(dto.Location) ? null : dto.Location.Trim(), applicationId, clientId, dto.WebAuthnRpId.Trim().ToLowerInvariant(), - PositionGrantsEndpoints.RequireActor(httpContext), DateTimeOffset.UtcNow)); + PositionGrantsEndpoints.RequireActor(httpContext), DateTimeOffset.UtcNow, binding, + allowedPositionIds.ToArray())); await session.SaveChangesAsync(ct); var created = await LoadDtoAsync(session, enrollmentId, ct); + created.ClientSecret = clientSecret; dispatcher.DispatchCreatedEvent("Terminal", created, session.TenantId); return Results.Ok(created); }) @@ -113,8 +160,11 @@ public static WebApplication MapPositionTerminalsEndpoints(this WebApplication a var displayName = dto.DisplayName?.Trim(); if (displayName is not null && displayName.Length == 0) - return Results.BadRequest(new { Error = "Terminal.DisplayNameRequired", - Message = "A display name is required." }); + return Results.BadRequest(new + { + Error = "Terminal.DisplayNameRequired", + Message = "A display name is required." + }); session.Events.Append(terminal.Id, new TerminalEnrollmentDetailsChanged( terminal.Id, @@ -131,6 +181,67 @@ dto.Location is null .WithName("V2_PositionTerminals_Update") .RequiresPermission("position:write"); + group.MapPut("{terminalId}/positions", async ( + ShortGuid positionId, + ShortGuid terminalId, + TerminalAllowedPositionsUpdateDto dto, + AppSettings settings, + IDocumentSession session, + IStaffingRevoker staffingRevoker, + HttpContext context, + CancellationToken ct) => + { + if (await LoadTerminalAsync(settings, session, positionId.Guid, terminalId.Guid, ct) is not { } terminal) + return Results.NotFound(); + + var ids = new HashSet(); + foreach (var rawId in dto.AllowedPositionIds) + { + if (!ShortGuid.TryParse(rawId, out Guid id)) + return Results.BadRequest(new { Error = "Terminal.InvalidPositionId", Message = $"Position id '{rawId}' is invalid." }); + ids.Add(id); + } + if (ids.Count == 0) + return Results.BadRequest(new { Error = "Terminal.PositionRequired", Message = "A terminal must allow at least one position." }); + + var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var requiredBinding = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None; + foreach (var id in ids) + { + var position = await session.LoadAsync(id, ct); + if (position is null || position.IsDeleted || !position.IsActive || !position.TerminalPolicy.Enabled || + !position.TerminalPolicy.AllowedDeviceBindings.Contains(terminal.Binding, StringComparer.Ordinal) || + !PositionTerminalSecurity.BindingMeetsFloor(terminal.Binding, requiredBinding)) + return Results.BadRequest(new { Error = "Terminal.PositionUnavailable", Message = $"Position '{ShortGuid.Encode(id)}' is not compatible with this terminal." }); + } + + var previous = terminal.EffectiveAllowedPositionIds.ToHashSet(); + if (terminal.EnrollmentAuthorizationId is not null && ids.Except(previous).Any()) + return Results.Conflict(new + { + Error = "Terminal.ReenrollmentRequired", + Message = "Adding a position to an enrolled terminal requires a fresh multi-position slot and enrollment." + }); + + session.Events.Append(terminal.Id, new TerminalAllowedPositionsChanged( + terminal.Id, ids.ToArray(), PositionGrantsEndpoints.RequireActor(context), DateTimeOffset.UtcNow)); + await session.SaveChangesAsync(ct); + + foreach (var removedPositionId in previous.Except(ids)) + { + var active = await session.Query() + .Where(s => s.TerminalEnrollmentId == terminal.Id && + s.PositionPrincipalId == removedPositionId && + s.Status == StaffingSessionStatus.Active) + .ToListAsync(ct); + foreach (var staffing in active) + await staffingRevoker.EndSessionAsync(staffing.Id, StaffingSessionEndReason.PolicyTightened, ct); + } + return Results.Ok(await LoadDtoAsync(session, terminal.Id, ct)); + }) + .WithName("V2_PositionTerminals_SetPositions") + .RequiresPermission("position:write"); + group.MapPost("{terminalId}/disable", (ShortGuid positionId, ShortGuid terminalId, AppSettings settings, IDocumentSession session, OAuthAdminService oauth, DataEventDispatcher dispatcher, IOAuthGrantRevoker revoker, IStaffingRevoker staffingRevoker, Wolverine.IMessageBus bus, @@ -185,8 +296,11 @@ private static async Task TransitionAsync( { return targetStatus == TerminalEnrollmentStatus.Revoked ? Results.Ok(ToDto(terminal)) // idempotent no-op - : Results.Conflict(new { Error = "Terminal.Revoked", - Message = "A revoked terminal cannot change state; create a new slot instead." }); + : Results.Conflict(new + { + Error = "Terminal.Revoked", + Message = "A revoked terminal cannot change state; create a new slot instead." + }); } if (targetStatus == TerminalEnrollmentStatus.Disabled) @@ -252,7 +366,7 @@ await bus.PublishAsync(new Modgud.Domain.PositionTerminals.Contracts.V1.Position { if (await LoadPositionAsync(settings, session, positionId, ct) is null) return null; var terminal = await session.LoadAsync(terminalId, ct); - return terminal is null || terminal.PositionPrincipalId != positionId ? null : terminal; + return terminal is null || !terminal.EffectiveAllowedPositionIds.Contains(positionId) ? null : terminal; } internal static async Task LoadDtoAsync(IDocumentSession session, Guid terminalId, CancellationToken ct) @@ -262,12 +376,14 @@ internal static async Task LoadDtoAsync(IDocumentSession session, G { Id = new ShortGuid(t.Id).ToString(), PositionId = new ShortGuid(t.PositionPrincipalId).ToString(), + AllowedPositionIds = t.EffectiveAllowedPositionIds.Select(ShortGuid.Encode).ToArray(), DisplayName = t.DisplayName, Location = t.Location, ClientId = t.ClientId, WebAuthnRpId = t.WebAuthnRpId, + Binding = t.Binding, Status = t.Status, - Enrolled = t.DpopJkt is not null, + Enrolled = t.EnrollmentAuthorizationId is not null, CreatedAt = t.CreatedAt, EnrolledAt = t.EnrolledAt, DisabledAt = t.DisabledAt, diff --git a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs index 6eb9b0fc..11938c1b 100644 --- a/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Positions/PositionsEndpoints.cs @@ -11,6 +11,7 @@ using Modgud.Infrastructure.PositionTerminals; using Modgud.Infrastructure.OpenIddict; using Marten; +using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Api.Features.Positions; @@ -78,6 +79,8 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati var policy = ApplyPolicy(PositionTerminalPolicy.Disabled, dto.TerminalPolicy, out var policyError); if (policyError is not null) return policyError; + if (await ValidatePolicyAgainstRealmFloorAsync(session, policy, ct) is { } floorError) + return floorError; // Staged terminal slots — same up-front validation as the grants // below. Plan §4.1 still holds: slots exist only while the @@ -85,11 +88,51 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati // to enable it in this very save. var stagedTerminals = dto.Terminals ?? []; if (stagedTerminals.Count > 0 && !policy.Enabled) - return Results.BadRequest(new { Error = "Terminal.TerminalPolicyDisabled", - Message = "Enable terminal use on the position before adding terminal slots." }); + return Results.BadRequest(new + { + Error = "Terminal.TerminalPolicyDisabled", + Message = "Enable terminal use on the position before adding terminal slots." + }); if (stagedTerminals.Any(t => string.IsNullOrWhiteSpace(t.DisplayName))) - return Results.BadRequest(new { Error = "Terminal.DisplayNameRequired", - Message = "A display name is required." }); + return Results.BadRequest(new + { + Error = "Terminal.DisplayNameRequired", + Message = "A display name is required." + }); + var stagedAllowedPositionIds = new List(); + foreach (var terminal in stagedTerminals) + { + var binding = string.IsNullOrWhiteSpace(terminal.Binding) + ? DeviceBindingIds.Dpop + : terminal.Binding; + if (!PositionTerminalSecurity.TryGetWritableBinding(binding, out _)) + return Results.BadRequest(new + { + Error = "Terminal.UnknownDeviceBinding", + Message = $"Device binding '{binding}' is unknown or unavailable." + }); + if (!policy.AllowedDeviceBindings.Contains(binding, StringComparer.Ordinal)) + return Results.BadRequest(new + { + Error = "Terminal.DeviceBindingNotAllowed", + Message = $"Device binding '{binding}' is not allowed by the position policy." + }); + var allowedIds = new HashSet(); + foreach (var rawId in terminal.AllowedPositionIds ?? []) + { + if (!ShortGuid.TryParse(rawId, out Guid allowedId)) + return Results.BadRequest(new { Error = "Terminal.InvalidPositionId", Message = $"Position id '{rawId}' is invalid." }); + allowedIds.Add(allowedId); + } + foreach (var allowedId in allowedIds) + { + var allowed = await session.LoadAsync(allowedId, ct); + if (allowed is null || allowed.IsDeleted || !allowed.IsActive || !allowed.TerminalPolicy.Enabled || + !allowed.TerminalPolicy.AllowedDeviceBindings.Contains(binding, StringComparer.Ordinal)) + return Results.BadRequest(new { Error = "Terminal.PositionUnavailable", Message = $"Position '{ShortGuid.Encode(allowedId)}' is not compatible with this terminal." }); + } + stagedAllowedPositionIds.Add(allowedIds.ToArray()); + } // Staged grants (rule 5: the entity is creatable completely) — // resolve and validate EVERY user before creating anything, so a @@ -99,16 +142,25 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati foreach (var rawUserId in dto.GrantUserIds?.Distinct() ?? []) { if (!ShortGuid.TryParse(rawUserId, out Guid grantUserId)) - return Results.BadRequest(new { Error = "PositionGrant.InvalidUserId", - Message = $"Grant user id '{rawUserId}' is invalid." }); + return Results.BadRequest(new + { + Error = "PositionGrant.InvalidUserId", + Message = $"Grant user id '{rawUserId}' is invalid." + }); var person = await session.LoadAsync(grantUserId, ct); if (person is null || person.IsDeleted) - return Results.BadRequest(new { Error = "PositionGrant.UserNotFound", - Message = $"Grant user '{rawUserId}' does not exist." }); + return Results.BadRequest(new + { + Error = "PositionGrant.UserNotFound", + Message = $"Grant user '{rawUserId}' does not exist." + }); if (!person.IsActive) - return Results.BadRequest(new { Error = "PositionGrant.UserInactive", - Message = $"Grant user '{rawUserId}' is inactive." }); + return Results.BadRequest(new + { + Error = "PositionGrant.UserInactive", + Message = $"Grant user '{rawUserId}' is inactive." + }); grantUserIds.Add(grantUserId); } @@ -142,16 +194,19 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati // that same unit of work (mirrors the service-account initial // credential). A rejected slot returns before SaveChanges, so // the whole create — position, grants, slots — never happened. - var terminalIds = new List(); - foreach (var terminal in stagedTerminals) + var terminalIds = new List<(Guid Id, string? ClientSecret)>(); + for (var terminalIndex = 0; terminalIndex < stagedTerminals.Count; terminalIndex++) { + var terminal = stagedTerminals[terminalIndex]; var enrollmentId = Guid.NewGuid(); var applicationId = Guid.NewGuid(); - var clientId = $"{fn.AccountName}.terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; + var clientId = $"terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; var clientError = oauth.StageCreateTerminalClient( applicationId, clientId, $"{fn.DisplayName} — {terminal.DisplayName.Trim()}", - fn.Id, enrollmentId, terminal.WebAuthnRpId); + fn.Id, enrollmentId, terminal.WebAuthnRpId, + string.IsNullOrWhiteSpace(terminal.Binding) ? DeviceBindingIds.Dpop : terminal.Binding, + out var clientSecret); if (clientError is not null) return Results.BadRequest(new { Error = clientError.Value.Code, Message = clientError.Value.Description }); @@ -159,17 +214,27 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati enrollmentId, fn.Id, terminal.DisplayName.Trim(), string.IsNullOrWhiteSpace(terminal.Location) ? null : terminal.Location.Trim(), applicationId, clientId, terminal.WebAuthnRpId.Trim().ToLowerInvariant(), - actor, now)); - terminalIds.Add(enrollmentId); + actor, now, string.IsNullOrWhiteSpace(terminal.Binding) + ? DeviceBindingIds.Dpop + : terminal.Binding, + [fn.Id, .. stagedAllowedPositionIds[terminalIndex].Where(id => id != fn.Id)])); + terminalIds.Add((enrollmentId, clientSecret)); } await session.SaveChangesAsync(ct); var created = ToDto(fn); dispatcher.DispatchCreatedEvent("Position", created, session.TenantId); - foreach (var terminalId in terminalIds) + var createdTerminals = new List(); + foreach (var terminal in terminalIds) + { dispatcher.DispatchCreatedEvent("Terminal", - await PositionTerminalsEndpoints.LoadDtoAsync(session, terminalId, ct), session.TenantId); + await PositionTerminalsEndpoints.LoadDtoAsync(session, terminal.Id, ct), session.TenantId); + var terminalDto = await PositionTerminalsEndpoints.LoadDtoAsync(session, terminal.Id, ct); + terminalDto.ClientSecret = terminal.ClientSecret; + createdTerminals.Add(terminalDto); + } + created.CreatedTerminals = createdTerminals.Count == 0 ? null : createdTerminals; return Results.Ok(created); }) .WithName("V2_Position_Create") @@ -191,6 +256,8 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati if (fn is null || fn.IsDeleted) return Results.NotFound(); var wasActive = fn.IsActive; + var previousPolicy = fn.TerminalPolicy; + var policyConsequences = new PositionTerminalPolicyConsequencesDto(); if (dto.AccountName is { } rawAccountName) { @@ -213,12 +280,33 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati if (dto.IsActive.HasValue) fn.IsActive = dto.IsActive.Value; + // A full-replace PositionPrincipalUpdatedEvent persists the policy even + // when this request only changes another field. Validate that persisted + // value as a write as well: open IDs remain readable, but unknown or + // currently unavailable IDs must never be written back silently. + var policy = ApplyPolicy( + fn.TerminalPolicy, + dto.TerminalPolicy ?? new PositionTerminalPolicyUpdateDto(), + out var policyError); + if (policyError is not null) return policyError; + if (await ValidatePolicyAgainstRealmFloorAsync(session, policy, ct) is { } floorError) + return floorError; + if (dto.TerminalPolicy is not null) { - var policy = ApplyPolicy(fn.TerminalPolicy, dto.TerminalPolicy, out var policyError); - if (policyError is not null) return policyError; - fn.TerminalPolicy = policy; + policyConsequences = await PreviewPolicyConsequencesAsync( + session, fn.Id, previousPolicy, policy, ct); + if (policyConsequences.HasConsequences && !dto.ConfirmTerminalPolicyConsequences) + return Results.BadRequest(new + { + Error = "Position.TerminalPolicyConfirmationRequired", + Message = $"The policy change affects {policyConsequences.TerminalIds.Count} terminal slots and " + + $"{policyConsequences.StaffingSessionIds.Count} active staffing sessions. " + + "Preview and confirm the consequences before saving.", + Consequences = policyConsequences, + }); } + fn.TerminalPolicy = policy; // Full-replace event (mirrors GroupUpdatedEvent) — `fn` carries the // merged state; the inline projection writes the document. @@ -226,6 +314,13 @@ public static WebApplication MapPositionsEndpoints(this WebApplication applicati fn.Id, fn.AccountName, fn.Purpose, fn.IsActive, fn.TerminalPolicy)); await session.SaveChangesAsync(ct); + foreach (var encodedSessionId in policyConsequences.StaffingSessionIds) + { + if (ShortGuid.TryDecode(encodedSessionId, out var staffingSessionId)) + await staffingRevoker.EndSessionAsync( + staffingSessionId, StaffingSessionEndReason.PolicyTightened, ct); + } + // Deactivation cuts off live position access, mirroring the SA // rule (Audit #6): position tokens carry sub = fn.Id, so a // by-subject revoke kills every outstanding staffing token, and @@ -252,6 +347,27 @@ await staffingRevoker.EndAllForPositionAsync( .WithName("V2_Position_Update") .RequiresPermission("position:write"); + group.MapPost("{id}/terminal-policy/preview", async ( + ShortGuid id, + PositionTerminalPolicyUpdateDto dto, + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PositionTerminals) return Results.NotFound(); + var position = await session.LoadAsync(id.Guid, ct); + if (position is null || position.IsDeleted) return Results.NotFound(); + + var policy = ApplyPolicy(position.TerminalPolicy, dto, out var policyError); + if (policyError is not null) return policyError; + if (await ValidatePolicyAgainstRealmFloorAsync(session, policy, ct) is { } floorError) + return floorError; + return Results.Ok(await PreviewPolicyConsequencesAsync( + session, position.Id, position.TerminalPolicy, policy, ct)); + }) + .WithName("V2_Position_TerminalPolicyPreview") + .RequiresPermission("position:write"); + group.MapDelete("{id}", async ( ShortGuid id, AppSettings settings, @@ -272,19 +388,30 @@ await staffingRevoker.EndAllForPositionAsync( var deleteActor = PositionGrantsEndpoints.RequireActor(httpContext); var deletedAt = DateTimeOffset.UtcNow; - // §15.4 — the slots go with the position. Without this a deleted - // position left its terminal slots Pending/Active and their - // managed OAuth clients registered: orphans pointing at a - // principal that no longer exists. Same steps the per-slot - // revoke takes, staged into this delete's unit of work. - var slots = await session.Query() - .Where(t => t.PositionPrincipalId == id.Guid && t.Status != TerminalEnrollmentStatus.Revoked) - .ToListAsync(ct); + // F4 — remove this position from shared terminals; only a slot + // whose allow-list becomes empty dies with the position. + var slots = (await session.Query().ToListAsync(ct)) + .Where(t => t.Status != TerminalEnrollmentStatus.Revoked && + t.EffectiveAllowedPositionIds.Contains(id.Guid)) + .ToList(); + var revokedSlots = new List(); + var updatedSlots = new List(); foreach (var slot in slots) { - session.Events.Append(slot.Id, new TerminalEnrollmentRevoked(slot.Id, deleteActor, deletedAt)); - if (await oauth.StageDeleteTerminalClientAsync(slot.OAuthApplicationId, ct) is { } slotError) - return Results.BadRequest(new { Error = slotError.Code, Message = slotError.Description }); + var remaining = slot.EffectiveAllowedPositionIds.Where(p => p != id.Guid).ToArray(); + if (remaining.Length == 0) + { + session.Events.Append(slot.Id, new TerminalEnrollmentRevoked(slot.Id, deleteActor, deletedAt)); + if (await oauth.StageDeleteTerminalClientAsync(slot.OAuthApplicationId, ct) is { } slotError) + return Results.BadRequest(new { Error = slotError.Code, Message = slotError.Description }); + revokedSlots.Add(slot); + } + else + { + session.Events.Append(slot.Id, new TerminalAllowedPositionsChanged( + slot.Id, remaining, deleteActor, deletedAt)); + updatedSlots.Add(slot); + } } // Soft delete via the stream: the projection flips IsDeleted (and @@ -303,13 +430,18 @@ await staffingRevoker.EndAllForPositionAsync( // Each revoked slot's device is cut off now, not at token expiry, // and consumers hear about it (MG-FT-09 §17). - foreach (var slot in slots) + foreach (var slot in revokedSlots) { await revoker.RevokeTokensByApplicationIdAsync(slot.OAuthApplicationId.ToString(), ct); dispatcher.DispatchDeletedEvent("Terminal", new ShortGuid(slot.Id).ToString(), session.TenantId); await bus.PublishAsync(new Modgud.Domain.PositionTerminals.Contracts.V1.PositionTerminalStatusChanged( fn.Id, slot.Id, TerminalEnrollmentStatus.Revoked, deletedAt)); } + foreach (var slot in updatedSlots) + { + var updated = await PositionTerminalsEndpoints.LoadDtoAsync(session, slot.Id, ct); + dispatcher.DispatchUpdatedEvent("Terminal", updated, session.TenantId); + } dispatcher.DispatchDeletedEvent("Position", new ShortGuid(fn.Id).ToString(), session.TenantId); return Results.Ok(); @@ -358,6 +490,12 @@ private static PositionTerminalPolicy ApplyPolicy( var merged = current with { Enabled = update.Enabled ?? current.Enabled, + AllowedActivationProofs = update.AllowedActivationProofs is null + ? current.AllowedActivationProofs + : update.AllowedActivationProofs.Distinct(StringComparer.Ordinal).ToArray(), + AllowedDeviceBindings = update.AllowedDeviceBindings is null + ? current.AllowedDeviceBindings + : update.AllowedDeviceBindings.Distinct(StringComparer.Ordinal).ToArray(), StaffingSessionLifetime = update.StaffingSessionLifetimeMinutes is { } sl ? TimeSpan.FromMinutes(sl) : current.StaffingSessionLifetime, @@ -366,17 +504,57 @@ private static PositionTerminalPolicy ApplyPolicy( : current.MaximumStaffingSessionLifetime, }; + if (merged.Enabled && (merged.AllowedActivationProofs.Count == 0 || merged.AllowedDeviceBindings.Count == 0)) + { + error = Results.BadRequest(new + { + Error = "Position.InvalidTerminalPolicy", + Message = "An enabled terminal policy requires at least one activation proof and one device binding." + }); + return current; + } + + var unknownProof = merged.AllowedActivationProofs.FirstOrDefault( + id => !PositionTerminalSecurity.TryGetWritableProof(id, out _)); + if (unknownProof is not null) + { + error = Results.BadRequest(new + { + Error = "Position.UnknownActivationProof", + Message = $"Activation proof '{unknownProof}' is unknown or unavailable." + }); + return current; + } + + var unknownBinding = merged.AllowedDeviceBindings.FirstOrDefault( + id => !PositionTerminalSecurity.TryGetWritableBinding(id, out _)); + if (unknownBinding is not null) + { + error = Results.BadRequest(new + { + Error = "Position.UnknownDeviceBinding", + Message = $"Device binding '{unknownBinding}' is unknown or unavailable." + }); + return current; + } + if (merged.StaffingSessionLifetime <= TimeSpan.Zero || merged.MaximumStaffingSessionLifetime <= TimeSpan.Zero) { - error = Results.BadRequest(new { Error = "Position.InvalidTerminalPolicy", - Message = "Staffing session lifetimes must be positive." }); + error = Results.BadRequest(new + { + Error = "Position.InvalidTerminalPolicy", + Message = "Staffing session lifetimes must be positive." + }); return current; } if (merged.StaffingSessionLifetime > merged.MaximumStaffingSessionLifetime) { - error = Results.BadRequest(new { Error = "Position.InvalidTerminalPolicy", - Message = "The staffing session lifetime must not exceed the absolute maximum lifetime." }); + error = Results.BadRequest(new + { + Error = "Position.InvalidTerminalPolicy", + Message = "The staffing session lifetime must not exceed the absolute maximum lifetime." + }); return current; } @@ -386,16 +564,92 @@ private static PositionTerminalPolicy ApplyPolicy( private static IResult? ValidateAccountName(string normalised) { if (string.IsNullOrWhiteSpace(normalised)) - return Results.BadRequest(new { Error = "Position.AccountNameRequired", - Message = "Account name is required." }); + return Results.BadRequest(new + { + Error = "Position.AccountNameRequired", + Message = "Account name is required." + }); if (!AccountNamePattern.IsMatch(normalised)) - return Results.BadRequest(new { Error = "Position.InvalidAccountName", - Message = "Account name must be 2-64 chars, start with a letter or digit, and contain only lowercase letters, digits, dots, hyphens, or underscores." }); + return Results.BadRequest(new + { + Error = "Position.InvalidAccountName", + Message = "Account name must be 2-64 chars, start with a letter or digit, and contain only lowercase letters, digits, dots, hyphens, or underscores." + }); return null; } + private static async Task ValidatePolicyAgainstRealmFloorAsync( + IDocumentSession session, PositionTerminalPolicy policy, CancellationToken ct) + { + var realm = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var requiredProof = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None; + var requiredBinding = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None; + + var violatingProofs = policy.AllowedActivationProofs + .Where(id => !PositionTerminalSecurity.ProofMeetsFloor(id, requiredProof)) + .ToArray(); + var violatingBindings = policy.AllowedDeviceBindings + .Where(id => !PositionTerminalSecurity.BindingMeetsFloor(id, requiredBinding)) + .ToArray(); + if (violatingProofs.Length == 0 && violatingBindings.Length == 0) return null; + + return Results.BadRequest(new + { + Error = "Position.TerminalPolicyBelowRealmFloor", + Message = "Every allowed activation proof and device binding must meet the realm capability floor.", + ViolatingActivationProofs = violatingProofs, + ViolatingDeviceBindings = violatingBindings, + }); + } + + private static async Task PreviewPolicyConsequencesAsync( + IDocumentSession session, + Guid positionId, + PositionTerminalPolicy previous, + PositionTerminalPolicy proposed, + CancellationToken ct) + { + var lifetimeTightened = + proposed.StaffingSessionLifetime < previous.StaffingSessionLifetime || + proposed.MaximumStaffingSessionLifetime < previous.MaximumStaffingSessionLifetime; + + var terminals = (await session.Query() + .Where(t => t.Status != TerminalEnrollmentStatus.Revoked) + .ToListAsync(ct)) + .Where(t => t.EffectiveAllowedPositionIds.Contains(positionId)) + .ToList(); + var affectedTerminalIds = terminals + .Where(t => !proposed.Enabled || + !proposed.AllowedDeviceBindings.Contains(t.Binding, StringComparer.Ordinal)) + .Select(t => t.Id) + .ToHashSet(); + + var activeSessions = await session.Query() + .Where(s => s.PositionPrincipalId == positionId && s.Status == StaffingSessionStatus.Active) + .ToListAsync(ct); + var affectedSessions = activeSessions.Where(s => + { + var methodId = string.IsNullOrWhiteSpace(s.Evidence?.MethodId) + ? ActivationProofMethodIds.PersonalPasskey + : s.Evidence.MethodId; + var binding = string.IsNullOrWhiteSpace(s.Evidence?.Binding) + ? DeviceBindingIds.Dpop + : s.Evidence.Binding; + return !proposed.Enabled || lifetimeTightened || + !proposed.AllowedActivationProofs.Contains(methodId, StringComparer.Ordinal) || + !proposed.AllowedDeviceBindings.Contains(binding, StringComparer.Ordinal) || + affectedTerminalIds.Contains(s.TerminalEnrollmentId); + }); + + return new PositionTerminalPolicyConsequencesDto + { + TerminalIds = affectedTerminalIds.Select(ShortGuid.Encode).ToArray(), + StaffingSessionIds = affectedSessions.Select(s => ShortGuid.Encode(s.Id)).ToArray(), + }; + } + private static PositionPrincipalDto ToDto(PositionPrincipal fn) => new() { Id = new ShortGuid(fn.Id).ToString(), @@ -406,6 +660,8 @@ private static PositionTerminalPolicy ApplyPolicy( TerminalPolicy = new PositionTerminalPolicyDto { Enabled = fn.TerminalPolicy.Enabled, + AllowedActivationProofs = fn.TerminalPolicy.AllowedActivationProofs, + AllowedDeviceBindings = fn.TerminalPolicy.AllowedDeviceBindings, StaffingSessionLifetimeMinutes = (int)fn.TerminalPolicy.StaffingSessionLifetime.TotalMinutes, MaximumStaffingSessionLifetimeMinutes = (int)fn.TerminalPolicy.MaximumStaffingSessionLifetime.TotalMinutes, }, diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 97146a3d..afc8f3ce 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -751,6 +751,16 @@ // cascade (user/passkey/grant/terminal/position) end sessions through it. builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); // Infrastructure (Marten + repositories + query services + event dispatcher) // Authentication Marten setup (documents + events + projections) is wired via @@ -1351,6 +1361,7 @@ Modgud.Api.Features.Positions.PositionsEndpoints.MapPositionsEndpoints(app, "api"); Modgud.Api.Features.Positions.PositionGrantsEndpoints.MapPositionGrantsEndpoints(app, "api"); Modgud.Api.Features.Positions.PositionTerminalsEndpoints.MapPositionTerminalsEndpoints(app, "api"); + Modgud.Api.Features.Positions.ActivationTokenEndpoints.MapActivationTokenEndpoints(app); app.MapPrincipalEndpoints("api"); app.MapRolesEndpoints("api"); app.MapGroupEndpoints("api"); diff --git a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs index 335993b1..f4990f42 100644 --- a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs @@ -115,6 +115,7 @@ public record OAuthClientDto /// grid and the read-only modal that deep-links into the position editor. /// public string? LinkedPositionPrincipalId { get; init; } + public string? ManagedTerminalEnrollmentId { get; init; } } public record OAuthClientClaimDto @@ -220,6 +221,8 @@ public record CreateOAuthClientDto /// Optional physical location of that slot ("Gate 3"). public string? TerminalLocation { get; init; } + /// Binding selected for the terminal slot. Omitted = dpop. + public string TerminalBinding { get; init; } = "dpop"; } public record UpdateOAuthClientDto diff --git a/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs b/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs index 6aa8b34a..a8552f2f 100644 --- a/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/Positions/PositionPrincipalDtos.cs @@ -18,6 +18,11 @@ public class PositionPrincipalDto public bool IsActive { get; set; } = true; public EntityStatus Status { get; set; } = EntityStatus.Active; public required PositionTerminalPolicyDto TerminalPolicy { get; set; } + + /// Only populated by the create endpoint when terminal slots were + /// staged with the position. This is the sole response in which a generated + /// client secret can be returned; ordinary reads leave it null. + public IReadOnlyList? CreatedTerminals { get; set; } } /// @@ -27,6 +32,8 @@ public class PositionPrincipalDto public class PositionTerminalPolicyDto { public bool Enabled { get; set; } + public IReadOnlyList AllowedActivationProofs { get; set; } = []; + public IReadOnlyList AllowedDeviceBindings { get; set; } = []; public int StaffingSessionLifetimeMinutes { get; set; } public int MaximumStaffingSessionLifetimeMinutes { get; set; } } @@ -67,12 +74,22 @@ public class PositionUpdateDto public string? Purpose { get; set; } public bool? IsActive { get; set; } public PositionTerminalPolicyUpdateDto? TerminalPolicy { get; set; } + public bool ConfirmTerminalPolicyConsequences { get; set; } } /// Partial policy update — null fields keep the persisted value. public class PositionTerminalPolicyUpdateDto { public bool? Enabled { get; set; } + public IReadOnlyList? AllowedActivationProofs { get; set; } + public IReadOnlyList? AllowedDeviceBindings { get; set; } public int? StaffingSessionLifetimeMinutes { get; set; } public int? MaximumStaffingSessionLifetimeMinutes { get; set; } } + +public sealed record PositionTerminalPolicyConsequencesDto +{ + public IReadOnlyList TerminalIds { get; init; } = []; + public IReadOnlyList StaffingSessionIds { get; init; } = []; + public bool HasConsequences => TerminalIds.Count > 0 || StaffingSessionIds.Count > 0; +} diff --git a/src/dotnet/Modgud.Application/DTOs/Positions/TerminalDtos.cs b/src/dotnet/Modgud.Application/DTOs/Positions/TerminalDtos.cs index dccac04a..51f33b1f 100644 --- a/src/dotnet/Modgud.Application/DTOs/Positions/TerminalDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/Positions/TerminalDtos.cs @@ -11,16 +11,20 @@ public class TerminalDto { public required string Id { get; set; } public required string PositionId { get; set; } + public IReadOnlyList AllowedPositionIds { get; set; } = []; public required string DisplayName { get; set; } public string? Location { get; set; } public required string ClientId { get; set; } public required string WebAuthnRpId { get; set; } + public required string Binding { get; set; } public TerminalEnrollmentStatus Status { get; set; } public bool Enrolled { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? EnrolledAt { get; set; } public DateTimeOffset? DisabledAt { get; set; } public DateTimeOffset? RevokedAt { get; set; } + /// Only populated on the create response for client-secret slots. + public string? ClientSecret { get; set; } } public class TerminalCreateDto @@ -32,6 +36,18 @@ public class TerminalCreateDto /// typically shared across every terminal client of the consuming app /// (spike 3: the credential is RP-ID-scoped, not client-scoped). public string WebAuthnRpId { get; set; } = string.Empty; + + /// Stable open binding ID. Omitted by old clients = dpop. + public string Binding { get; set; } = "dpop"; + + /// Optional n:m assignment. The route position is included + /// automatically; omitted by V1 clients means that singleton position. + public IReadOnlyList? AllowedPositionIds { get; set; } +} + +public sealed class TerminalAllowedPositionsUpdateDto +{ + public IReadOnlyList AllowedPositionIds { get; set; } = []; } public class TerminalUpdateDto diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/PositionSecuritySettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/PositionSecuritySettingsDtos.cs new file mode 100644 index 00000000..50d05fc2 --- /dev/null +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/PositionSecuritySettingsDtos.cs @@ -0,0 +1,29 @@ +using Modgud.Authorization.Principals; + +namespace Modgud.Application.DTOs.RealmSettings; + +public record PositionSecuritySettingsDto +{ + public ProofCapability? RequiredProofCapabilities { get; init; } + public BindingCapability? RequiredBindingCapabilities { get; init; } +} + +public record UpdatePositionSecuritySettingsDto +{ + public ProofCapability? RequiredProofCapabilities { get; init; } + public BindingCapability? RequiredBindingCapabilities { get; init; } +} + +public record PositionSecurityConsequencesDto +{ + public IReadOnlyList Positions { get; init; } = []; + public IReadOnlyList TerminalIds { get; init; } = []; + public IReadOnlyList StaffingSessionIds { get; init; } = []; + public bool HasConsequences => Positions.Count > 0 || TerminalIds.Count > 0 || StaffingSessionIds.Count > 0; +} + +public record PositionSecurityAffectedPositionDto( + string Id, + string AccountName, + IReadOnlyList ViolatingActivationProofs, + IReadOnlyList ViolatingDeviceBindings); diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs index 2d111a65..3294f6ce 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs @@ -18,6 +18,7 @@ public record RealmSettingsDto public NativeGrantSettingsDto NativeGrants { get; init; } = new(); public BrowserSessionPolicyDto BrowserSessions { get; init; } = new(); public ClientSessionPolicyDto ClientSessions { get; init; } = new(); + public PositionSecuritySettingsDto PositionSecurity { get; init; } = new(); public AuthRateLimitsDto AuthRateLimits { get; init; } = new(); public BrandingSettingsDto Branding { get; init; } = new(); public EmailBrandingSettingsDto EmailBranding { get; init; } = new(); @@ -42,6 +43,8 @@ public record UpdateRealmSettingsDto public UpdateNativeGrantSettingsDto? NativeGrants { get; init; } public UpdateBrowserSessionPolicyDto? BrowserSessions { get; init; } public UpdateClientSessionPolicyDto? ClientSessions { get; init; } + public UpdatePositionSecuritySettingsDto? PositionSecurity { get; init; } + public bool ConfirmPositionSecurityConsequences { get; init; } public UpdateAuthRateLimitsDto? AuthRateLimits { get; init; } public UpdateBrandingSettingsDto? Branding { get; init; } public UpdateEmailBrandingSettingsDto? EmailBranding { get; init; } diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs index 582895c5..8b758846 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs @@ -585,6 +585,9 @@ internal static OAuthClientDto MapClient(OAuthApplicationState s) LinkedPositionPrincipalId = s.LinkedPositionPrincipalId is null ? null : new ShortGuid(s.LinkedPositionPrincipalId.Value).ToString(), + ManagedTerminalEnrollmentId = s.ManagedTerminalEnrollmentId is null + ? null + : new ShortGuid(s.ManagedTerminalEnrollmentId.Value).ToString(), }; } @@ -772,7 +775,8 @@ internal static bool VerifySecret(string secret, string hash) Guid? linkedServiceAccountId, Guid? linkedPositionPrincipalId, Guid? managedTerminalEnrollmentId, - string? webAuthnRpId) + string? webAuthnRpId, + string binding = "dpop") { var hasPosition = linkedPositionPrincipalId.HasValue; var hasTerminal = managedTerminalEnrollmentId.HasValue; @@ -786,16 +790,19 @@ internal static bool VerifySecret(string secret, string hash) return OAuthErrors.InvalidPositionTerminalClient( "a terminal-managed client cannot also be ServiceAccount-linked (one client = one auth mode)."); - if (!string.Equals(clientType, OAuthClientTypes.Public, StringComparison.Ordinal)) - return OAuthErrors.InvalidPositionTerminalClient("the client must be public."); - - if (requireClientSecret) - return OAuthErrors.InvalidPositionTerminalClient( - "the client must not carry a client secret — the device is bound via DPoP, not a shared secret."); - - if (!requireDpop) + var profileValid = binding switch + { + "dpop" => string.Equals(clientType, OAuthClientTypes.Public, StringComparison.Ordinal) + && !requireClientSecret && requireDpop, + "client-secret" => string.Equals(clientType, OAuthClientTypes.Confidential, StringComparison.Ordinal) + && requireClientSecret && !requireDpop, + "none" => string.Equals(clientType, OAuthClientTypes.Public, StringComparison.Ordinal) + && !requireClientSecret && !requireDpop, + _ => false, + }; + if (!profileValid) return OAuthErrors.InvalidPositionTerminalClient( - "DPoP is mandatory — terminal tokens must be sender-constrained to the enrolled device key."); + $"the OAuth authentication profile is inconsistent with terminal binding '{binding}'."); if (accessTokenType != AccessTokenType.Reference) return OAuthErrors.InvalidPositionTerminalClient( diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs index a5d20a47..d7a1ca09 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs @@ -10,6 +10,7 @@ using Modgud.Domain.OAuth.Applications; using Modgud.Domain.OAuth.Common; using static Modgud.Application.Services.OAuthAdminMapping; +using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Application.Services; @@ -73,13 +74,26 @@ private async Task> CreateTerminalClientAsync( return OAuthErrors.InvalidPositionTerminalClient( "allowed grants are exactly device_code, refresh_token, and the position staffing grant."); - if (!string.Equals(dto.ClientType, OAuthClientTypes.Public, StringComparison.Ordinal)) - return OAuthErrors.InvalidPositionTerminalClient("the client must be public."); + var binding = string.IsNullOrWhiteSpace(dto.TerminalBinding) + ? DeviceBindingIds.Dpop + : dto.TerminalBinding; + if (!PositionTerminalSecurity.TryGetWritableBinding(binding, out _)) + return OAuthErrors.InvalidPositionTerminalClient($"device binding '{binding}' is unknown or unavailable."); + var expectedClientType = binding == DeviceBindingIds.ClientSecret + ? OAuthClientTypes.Confidential + : OAuthClientTypes.Public; + if (!string.Equals(dto.ClientType, expectedClientType, StringComparison.Ordinal)) + return OAuthErrors.InvalidPositionTerminalClient( + $"binding '{binding}' requires a {expectedClientType} client."); var terminalDisplayName = (dto.TerminalDisplayName ?? string.Empty).Trim(); if (terminalDisplayName.Length == 0) return OAuthErrors.TerminalDisplayNameRequired; + var realm = await _session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var proofFloor = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None; + var bindingFloor = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None; + // ── Resolve or inline-create the position ───────────────────────── PositionPrincipal position; PositionPrincipalDto? createdPosition = null; @@ -124,6 +138,8 @@ private async Task> CreateTerminalClientAsync( policy = policy with { Enabled = policyUpdate.Enabled ?? policy.Enabled, + AllowedActivationProofs = policyUpdate.AllowedActivationProofs ?? policy.AllowedActivationProofs, + AllowedDeviceBindings = policyUpdate.AllowedDeviceBindings ?? policy.AllowedDeviceBindings, StaffingSessionLifetime = policyUpdate.StaffingSessionLifetimeMinutes is { } sessionMinutes ? TimeSpan.FromMinutes(sessionMinutes) : policy.StaffingSessionLifetime, @@ -137,6 +153,26 @@ private async Task> CreateTerminalClientAsync( if (policy.StaffingSessionLifetime > policy.MaximumStaffingSessionLifetime) return Error.Validation("Position.InvalidTerminalPolicy", "The staffing session lifetime must not exceed the absolute maximum lifetime."); + + if (policy.Enabled && (policy.AllowedActivationProofs.Count == 0 || policy.AllowedDeviceBindings.Count == 0)) + return OAuthErrors.InvalidPositionTerminalClient( + "an enabled Position policy requires at least one activation proof and one device binding."); + var unknownProof = policy.AllowedActivationProofs.FirstOrDefault( + methodId => !PositionTerminalSecurity.TryGetWritableProof(methodId, out _)); + if (unknownProof is not null) + return OAuthErrors.InvalidPositionTerminalClient( + $"activation proof '{unknownProof}' is unknown or unavailable."); + var unknownBinding = policy.AllowedDeviceBindings.FirstOrDefault( + bindingId => !PositionTerminalSecurity.TryGetWritableBinding(bindingId, out _)); + if (unknownBinding is not null) + return OAuthErrors.InvalidPositionTerminalClient( + $"device binding '{unknownBinding}' is unknown or unavailable."); + if (policy.AllowedActivationProofs.Any(methodId => + !PositionTerminalSecurity.ProofMeetsFloor(methodId, proofFloor)) + || policy.AllowedDeviceBindings.Any(bindingId => + !PositionTerminalSecurity.BindingMeetsFloor(bindingId, bindingFloor))) + return OAuthErrors.InvalidPositionTerminalClient( + "every allowed activation proof and device binding of the new Position must meet the realm capability floor."); } // Staged grants — resolve and validate EVERY user before creating @@ -176,6 +212,8 @@ private async Task> CreateTerminalClientAsync( TerminalPolicy = new PositionTerminalPolicyDto { Enabled = position.TerminalPolicy.Enabled, + AllowedActivationProofs = position.TerminalPolicy.AllowedActivationProofs, + AllowedDeviceBindings = position.TerminalPolicy.AllowedDeviceBindings, StaffingSessionLifetimeMinutes = (int)position.TerminalPolicy.StaffingSessionLifetime.TotalMinutes, MaximumStaffingSessionLifetimeMinutes = (int)position.TerminalPolicy.MaximumStaffingSessionLifetime.TotalMinutes, }, @@ -184,27 +222,49 @@ private async Task> CreateTerminalClientAsync( if (!position.TerminalPolicy.Enabled) return OAuthErrors.PositionTerminalsDisabled(position.AccountName); - - // ── ClientId per convention (dto.ClientId is deliberately ignored — - // the audit log reads the owning position off the generated id) ────── - var clientId = string.Empty; - for (var attempt = 0; attempt < 8; attempt++) + if (!position.TerminalPolicy.AllowedDeviceBindings.Contains(binding, StringComparer.Ordinal)) + return OAuthErrors.InvalidPositionTerminalClient( + $"device binding '{binding}' is not allowed by the position policy."); + if (!PositionTerminalSecurity.BindingMeetsFloor(binding, bindingFloor)) + return OAuthErrors.InvalidPositionTerminalClient( + $"device binding '{binding}' does not meet the realm security floor."); + + // The generic OAuth-client surface owns the client identity just like + // it does for client_credentials + ServiceAccount. Keep generation as + // a backwards-compatible fallback for older callers that omit the id + // (the position/terminal endpoints still use that convention), but do + // not overwrite an explicit admin choice. + var clientId = (dto.ClientId ?? string.Empty).Trim(); + if (clientId.Length == 0) + { + for (var attempt = 0; attempt < 8; attempt++) + { + var candidate = $"terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; + var clash = await _session.Query() + .AnyAsync(x => !x.IsDeleted && x.ClientId == candidate, ct); + if (!clash) { clientId = candidate; break; } + } + if (clientId.Length == 0) + return Error.Conflict("OAuth.ClientIdAutoGenerationFailed", + "Could not generate a unique client_id for the terminal client after 8 attempts."); + } + else if (await _session.Query() + .AnyAsync(x => !x.IsDeleted && x.ClientId == clientId, ct)) { - var candidate = $"{position.AccountName}.terminal.{new ShortGuid(Guid.NewGuid()).ToString()[..8]}"; - var clash = await _session.Query() - .AnyAsync(x => !x.IsDeleted && x.ClientId == candidate, ct); - if (!clash) { clientId = candidate; break; } + return OAuthErrors.ClientIdAlreadyExists(clientId); } - if (clientId.Length == 0) - return Error.Conflict("OAuth.ClientIdAutoGenerationFailed", - "Could not generate a unique client_id for the terminal client after 8 attempts."); + + var clientDisplayName = string.IsNullOrWhiteSpace(dto.DisplayName) + ? $"{position.DisplayName} — {terminalDisplayName}" + : dto.DisplayName.Trim(); // ── Stage client + enrollment, commit once ───────────────────────── var enrollmentId = Guid.NewGuid(); var applicationId = Guid.NewGuid(); var clientError = StageCreateTerminalClient( - applicationId, clientId, $"{position.DisplayName} — {terminalDisplayName}", - position.Id, enrollmentId, dto.WebAuthnRpId ?? string.Empty); + applicationId, clientId, clientDisplayName, + position.Id, enrollmentId, dto.WebAuthnRpId ?? string.Empty, + binding, out var clientSecret); if (clientError is not null) return clientError.Value; @@ -220,7 +280,7 @@ private async Task> CreateTerminalClientAsync( enrollmentId, position.Id, terminalDisplayName, string.IsNullOrWhiteSpace(dto.TerminalLocation) ? null : dto.TerminalLocation.Trim(), applicationId, clientId, dto.WebAuthnRpId!.Trim().ToLowerInvariant(), - actorId.Value, now)); + actorId.Value, now, binding, [position.Id])); await _session.SaveChangesAsync(ct); @@ -228,16 +288,18 @@ private async Task> CreateTerminalClientAsync( return new OAuthClientCreatedDto { Client = MapClient(state!), - ClientSecret = null, + ClientSecret = clientSecret, CreatedPosition = createdPosition, CreatedTerminalId = new ShortGuid(enrollmentId).ToString(), }; } /// - /// Stages the terminal-managed public client for one slot. The profile is - /// FIXED (plan §6.4): public, secretless, DPoP-mandatory, reference tokens, - /// per-client RP-ID, exactly the three terminal grants — validated against + /// Stages the terminal-managed client for one slot. The profile is fixed + /// by its binding: DPoP is public and sender-constrained, ClientSecret is + /// confidential, and None is public bearer-only. Every profile uses + /// reference tokens, a per-client RP-ID, and exactly the three terminal + /// grants. The result is validated against /// as /// defense in depth even though this method is the only producer. /// @@ -247,26 +309,38 @@ private async Task> CreateTerminalClientAsync( string displayName, Guid positionPrincipalId, Guid terminalEnrollmentId, - string webAuthnRpId) + string webAuthnRpId, + string binding, + out string? clientSecret) { + clientSecret = null; var grants = TerminalGrantTypes.ToList(); if (ValidateWebAuthnRpId(webAuthnRpId) is { } rpIdError) return rpIdError; + if (!PositionTerminalSecurity.TryGetWritableBinding(binding, out _)) + return OAuthErrors.InvalidPositionTerminalClient($"device binding '{binding}' is unknown or unavailable."); + + var clientType = binding == DeviceBindingIds.ClientSecret + ? OAuthClientTypes.Confidential + : OAuthClientTypes.Public; + var requireSecret = binding == DeviceBindingIds.ClientSecret; + var requireDpop = binding == DeviceBindingIds.Dpop; + if (ValidatePositionTerminalLinkInvariant( - grants, OAuthClientTypes.Public, requireClientSecret: false, - AccessTokenType.Reference, requireDpop: true, + grants, clientType, requireSecret, + AccessTokenType.Reference, requireDpop, linkedServiceAccountId: null, positionPrincipalId, terminalEnrollmentId, - webAuthnRpId) is { } invariantError) + webAuthnRpId, binding) is { } invariantError) return invariantError; - var permissions = BuildClientPermissions(grants, scopes: [], OAuthClientTypes.Public); + var permissions = BuildClientPermissions(grants, scopes: [], clientType); var (aggregate, createdEvent) = OAuthApplicationAggregate.Create( applicationId, clientId, displayName, - OAuthClientTypes.Public, + clientType, OAuthConsentTypes.Implicit, applicationType: null, redirectUris: [], @@ -282,13 +356,24 @@ private async Task> CreateTerminalClientAsync( })); _session.Events.Append(applicationId, aggregate.SetProperties(BuildClientProperties( - enabled: true, allowBrowser: false, requireSecret: false, enableLocal: false, + enabled: true, allowBrowser: false, requireSecret: requireSecret, enableLocal: false, requireConsent: false, allowRemember: false, corsOrigins: [], alwaysSend: false, updateClaims: false, claims: [], roles: [], - requireDpop: true, requireDpopNonce: false))); + requireDpop: requireDpop, requireDpopNonce: false))); + // V2 links the client to the terminal only. The position set belongs to + // the slot and may change independently; legacy streams retain their + // position link for dual-protocol acceptance. _session.Events.Append(applicationId, - aggregate.SetPositionTerminalLink(positionPrincipalId, terminalEnrollmentId)); + aggregate.SetPositionTerminalLink(positionPrincipalId: null, terminalEnrollmentId)); + + if (requireSecret) + { + clientSecret = GenerateSecret(); + var security = OAuthApplicationSecurityData.Create(applicationId); + security.ClientSecret = HashSecret(clientSecret); + _session.Store(security); + } return null; } diff --git a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs index 0f9db696..2f11432e 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs @@ -543,6 +543,7 @@ externalLoginProvider is UserManager userManager, SignInManager signInManager, IUserAccessRevoker accessRevoker, + Modgud.Infrastructure.PositionTerminals.IStaffingRevoker staffingRevoker, IAuthSettings appSettings) => { if (appSettings.AuthenticationMinimumLevel >= 2) @@ -570,6 +571,10 @@ externalLoginProvider is // authoritative row as part of the refreshed cookie. await accessRevoker.RevokeAllAccessAsync( user.Id, AccessRevocationReason.ForceSignOut, context.RequestAborted); + await staffingRevoker.EndAllForUserAsync( + user.Id, + Modgud.Domain.PositionTerminals.StaffingSessionEndReason.ActivationCredentialInvalidated, + context.RequestAborted); var refreshed = await userManager.FindByIdAsync(user.Id.ToString()); if (refreshed is not null) { diff --git a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs index 7862e662..cc9e7b4d 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs @@ -87,6 +87,7 @@ public static WebApplication MapEmailOtpEndpoints(this WebApplication applicatio UserManager userManager, SignInManager signInManager, IOAuthGrantRevoker grantRevoker, + Modgud.Infrastructure.PositionTerminals.IStaffingRevoker staffingRevoker, IAuthSettings appSettings, IDocumentSession session, CancellationToken ct) => @@ -116,6 +117,10 @@ public static WebApplication MapEmailOtpEndpoints(this WebApplication applicatio // Audit #10 — and revoke OAuth reference tokens, which the stamp rotation // alone doesn't kill (stock introspection trusts store status). await grantRevoker.RevokeTokensBySubjectAsync(user.Id.ToString(), ct); + await staffingRevoker.EndAllForUserAsync( + user.Id, + Modgud.Domain.PositionTerminals.StaffingSessionEndReason.ActivationCredentialInvalidated, + ct); return Results.Ok(new { diff --git a/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs index 9aec0023..59b57c62 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs @@ -95,6 +95,7 @@ await emailBranding.ApplyAsync(new Dictionary ResetPasswordRequest request, UserManager userManager, Modgud.Authentication.Sessions.IUserAccessRevoker accessRevoker, + Modgud.Infrastructure.PositionTerminals.IStaffingRevoker staffingRevoker, IAuthSettings appSettings) => { if (appSettings.AuthenticationMinimumLevel >= 2) @@ -127,6 +128,9 @@ await emailBranding.ApplyAsync(new Dictionary // live reference access token + session rows alive. Revoke everything. // (Anonymous request → no acting session to RefreshSignIn.) await accessRevoker.RevokeAllAccessAsync(user.Id, Modgud.Authentication.Sessions.AccessRevocationReason.ForceSignOut); + await staffingRevoker.EndAllForUserAsync( + user.Id, + Modgud.Domain.PositionTerminals.StaffingSessionEndReason.ActivationCredentialInvalidated); return Results.Ok(new { Message = "Passwort wurde erfolgreich zurückgesetzt." }); }) diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs index f6aead4f..2ca2eb22 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs @@ -59,6 +59,14 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, .WithName("RealmSettings_Patch") .RequiresPermission("realm-settings:write"); + group.MapPost("position-security/preview", async ( + UpdatePositionSecuritySettingsDto dto, + IRealmSettingsService svc, + CancellationToken ct) => + Results.Ok(await svc.PreviewPositionSecurityAsync(dto, ct))) + .WithName("RealmSettings_PositionSecurityPreview") + .RequiresPermission("realm-settings:write"); + // Manual signing-key rotation for the calling realm. Generates a fresh // RSA keypair, retires the previous active key into the verification // overlap window (so in-flight tokens stay valid for ~30 days), and diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index 61704c4e..1939e46f 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -2,10 +2,13 @@ using BuildingBlocks.Helper; using Modgud.Application.DTOs.RealmSettings; using Modgud.Application.DTOs.Realms; +using Modgud.Authorization.Principals; using Modgud.Authentication.SelfRegistration.Captcha; +using Modgud.Domain.PositionTerminals; using Modgud.Domain.Realms; using Modgud.Domain.Assets; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.PositionTerminals; using ErrorOr; using Marten; using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; @@ -23,13 +26,16 @@ public interface IRealmSettingsService { Task LoadAsync(CancellationToken ct = default); Task GetDtoAsync(CancellationToken ct = default); + Task PreviewPositionSecurityAsync( + UpdatePositionSecuritySettingsDto dto, CancellationToken ct = default); Task> PatchAsync(UpdateRealmSettingsDto dto, CancellationToken ct = default); } public sealed class RealmSettingsService( IDocumentSession session, CaptchaSecretStore captchaStore, - ISecurityAuditLog? securityAudit = null) : IRealmSettingsService + ISecurityAuditLog? securityAudit = null, + IStaffingRevoker? staffingRevoker = null) : IRealmSettingsService { public async Task LoadAsync(CancellationToken ct = default) { @@ -57,6 +63,7 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d }; var previousSecurityRetentionDays = doc.Audit?.SecurityRetentionDays ?? AuditSettings.Defaults.SecurityRetentionDays; + PositionSecurityConsequencesDto? positionSecurityConsequences = null; if (dto.SelfRegistration is not null) { @@ -98,6 +105,25 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d doc.ClientSessions = clientSessions.Value; } + if (dto.PositionSecurity is not null) + { + positionSecurityConsequences = await PreviewPositionSecurityAsync(dto.PositionSecurity, ct); + if (positionSecurityConsequences.HasConsequences && !dto.ConfirmPositionSecurityConsequences) + return Error.Validation("PositionSecurity.ConfirmationRequired", + $"The stricter floor affects {positionSecurityConsequences.Positions.Count} positions, " + + $"{positionSecurityConsequences.TerminalIds.Count} terminal slots and " + + $"{positionSecurityConsequences.StaffingSessionIds.Count} active staffing sessions. " + + "Preview and confirm the consequences before saving."); + + doc.PositionSecurity = new Modgud.Domain.RealmSettings.PositionSecuritySettings + { + RequiredProofCapabilities = dto.PositionSecurity.RequiredProofCapabilities + ?? doc.PositionSecurity?.RequiredProofCapabilities, + RequiredBindingCapabilities = dto.PositionSecurity.RequiredBindingCapabilities + ?? doc.PositionSecurity?.RequiredBindingCapabilities, + }; + } + if (dto.AuthRateLimits is not null) { var arl = ApplyAuthRateLimitsPatch(doc.AuthRateLimits, dto.AuthRateLimits); @@ -163,9 +189,86 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d } await session.SaveChangesAsync(ct); + // Deliberately best-effort after the durable settings write. Refresh + // revalidation is the fail-closed backstop if one individual cascade + // fails; callers may safely retry because the revoker is idempotent. + if (positionSecurityConsequences is { StaffingSessionIds.Count: > 0 } && staffingRevoker is not null) + { + foreach (var encodedId in positionSecurityConsequences.StaffingSessionIds) + { + if (ShortGuid.TryDecode(encodedId, out var sessionId)) + await staffingRevoker.EndSessionAsync(sessionId, StaffingSessionEndReason.PolicyTightened, ct); + } + } + return ToDto(doc); } + public async Task PreviewPositionSecurityAsync( + UpdatePositionSecuritySettingsDto dto, CancellationToken ct = default) + { + var current = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var requiredProof = dto.RequiredProofCapabilities + ?? current?.PositionSecurity?.RequiredProofCapabilities + ?? ProofCapability.None; + var requiredBinding = dto.RequiredBindingCapabilities + ?? current?.PositionSecurity?.RequiredBindingCapabilities + ?? BindingCapability.None; + + var positions = (await session.Query() + .Where(p => !p.IsDeleted && p.TerminalPolicy.Enabled) + .ToListAsync(ct)) + .ToDictionary(p => p.Id); + + var affectedPositions = positions.Values + .Select(position => new PositionSecurityAffectedPositionDto( + ShortGuid.Encode(position.Id), + position.AccountName, + position.TerminalPolicy.AllowedActivationProofs + .Where(id => !PositionTerminalSecurity.ProofMeetsFloor(id, requiredProof)) + .ToArray(), + position.TerminalPolicy.AllowedDeviceBindings + .Where(id => !PositionTerminalSecurity.BindingMeetsFloor(id, requiredBinding)) + .ToArray())) + .Where(p => p.ViolatingActivationProofs.Count > 0 || p.ViolatingDeviceBindings.Count > 0) + .ToArray(); + + if (positions.Count == 0) + return new PositionSecurityConsequencesDto { Positions = affectedPositions }; + + var positionIds = positions.Keys.ToArray(); + var terminals = await session.Query() + .Where(t => positionIds.Contains(t.PositionPrincipalId)) + .ToListAsync(ct); + var affectedTerminalIds = terminals + .Where(t => !PositionTerminalSecurity.BindingMeetsFloor(t.Binding, requiredBinding)) + .Select(t => t.Id) + .ToHashSet(); + + var activeSessions = await session.Query() + .Where(s => s.Status == StaffingSessionStatus.Active && positionIds.Contains(s.PositionPrincipalId)) + .ToListAsync(ct); + var affectedSessions = activeSessions.Where(s => + { + var methodId = string.IsNullOrWhiteSpace(s.Evidence?.MethodId) + ? ActivationProofMethodIds.PersonalPasskey + : s.Evidence.MethodId; + var binding = string.IsNullOrWhiteSpace(s.Evidence?.Binding) + ? DeviceBindingIds.Dpop + : s.Evidence.Binding; + return !PositionTerminalSecurity.ProofMeetsFloor(methodId, requiredProof) + || !PositionTerminalSecurity.BindingMeetsFloor(binding, requiredBinding) + || affectedTerminalIds.Contains(s.TerminalEnrollmentId); + }); + + return new PositionSecurityConsequencesDto + { + Positions = affectedPositions, + TerminalIds = affectedTerminalIds.Select(ShortGuid.Encode).ToArray(), + StaffingSessionIds = affectedSessions.Select(s => ShortGuid.Encode(s.Id)).ToArray(), + }; + } + private SelfRegistrationSettings ApplySelfRegistrationPatch( SelfRegistrationSettings? current, UpdateSelfRegistrationDto patch) @@ -199,6 +302,11 @@ private SelfRegistrationSettings ApplySelfRegistrationPatch( NativeGrants = MapNativeGrantsToDto(doc.NativeGrants), BrowserSessions = MapBrowserSessionsToDto(doc.BrowserSessions), ClientSessions = MapClientSessionsToDto(doc.ClientSessions), + PositionSecurity = new PositionSecuritySettingsDto + { + RequiredProofCapabilities = doc.PositionSecurity?.RequiredProofCapabilities, + RequiredBindingCapabilities = doc.PositionSecurity?.RequiredBindingCapabilities, + }, AuthRateLimits = MapAuthRateLimitsToDto(doc.AuthRateLimits), Branding = MapBrandingToDto(doc.Branding), EmailBranding = MapEmailBrandingToDto(doc.EmailBranding), diff --git a/src/dotnet/Modgud.Authorization/Principals/PositionPrincipal.cs b/src/dotnet/Modgud.Authorization/Principals/PositionPrincipal.cs index 1928cb92..a705f8c4 100644 --- a/src/dotnet/Modgud.Authorization/Principals/PositionPrincipal.cs +++ b/src/dotnet/Modgud.Authorization/Principals/PositionPrincipal.cs @@ -43,6 +43,16 @@ public sealed record PositionTerminalPolicy { public bool Enabled { get; init; } + /// Open proof-method IDs. Missing on old documents deserializes to + /// today's only supported method and therefore preserves old behaviour. + public IReadOnlyList AllowedActivationProofs { get; init; } + = [ActivationProofMethodIds.PersonalPasskey]; + + /// Open device-binding IDs. Missing on old documents defaults to + /// DPoP, matching every enrollment created before ADR 0004. + public IReadOnlyList AllowedDeviceBindings { get; init; } + = [DeviceBindingIds.Dpop]; + public TimeSpan StaffingSessionLifetime { get; init; } = TimeSpan.FromHours(16); public TimeSpan MaximumStaffingSessionLifetime { get; init; } = TimeSpan.FromHours(24); diff --git a/src/dotnet/Modgud.Authorization/Principals/PositionTerminalSecurity.cs b/src/dotnet/Modgud.Authorization/Principals/PositionTerminalSecurity.cs new file mode 100644 index 00000000..e8dde040 --- /dev/null +++ b/src/dotnet/Modgud.Authorization/Principals/PositionTerminalSecurity.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Modgud.Authorization.Principals; + +/// +/// Stable, open activation-proof identifiers. Stored values deliberately remain +/// strings so future proof plug-ins do not require a shared enum deployment. +/// +public static class ActivationProofMethodIds +{ + public const string PersonalPasskey = "personal-passkey"; + public const string PersonalPassword = "personal-password"; + public const string PersonalEmailOtp = "personal-email-otp"; + public const string PositionToken = "position-token"; + public const string TeamSecret = "team-secret"; + + public static readonly IReadOnlyDictionary Known = + new Dictionary(StringComparer.Ordinal) + { + [PersonalPasskey] = new( + PersonalPasskey, + ProofCapability.IdentifiedActor | + ProofCapability.PhishingResistant | + ProofCapability.IndividuallyRevocable, + ActivationProofOwnerKind.Personal, + IsAvailable: true), + [PersonalPassword] = new( + PersonalPassword, + ProofCapability.IdentifiedActor, + ActivationProofOwnerKind.Personal, + IsAvailable: true), + [PersonalEmailOtp] = new( + PersonalEmailOtp, + ProofCapability.IdentifiedActor, + ActivationProofOwnerKind.Personal, + IsAvailable: true), + [PositionToken] = new( + PositionToken, + ProofCapability.PhishingResistant | + ProofCapability.IndividuallyRevocable, + ActivationProofOwnerKind.PositionCredential, + IsAvailable: true), + }; +} + +/// Stable, open device-binding identifiers. +public static class DeviceBindingIds +{ + public const string Dpop = "dpop"; + public const string ClientSecret = "client-secret"; + public const string None = "none"; + + public static readonly IReadOnlyDictionary Known = + new Dictionary(StringComparer.Ordinal) + { + [Dpop] = new(Dpop, + BindingCapability.DeviceIdentity | BindingCapability.SenderConstrained, + IsAvailable: true), + [ClientSecret] = new(ClientSecret, BindingCapability.DeviceIdentity, IsAvailable: true), + [None] = new(None, BindingCapability.None, IsAvailable: true), + }; +} + +public sealed record ProofMethodDescriptor( + string MethodId, + ProofCapability Capabilities, + ActivationProofOwnerKind OwnerKind, + bool IsAvailable); + +public sealed record DeviceBindingDescriptor( + string BindingId, + BindingCapability Capabilities, + bool IsAvailable); + +public enum ActivationProofOwnerKind +{ + Personal, + PositionCredential, + SharedSecret, +} + +[Flags] +[JsonConverter(typeof(ProofCapabilityJsonConverter))] +public enum ProofCapability +{ + None = 0, + IdentifiedActor = 1, + PhishingResistant = 2, + IndividuallyRevocable = 4, +} + +[Flags] +[JsonConverter(typeof(BindingCapabilityJsonConverter))] +public enum BindingCapability +{ + None = 0, + DeviceIdentity = 1, + SenderConstrained = 2, +} + +/// +/// Central rules for writes and execution. Reads intentionally do not call +/// these helpers: unknown stored IDs must survive round-trips unchanged. +/// +public static class PositionTerminalSecurity +{ + public static bool TryGetWritableProof(string methodId, out ProofMethodDescriptor descriptor) + { + if (ActivationProofMethodIds.Known.TryGetValue(methodId, out var value) && value.IsAvailable) + { + descriptor = value; + return true; + } + + descriptor = null!; + return false; + } + + public static bool TryGetWritableBinding(string bindingId, out DeviceBindingDescriptor descriptor) + { + if (DeviceBindingIds.Known.TryGetValue(bindingId, out var value) && value.IsAvailable) + { + descriptor = value; + return true; + } + + descriptor = null!; + return false; + } + + public static bool ProofMeetsFloor(string methodId, ProofCapability required) + => ActivationProofMethodIds.Known.TryGetValue(methodId, out var descriptor) + && (descriptor.Capabilities & required) == required; + + public static bool BindingMeetsFloor(string bindingId, BindingCapability required) + => DeviceBindingIds.Known.TryGetValue(bindingId, out var descriptor) + && (descriptor.Capabilities & required) == required; +} + +public sealed class ProofCapabilityJsonConverter : FlagArrayJsonConverter; + +public sealed class BindingCapabilityJsonConverter : FlagArrayJsonConverter; + +/// Serializes flags as an array of stable enum names, never as a +/// comma-delimited pseudo-enum string. +public abstract class FlagArrayJsonConverter : JsonConverter + where TEnum : struct, Enum +{ + public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException($"{typeof(TEnum).Name} must be a JSON string array."); + + ulong combined = 0; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.String || + !Enum.TryParse(reader.GetString(), ignoreCase: false, out var value) || + !Enum.IsDefined(value)) + throw new JsonException($"Unknown {typeof(TEnum).Name} capability '{reader.GetString()}'."); + + combined |= Convert.ToUInt64(value); + } + + if (reader.TokenType != JsonTokenType.EndArray) + throw new JsonException($"Unterminated {typeof(TEnum).Name} capability array."); + + return (TEnum)Enum.ToObject(typeof(TEnum), combined); + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + var combined = Convert.ToUInt64(value); + writer.WriteStartArray(); + foreach (var candidate in Enum.GetValues()) + { + var bits = Convert.ToUInt64(candidate); + if (bits == 0 || (bits & (bits - 1)) != 0) continue; + if ((combined & bits) == bits) writer.WriteStringValue(candidate.ToString()); + } + writer.WriteEndArray(); + } +} diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/ActivationToken.cs b/src/dotnet/Modgud.Domain/PositionTerminals/ActivationToken.cs new file mode 100644 index 00000000..dabb6f43 --- /dev/null +++ b/src/dotnet/Modgud.Domain/PositionTerminals/ActivationToken.cs @@ -0,0 +1,57 @@ +namespace Modgud.Domain.PositionTerminals; + +/// A physical WebAuthn authenticator owned by a position/team rather +/// than by a person. One logical token can carry one credential per RP ID and +/// can be assigned to multiple positions. +public sealed class ActivationToken +{ + public Guid Id { get; set; } + public string Label { get; set; } = string.Empty; + public ActivationTokenStatus Status { get; set; } = ActivationTokenStatus.PendingRegistration; + public List AssignedPositionIds { get; set; } = []; + public Guid CreatedByUserId { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public Guid? RevokedByUserId { get; set; } + public DateTimeOffset? RevokedAt { get; set; } +} + +public enum ActivationTokenStatus +{ + PendingRegistration, + Active, + Disabled, + Revoked, +} + +/// RP-bound credential of an . Kept +/// separate from person passkeys because their ownership and lifecycle are +/// intentionally different. +public sealed class ActivationTokenCredential +{ + public Guid Id { get; set; } + public Guid ActivationTokenId { get; set; } + public byte[] CredentialId { get; set; } = []; + public byte[] PublicKey { get; set; } = []; + public byte[] UserHandle { get; set; } = []; + public uint SignatureCount { get; set; } + public Guid AaGuid { get; set; } + public string RpId { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? LastUsedAt { get; set; } +} + +/// Single-use attestation ceremony issued to an enrolled terminal, +/// which guarantees that registration runs under the terminal application's +/// RP-compatible origin. +public sealed class ActivationTokenRegistrationCeremony +{ + public Guid Id { get; set; } + public Guid ActivationTokenId { get; set; } + public Guid TerminalEnrollmentId { get; set; } + public string ClientId { get; set; } = string.Empty; + public string RpId { get; set; } = string.Empty; + public string OptionsJson { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; +} diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrant.cs b/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrant.cs index 1017e7f3..df5f58c7 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrant.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrant.cs @@ -26,6 +26,14 @@ public sealed class PositionGrant public DateTimeOffset? RevokedAt { get; set; } public Guid? RevokedByUserId { get; set; } + + /// Staffing-specific brute-force budget. It deliberately lives on + /// the grant rather than the user so attempts at a public terminal cannot + /// lock the person's normal realm login. + public int ActivationFailedCount { get; set; } + public DateTimeOffset? ActivationLockoutEnd { get; set; } + + public bool IsActivationLockedOut(DateTimeOffset now) => ActivationLockoutEnd > now; } public enum PositionGrantStatus diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrantEvents.cs b/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrantEvents.cs index 2f2aef4b..d05257ad 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrantEvents.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/PositionGrantEvents.cs @@ -26,3 +26,12 @@ public record PositionGrantRevoked( Guid Id, Guid RevokedByUserId, DateTimeOffset RevokedAt); + +public record PositionGrantActivationFailed( + Guid Id, + DateTimeOffset AttemptedAt, + DateTimeOffset? LockedUntil); + +public record PositionGrantActivationSucceeded( + Guid Id, + DateTimeOffset SucceededAt); diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/PositionTokenConstants.cs b/src/dotnet/Modgud.Domain/PositionTerminals/PositionTokenConstants.cs index 7ef3f97b..b0386b80 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/PositionTokenConstants.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/PositionTokenConstants.cs @@ -19,17 +19,28 @@ public static class PositionTokenClaimTypes public const string TokenUse = "token_use"; public const string TerminalId = "terminal_id"; public const string StaffingSessionId = "staffing_session_id"; + public const string ActivationProof = "activation_proof"; + public const string TerminalBinding = "terminal_binding"; + public const string StepUpAction = "stepup_action"; + public const string StepUpNonce = "stepup_nonce"; } public static class PositionTokenUses { public const string TerminalEnrollment = "terminal_enrollment"; public const string StaffingSession = "staffing_session"; + public const string StaffingStepUp = "staffing_step_up"; +} + +public static class PositionAuthenticationContextReferences +{ + public const string StaffingStepUp = "urn:cocoar:staffing:step-up"; } public static class PositionPrincipalTypes { public const string Position = "position"; + public const string Terminal = "terminal"; } /// Terminal-control surface constants: the audience enrollment tokens diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/StaffingCeremony.cs b/src/dotnet/Modgud.Domain/PositionTerminals/StaffingCeremony.cs index da2a38ce..ddb5b78f 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/StaffingCeremony.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/StaffingCeremony.cs @@ -17,6 +17,21 @@ public sealed class StaffingCeremony public Guid TerminalEnrollmentId { get; set; } public string ClientId { get; set; } = string.Empty; public string DpopJkt { get; set; } = string.Empty; + public string MethodId { get; set; } = "personal-passkey"; + public Guid? SubjectUserId { get; set; } + public Guid? SubjectGrantId { get; set; } + /// For a V2 n:m proof-first ceremony, the positions whose + /// credentials were included in the challenge. This list is server-side + /// only and is not disclosed until the proof has been verified. + public Guid[] CandidatePositionIds { get; set; } = []; + /// Present only on the short-lived continuation created after a + /// valid proof identified more than one position. Each entry carries the + /// position-specific evidence (notably the correct personal grant id). + public StaffingCandidateEvidence[] VerifiedCandidates { get; set; } = []; + public Guid? StepUpForStaffingSessionId { get; set; } + public string? StepUpAction { get; set; } + public string? StepUpNonce { get; set; } + public string[] StepUpScopes { get; set; } = []; public string RpId { get; set; } = string.Empty; public string OptionsJson { get; set; } = string.Empty; @@ -28,3 +43,7 @@ public sealed class StaffingCeremony public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; public bool IsConsumed => ConsumedAt is not null; } + +public sealed record StaffingCandidateEvidence( + Guid PositionPrincipalId, + ActivationEvidence Evidence); diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSession.cs b/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSession.cs index faaa4693..0c2c1c0e 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSession.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSession.cs @@ -25,7 +25,15 @@ public sealed class StaffingSession public Guid ActivatedByPasskeyCredentialId { get; set; } public Guid PositionGrantId { get; set; } - public string DpopJkt { get; set; } = string.Empty; + /// Versioned, method-neutral activation evidence. Legacy scalar + /// fields stay projected for query compatibility during the transition. + public ActivationEvidence Evidence { get; set; } = new() + { + MethodId = "personal-passkey", + Binding = "dpop", + }; + + public string? DpopJkt { get; set; } public string OAuthAuthorizationId { get; set; } = string.Empty; public StaffingSessionStatus Status { get; set; } @@ -36,6 +44,21 @@ public sealed class StaffingSession public DateTimeOffset? EndedAt { get; set; } public StaffingSessionEndReason? EndReason { get; set; } + + /// Projection-row compatibility for sessions written before the + /// Evidence property existed (event rebuilds already use the V1 upcast). + public ActivationEvidence GetActivationEvidence() + { + if (Evidence.UserId is not null || ActivatedByUserId == Guid.Empty) return Evidence; + return Evidence with + { + MethodId = "personal-passkey", + UserId = ActivatedByUserId, + GrantId = PositionGrantId == Guid.Empty ? null : PositionGrantId, + CredentialId = ActivatedByPasskeyCredentialId == Guid.Empty ? null : ActivatedByPasskeyCredentialId, + Binding = "dpop", + }; + } } public enum StaffingSessionStatus @@ -58,4 +81,23 @@ public enum StaffingSessionEndReason GrantSuspended, GrantRevoked, OAuthClientDisabled, + PolicyTightened, + ActivationCredentialInvalidated, + ActivationTokenRevoked, + ActivationTokenUnassigned, +} + +/// +/// Method-neutral evidence captured at activation. Optional identifiers are +/// populated only when the selected proof method owns that concept. +/// +public sealed record ActivationEvidence +{ + public required string MethodId { get; init; } + public Guid? UserId { get; init; } + public Guid? GrantId { get; init; } + public Guid? CredentialId { get; init; } + public Guid? ActivationTokenId { get; init; } + public int? TeamSecretVersion { get; init; } + public required string Binding { get; init; } } diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSessionEvents.cs b/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSessionEvents.cs index 6bc1c405..5b0af02d 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSessionEvents.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/StaffingSessionEvents.cs @@ -13,7 +13,19 @@ public record StaffingSessionStarted( Guid ActivatedByUserId, Guid ActivatedByPasskeyCredentialId, Guid PositionGrantId, - string DpopJkt, + string? DpopJkt, + string OAuthAuthorizationId, + DateTimeOffset StartedAt, + DateTimeOffset AbsoluteExpiresAt); + +/// Current wire event. V1 remains mapped and is upcast by the +/// projection into personal-passkey/dpop evidence. +public record StaffingSessionStartedV2( + Guid Id, + Guid PositionPrincipalId, + Guid TerminalEnrollmentId, + ActivationEvidence Evidence, + string? DpopJkt, string OAuthAuthorizationId, DateTimeOffset StartedAt, DateTimeOffset AbsoluteExpiresAt); diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollment.cs b/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollment.cs index be07c4ff..1db77096 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollment.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollment.cs @@ -2,7 +2,7 @@ namespace Modgud.Domain.PositionTerminals; /// /// One physical terminal slot of a position (MG-FT-03, plan §4.3). Owns exactly -/// one terminal-managed public OAuth client (1:1, enforced by the unique +/// one terminal-managed OAuth client (1:1, enforced by the unique /// indexes on / and the /// position-terminal client invariant). is empty until /// the device-flow enrollment succeeds (MG-FT-04) and immutable afterwards — @@ -18,6 +18,13 @@ public sealed class TerminalEnrollment public Guid Id { get; set; } public Guid PositionPrincipalId { get; set; } + /// V2 n:m assignment. Legacy projected rows leave this empty and + /// therefore resolve to the singleton . + public List AllowedPositionIds { get; set; } = []; + + public IReadOnlyList EffectiveAllowedPositionIds => + AllowedPositionIds.Count > 0 ? AllowedPositionIds : [PositionPrincipalId]; + public string DisplayName { get; set; } = string.Empty; public string? Location { get; set; } @@ -25,6 +32,10 @@ public sealed class TerminalEnrollment public string ClientId { get; set; } = string.Empty; public string WebAuthnRpId { get; set; } = string.Empty; + /// Immutable enrollment binding. Old projected documents and old + /// create events default to DPoP. + public string Binding { get; set; } = "dpop"; + public string? DpopJkt { get; set; } public string? EnrollmentAuthorizationId { get; set; } diff --git a/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollmentEvents.cs b/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollmentEvents.cs index 6fedbaf3..12a79cea 100644 --- a/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollmentEvents.cs +++ b/src/dotnet/Modgud.Domain/PositionTerminals/TerminalEnrollmentEvents.cs @@ -16,7 +16,15 @@ public record TerminalEnrollmentCreated( string ClientId, string WebAuthnRpId, Guid CreatedByUserId, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + string? Binding = null, + IReadOnlyList? AllowedPositionIds = null); + +public record TerminalAllowedPositionsChanged( + Guid Id, + IReadOnlyList AllowedPositionIds, + Guid ChangedByUserId, + DateTimeOffset ChangedAt); public record TerminalEnrollmentDetailsChanged( Guid Id, @@ -25,7 +33,7 @@ public record TerminalEnrollmentDetailsChanged( public record TerminalEnrollmentEnrolled( Guid Id, - string DpopJkt, + string? DpopJkt, string EnrollmentAuthorizationId, DateTimeOffset EnrolledAt); diff --git a/src/dotnet/Modgud.Domain/RealmSettings/PositionSecuritySettings.cs b/src/dotnet/Modgud.Domain/RealmSettings/PositionSecuritySettings.cs new file mode 100644 index 00000000..a973ab17 --- /dev/null +++ b/src/dotnet/Modgud.Domain/RealmSettings/PositionSecuritySettings.cs @@ -0,0 +1,14 @@ +using Modgud.Authorization.Principals; + +namespace Modgud.Domain.RealmSettings; + +/// +/// Realm-wide capability floors for position activation and terminal binding. +/// Null means no floor for that dimension. Concrete method/binding IDs remain +/// position and slot choices, respectively. +/// +public sealed record PositionSecuritySettings +{ + public ProofCapability? RequiredProofCapabilities { get; init; } + public BindingCapability? RequiredBindingCapabilities { get; init; } +} diff --git a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs index 47242a9d..81a6e025 100644 --- a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs +++ b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs @@ -68,6 +68,10 @@ public class RealmSettings /// . public ClientSessionPolicy? ClientSessions { get; set; } + /// Realm-wide capability floors for position-terminal security. + /// Null = no proof or binding floor. + public PositionSecuritySettings? PositionSecurity { get; set; } + /// Per-realm overrides for the per-IP auth rate-limit ceilings /// (native-otp, magic-link, password-reset, email-otp, email-verification, /// passkey-begin, bootstrap). Null = never configured; every policy uses its diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/PositionGrantProjection.cs b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/PositionGrantProjection.cs index 9c2b42eb..9770a5ff 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/PositionGrantProjection.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/PositionGrantProjection.cs @@ -32,4 +32,16 @@ public void Apply(PositionGrantRevoked e, PositionGrant grant) grant.RevokedAt = e.RevokedAt; grant.RevokedByUserId = e.RevokedByUserId; } + + public void Apply(PositionGrantActivationFailed e, PositionGrant grant) + { + grant.ActivationFailedCount++; + grant.ActivationLockoutEnd = e.LockedUntil; + } + + public void Apply(PositionGrantActivationSucceeded e, PositionGrant grant) + { + grant.ActivationFailedCount = 0; + grant.ActivationLockoutEnd = null; + } } diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/StaffingSessionProjection.cs b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/StaffingSessionProjection.cs index af6eb956..d3b7c7c3 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/StaffingSessionProjection.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/StaffingSessionProjection.cs @@ -17,6 +17,30 @@ public partial class StaffingSessionProjection : SingleStreamProjection new() + { + Id = e.Id, + PositionPrincipalId = e.PositionPrincipalId, + TerminalEnrollmentId = e.TerminalEnrollmentId, + ActivatedByUserId = e.Evidence.UserId ?? Guid.Empty, + ActivatedByPasskeyCredentialId = e.Evidence.CredentialId ?? Guid.Empty, + PositionGrantId = e.Evidence.GrantId ?? Guid.Empty, + Evidence = e.Evidence, DpopJkt = e.DpopJkt, OAuthAuthorizationId = e.OAuthAuthorizationId, Status = StaffingSessionStatus.Active, diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/TerminalEnrollmentProjection.cs b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/TerminalEnrollmentProjection.cs index c56f7463..487aa7cb 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/TerminalEnrollmentProjection.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Projections/PositionTerminals/TerminalEnrollmentProjection.cs @@ -13,11 +13,15 @@ public partial class TerminalEnrollmentProjection : SingleStreamProjection 0 } + ? [.. e.AllowedPositionIds] + : [e.PositionPrincipalId], DisplayName = e.DisplayName, Location = e.Location, OAuthApplicationId = e.OAuthApplicationId, ClientId = e.ClientId, WebAuthnRpId = e.WebAuthnRpId, + Binding = string.IsNullOrWhiteSpace(e.Binding) ? "dpop" : e.Binding, Status = TerminalEnrollmentStatus.Pending, CreatedAt = e.CreatedAt, CreatedByUserId = e.CreatedByUserId, @@ -29,6 +33,9 @@ public void Apply(TerminalEnrollmentDetailsChanged e, TerminalEnrollment termina terminal.Location = e.Location; } + public void Apply(TerminalAllowedPositionsChanged e, TerminalEnrollment terminal) + => terminal.AllowedPositionIds = [.. e.AllowedPositionIds]; + public void Apply(TerminalEnrollmentEnrolled e, TerminalEnrollment terminal) { // The endpoint layer guarantees Enrolled is only ever appended once per @@ -49,7 +56,7 @@ public void Apply(TerminalEnrollmentReactivated e, TerminalEnrollment terminal) { // Back to where the slot stood before the disable: Active once a key is // enrolled, otherwise still Pending (waiting for MG-FT-04's flow). - terminal.Status = terminal.DpopJkt is null + terminal.Status = terminal.EnrollmentAuthorizationId is null ? TerminalEnrollmentStatus.Pending : TerminalEnrollmentStatus.Active; terminal.DisabledAt = null; diff --git a/src/dotnet/Modgud.Infrastructure/PositionTerminals/IStaffingRevoker.cs b/src/dotnet/Modgud.Infrastructure/PositionTerminals/IStaffingRevoker.cs index 6822c1c2..678e5a76 100644 --- a/src/dotnet/Modgud.Infrastructure/PositionTerminals/IStaffingRevoker.cs +++ b/src/dotnet/Modgud.Infrastructure/PositionTerminals/IStaffingRevoker.cs @@ -34,4 +34,6 @@ public interface IStaffingRevoker Task EndAllForPasskeyAsync(Guid credentialId, StaffingSessionEndReason reason, CancellationToken ct = default); Task EndAllForGrantAsync(Guid grantId, StaffingSessionEndReason reason, CancellationToken ct = default); + Task EndAllForActivationTokenAsync(Guid activationTokenId, StaffingSessionEndReason reason, CancellationToken ct = default); + Task EndAllForActivationTokenAndPositionAsync(Guid activationTokenId, Guid positionId, StaffingSessionEndReason reason, CancellationToken ct = default); } diff --git a/src/dotnet/Modgud.Infrastructure/PositionTerminals/PositionTerminalsMartenSetup.cs b/src/dotnet/Modgud.Infrastructure/PositionTerminals/PositionTerminalsMartenSetup.cs index d5b09e46..b30f7531 100644 --- a/src/dotnet/Modgud.Infrastructure/PositionTerminals/PositionTerminalsMartenSetup.cs +++ b/src/dotnet/Modgud.Infrastructure/PositionTerminals/PositionTerminalsMartenSetup.cs @@ -37,6 +37,7 @@ public static StoreOptions UseModgudPositionTerminals(this StoreOptions options) options.Schema.For() .Identity(x => x.Id) .Index(x => x.PositionPrincipalId) + .Index(x => x.AllowedPositionIds) .Index(x => x.ClientId, x => x.IsUnique = true) .Index(x => x.OAuthApplicationId, x => x.IsUnique = true) .Index(x => x.Status) @@ -69,12 +70,32 @@ public static StoreOptions UseModgudPositionTerminals(this StoreOptions options) .Index(x => x.ActivatedByUserId) .Index(x => x.ActivatedByPasskeyCredentialId) .Index(x => x.PositionGrantId) + .Index(x => x.Evidence.MethodId) + .Index(x => x.Evidence.UserId) + .Index(x => x.Evidence.GrantId) + .Index(x => x.Evidence.CredentialId) + .Index(x => x.Evidence.ActivationTokenId) + .Index(x => x.Evidence.TeamSecretVersion) + .Index(x => x.Evidence.Binding) .Index(x => x.OAuthAuthorizationId, x => x.IsUnique = true) .Index(x => x.Status) .Index(x => x.AbsoluteExpiresAt); options.Projections.Add(ProjectionLifecycle.Inline); + options.Schema.For() + .Identity(x => x.Id) + .Index(x => x.Status) + .Index(x => x.AssignedPositionIds); + options.Schema.For() + .Identity(x => x.Id) + .Index(x => x.ActivationTokenId) + .Index(x => x.RpId); + options.Schema.For() + .Identity(x => x.Id) + .Index(x => x.ExpiresAt) + .Index(x => x.TerminalEnrollmentId, x => x.Name = "idx_activation_reg_terminal"); + // Stable event-type aliases — keeps mt_events.type rename-proof. options.Events.MapEventType("position_grant_issued"); options.Events.MapEventType("position_grant_suspended"); @@ -83,6 +104,7 @@ public static StoreOptions UseModgudPositionTerminals(this StoreOptions options) options.Events.MapEventType("terminal_enrollment_created"); options.Events.MapEventType("terminal_enrollment_details_changed"); + options.Events.MapEventType("terminal_allowed_positions_changed"); options.Events.MapEventType("terminal_enrollment_enrolled"); options.Events.MapEventType("terminal_enrollment_disabled"); options.Events.MapEventType("terminal_enrollment_reactivated"); @@ -91,6 +113,7 @@ public static StoreOptions UseModgudPositionTerminals(this StoreOptions options) options.Events.MapEventType("terminal_staffing_session_cleared"); options.Events.MapEventType("staffing_session_started"); + options.Events.MapEventType("staffing_session_started_v2"); options.Events.MapEventType("staffing_session_ended"); return options; diff --git a/src/dotnet/Modgud.Infrastructure/PositionTerminals/StaffingRevoker.cs b/src/dotnet/Modgud.Infrastructure/PositionTerminals/StaffingRevoker.cs index 2d55329a..9056ce83 100644 --- a/src/dotnet/Modgud.Infrastructure/PositionTerminals/StaffingRevoker.cs +++ b/src/dotnet/Modgud.Infrastructure/PositionTerminals/StaffingRevoker.cs @@ -63,6 +63,12 @@ public Task EndAllForPasskeyAsync(Guid credentialId, StaffingSessionEndReas public Task EndAllForGrantAsync(Guid grantId, StaffingSessionEndReason reason, CancellationToken ct = default) => EndWhereAsync(s => s.PositionGrantId == grantId, reason, ct); + public Task EndAllForActivationTokenAsync(Guid activationTokenId, StaffingSessionEndReason reason, CancellationToken ct = default) => + EndWhereAsync(s => s.Evidence.ActivationTokenId == activationTokenId, reason, ct); + + public Task EndAllForActivationTokenAndPositionAsync(Guid activationTokenId, Guid positionId, StaffingSessionEndReason reason, CancellationToken ct = default) => + EndWhereAsync(s => s.Evidence.ActivationTokenId == activationTokenId && s.PositionPrincipalId == positionId, reason, ct); + private async Task EndWhereAsync( System.Linq.Expressions.Expression> selector, StaffingSessionEndReason reason, diff --git a/src/dotnet/Modgud.Tests.Unit/Authorization/PositionTerminalSecurityContractTests.cs b/src/dotnet/Modgud.Tests.Unit/Authorization/PositionTerminalSecurityContractTests.cs new file mode 100644 index 00000000..9746c7b3 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Authorization/PositionTerminalSecurityContractTests.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using Modgud.Authorization.Principals; +using Modgud.Domain.PositionTerminals; +using Modgud.Domain.RealmSettings; +using Modgud.Infrastructure.Persistence.Marten.Projections.PositionTerminals; + +namespace Modgud.Tests.Unit.Authorization; + +public class PositionTerminalSecurityContractTests +{ + [Fact] + public void Open_id_sets_and_capability_arrays_round_trip_without_enum_ordinals() + { + var policy = new PositionTerminalPolicy + { + Enabled = true, + AllowedActivationProofs = [ActivationProofMethodIds.PersonalPasskey], + AllowedDeviceBindings = [DeviceBindingIds.Dpop], + }; + var policyJson = JsonSerializer.Serialize(policy); + var restoredPolicy = JsonSerializer.Deserialize(policyJson)!; + Assert.Equal(policy.AllowedActivationProofs, restoredPolicy.AllowedActivationProofs); + Assert.Equal(policy.AllowedDeviceBindings, restoredPolicy.AllowedDeviceBindings); + + var floor = new PositionSecuritySettings + { + RequiredProofCapabilities = ProofCapability.IdentifiedActor | + ProofCapability.PhishingResistant, + RequiredBindingCapabilities = BindingCapability.DeviceIdentity | + BindingCapability.SenderConstrained, + }; + var json = JsonSerializer.Serialize(floor); + Assert.Contains("[\"IdentifiedActor\",\"PhishingResistant\"]", json); + Assert.Contains("[\"DeviceIdentity\",\"SenderConstrained\"]", json); + Assert.Equal(floor, JsonSerializer.Deserialize(json)); + } + + [Fact] + public void Capability_floor_is_set_based_and_not_a_numeric_binding_order() + { + Assert.True(PositionTerminalSecurity.BindingMeetsFloor( + DeviceBindingIds.Dpop, + BindingCapability.DeviceIdentity | BindingCapability.SenderConstrained)); + Assert.False(PositionTerminalSecurity.BindingMeetsFloor( + DeviceBindingIds.ClientSecret, + BindingCapability.SenderConstrained)); + Assert.False(PositionTerminalSecurity.BindingMeetsFloor( + DeviceBindingIds.None, + BindingCapability.DeviceIdentity)); + Assert.True(PositionTerminalSecurity.ProofMeetsFloor( + ActivationProofMethodIds.PersonalPasskey, + ProofCapability.IdentifiedActor | + ProofCapability.PhishingResistant | + ProofCapability.IndividuallyRevocable)); + Assert.False(PositionTerminalSecurity.ProofMeetsFloor( + "removed-plugin", ProofCapability.None)); + } + + [Fact] + public void Legacy_events_upcast_to_personal_passkey_and_dpop() + { + var terminalId = Guid.NewGuid(); + var terminalJson = JsonSerializer.Serialize(new Dictionary + { + ["Id"] = terminalId, + ["PositionPrincipalId"] = Guid.NewGuid(), + ["DisplayName"] = "Legacy terminal", + ["Location"] = null, + ["OAuthApplicationId"] = Guid.NewGuid(), + ["ClientId"] = "legacy.terminal", + ["WebAuthnRpId"] = "example.test", + ["CreatedByUserId"] = Guid.NewGuid(), + ["CreatedAt"] = DateTimeOffset.UtcNow, + }); + var legacyTerminalEvent = JsonSerializer.Deserialize(terminalJson)!; + Assert.Null(legacyTerminalEvent.Binding); + var terminal = new TerminalEnrollmentProjection().Create(legacyTerminalEvent); + Assert.Equal(DeviceBindingIds.Dpop, terminal.Binding); + + var userId = Guid.NewGuid(); + var credentialId = Guid.NewGuid(); + var grantId = Guid.NewGuid(); + var legacySessionEvent = new StaffingSessionStarted( + Guid.NewGuid(), Guid.NewGuid(), terminalId, userId, credentialId, grantId, + "legacy-jkt", "legacy-auth", DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(16)); + var staffing = new StaffingSessionProjection().Create(legacySessionEvent); + Assert.Equal(ActivationProofMethodIds.PersonalPasskey, staffing.Evidence.MethodId); + Assert.Equal(DeviceBindingIds.Dpop, staffing.Evidence.Binding); + Assert.Equal(userId, staffing.Evidence.UserId); + Assert.Equal(credentialId, staffing.Evidence.CredentialId); + Assert.Equal(grantId, staffing.Evidence.GrantId); + } +} diff --git a/src/frontend-vue/e2e/81-staffing-client-ui.spec.ts b/src/frontend-vue/e2e/81-staffing-client-ui.spec.ts new file mode 100644 index 00000000..0676a4e2 --- /dev/null +++ b/src/frontend-vue/e2e/81-staffing-client-ui.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test' +import { apiLogin } from './helpers' + +const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'ABC12abc!' + +/** + * MG-FT-FLEX — verify the create-client editor as an operator actually sees it. + * + * This test deliberately stops before Create: it exercises the complete local + * form state without leaving an OAuth client, Position, or terminal slot behind. + * A one-off local run may set E2E_EPHEMERAL_ADMIN=true; in that mode the + * bootstrap account used for the run is permanently erased in finally. + */ +test('staffing is an exclusive grant with a compact, dedicated terminal profile', async ({ page }, testInfo) => { + test.setTimeout(90_000) + await apiLogin(page, ADMIN_USER, ADMIN_PASSWORD) + + const meResponse = await page.request.get('/api/account/me') + expect(meResponse.ok()).toBeTruthy() + const me = await meResponse.json() as { Id: string } + + try { + await page.goto('/admin/oauth/clients#create') + + const modal = page.locator('.modal-container') + await expect(modal).toBeVisible({ timeout: 15_000 }) + await expect(modal.getByText('OAuth-Client erstellen', { exact: true })).toBeVisible() + + // Identity remains operator-owned when the Staffing profile is selected. + const clientId = modal.getByRole('textbox', { name: /client id/i }) + const displayName = modal.getByRole('textbox', { name: /display name/i }) + await expect(clientId).toBeEnabled() + await expect(displayName).toBeEnabled() + await clientId.fill('staffing-ui-smoke') + await displayName.fill('Staffing UI Smoke') + + await modal.getByRole('tab', { name: /^flows\b/i }).click() + const staffing = modal.getByRole('option', { name: /staffing/i }).first() + await expect(staffing).toBeVisible() + await staffing.dblclick() + + // Selecting Staffing adds exactly one compact destination for its required + // metadata instead of growing the Flows tab vertically. + const terminalTab = modal.getByRole('tab', { name: /^terminal/i }) + await expect(terminalTab).toBeVisible() + await expect(modal.getByText(/zugehörige position/i)).toBeHidden() + await expect(modal.getByText(/terminalname/i)).toBeHidden() + await expect(modal.getByText(/konfiguration unvollständig \(0\)/i)).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath('01-staffing-flow.png'), fullPage: true }) + + // A mixed grant remains selectable for diagnosis, but is visibly invalid + // and Create stays blocked. Removing it restores the exclusive profile. + const authorizationCode = modal.getByRole('option', { name: /^authorization_code/i }).first() + await authorizationCode.dblclick() + await expect(modal.getByText(/konfiguration unvollständig \(1\)/i)).toBeVisible() + await expect(modal.locator('.modal-footer').getByRole('button', { name: /erstellen/i })).toBeDisabled() + await modal.getByRole('option', { name: /^authorization_code/i }).last().dblclick() + + await modal.getByRole('tab', { name: /^allgemein$/i }).click() + await expect(modal.getByText(/staffing legt client-typ und aktivstatus fest/i)).toBeVisible() + await expect(clientId).toBeEnabled() + await expect(displayName).toBeEnabled() + await expect(modal.getByRole('combobox', { name: /client-typ/i })).toBeDisabled() + + await modal.getByRole('tab', { name: /login & zustimmung/i }).click() + await expect(modal.getByText(/staffing verwendet implizite zustimmung/i)).toBeVisible() + await expect(modal.getByRole('textbox', { name: /webauthn rp-id/i })).toBeEnabled() + + await terminalTab.click() + await expect(modal.getByText(/konfiguration unvollständig \(3\)/i)).toBeVisible() + await expect(modal.getByText(/zugehörige position/i)).toBeVisible() + await expect(modal.getByRole('button', { name: /neu anlegen/i })).toBeVisible() + await expect(modal.getByRole('textbox', { name: /terminalname/i })).toBeVisible() + await expect(modal.getByRole('textbox', { name: /standort/i })).toBeVisible() + await expect(modal.getByRole('textbox', { name: /^webauthn rp-id$/i })).toBeVisible() + await expect(modal.getByRole('combobox', { name: /gerätebindung/i })).toBeVisible() + await expect(modal.getByText(/position, terminal-slot und oauth-client werden gemeinsam erstellt/i)).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('02-terminal-profile.png'), fullPage: true }) + } finally { + if (process.env.E2E_EPHEMERAL_ADMIN === 'true') { + const cleanup = await page.request.delete(`/api/admin/users/${me.Id}/permanent`, { + data: { Reason: 'Temporary MG-FT-FLEX UI smoke account cleanup' }, + }) + expect(cleanup.status(), await cleanup.text()).toBe(204) + } + } +}) diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 9178ec8f..e15889e7 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -33,6 +33,9 @@ "active": "Aktiv", "enabled": "Aktiviert", "disabled": "Deaktiviert", + "disable": "Deaktivieren", + "reactivate": "Reaktivieren", + "revoke": "Widerrufen", "statusTag": { "active": "Aktiv", "inactive": "Inaktiv" @@ -464,6 +467,7 @@ "active": "Aktiv", "activeHint": "Deaktivieren widerruft sofort alle ausstehenden Tokens dieser Position; Besetzung und Enrollment bleiben bis zur Reaktivierung blockiert.", "createTitle": "Position anlegen", + "createTerminalPositionTitle": "Neue Position für Terminal", "editTitle": "Position", "section.basics": "Basis", "section.status": "Status", @@ -471,6 +475,7 @@ "tabs.general": "Allgemein", "tabs.terminals": "Terminals", "tabs.grants": "Berechtigte Benutzer", + "tabs.tokens": "Aktivierungs-Token", "tabs.sessions": "Schichten", "validation.incomplete": "Fehlende Angaben", "terminalsEnabled": "Terminal-Nutzung", @@ -480,7 +485,25 @@ "maxSessionLifetime": "Absolutes Maximum (Minuten)", "maxSessionLifetimeHint": "Die harte Obergrenze, über die auch ein Refresh nie hinaus verlängert (1440 = 24 Stunden).", "lifetimePositive": "Laufzeiten müssen positiv sein.", - "lifetimeCeiling": "Die Session-Laufzeit darf das absolute Maximum nicht überschreiten." + "lifetimeCeiling": "Die Session-Laufzeit darf das absolute Maximum nicht überschreiten.", + "activationProofRequired": "Mindestens einen Aktivierungsnachweis auswählen.", + "deviceBindingRequired": "Mindestens eine Gerätebindung auswählen.", + "activationProofs": "Erlaubte Aktivierungsnachweise", + "activationProofsHint": "Wie eine Person nachweist, dass sie diese Position aktivieren darf.", + "activationProof": { + "personalPasskey": "Persönlicher Passkey", + "personalPassword": "Persönliches Passwort", + "personalEmailOtp": "Persönlicher E-Mail-Einmalcode", + "positionToken": "Positions-Token", + "teamSecret": "Team-Secret" + }, + "proofReserved": "Reserviert; noch nicht implementiert.", + "deviceBindings": "Erlaubte Gerätebindungen", + "deviceBindingsHint": "Die beim Anlegen eines Terminal-Slots unveränderbar gewählte Bindung.", + "availableInPhase": "Verfügbar in Phase {phase}.", + "realmFloor": "Realm-Mindeststandard", + "thisPosition": "Diese Position", + "policyConsequencesConfirm": "Diese Änderung betrifft {terminals} Terminal-Slots und beendet sofort {sessions} aktive Staffing-Sessions. Fortfahren?" }, "positionGrants": { "sectionTitle": "Berechtigte Benutzer", @@ -510,6 +533,21 @@ "locationPlaceholder": "Tor 3, …", "rpId": "WebAuthn RP-ID", "rpIdHint": "Die RP-ID, gegen die Personal-Passkeys geprüft werden — üblicherweise für alle Terminals der konsumierenden App gleich.", + "binding": "Gerätebindung", + "bindingOption": { + "dpop": { + "label": "DPoP-Schlüssel", + "hint": "Geräteschlüssel und sendergebundene Tokens" + }, + "clientSecret": { + "label": "Client-Secret", + "hint": "Vertraulicher Client mit einmalig sichtbarem Secret" + }, + "none": { + "label": "Keine Gerätebindung", + "hint": "Nur Admin-Freigabe; keine nachweisbare Geräteidentität" + } + }, "createButton": "Slot anlegen", "empty": "Noch keine Terminal-Slots.", "statusActive": "Aktiv", @@ -520,7 +558,27 @@ "reactivateButton": "Reaktivieren", "revokeButton": "Widerrufen", "revokeTitle": "Terminal widerrufen?", - "revokeConfirm": "Der Widerruf ist endgültig: das Gerät wird sofort abgeschnitten und braucht einen komplett neuen Slot (mit frischem Enrollment), um je zurückzukehren." + "revokeConfirm": "Der Widerruf ist endgültig: das Gerät wird sofort abgeschnitten und braucht einen komplett neuen Slot (mit frischem Enrollment), um je zurückzukehren.", + "allowedPositions": "Auf diesem Terminal verfügbare Positionen", + "allowedPositionsHint": "Wähle vor dem Enrollment alle kompatiblen Positionen. Eine weitere Position nachträglich hinzuzufügen erfordert einen neuen Terminal-Slot und eine neue Device-Flow-Freigabe.", + "noCompatibleAdditionalPositions": "Keine andere aktive Position erlaubt derzeit diese Gerätebindung.", + "noneWarning": "Ohne Bindung besitzt das Terminal keine nachweisbare Geräteidentität. Die Admin-Freigabe beim Enrollment ist die einzige Ausgabebarriere.", + "secretOnce": "Client-Secret jetzt kopieren — es wird nicht wieder angezeigt.", + "secretsOnce": "Position angelegt. Client-Secrets jetzt kopieren — sie werden nicht wieder angezeigt.", + "positions": "Position(en)", + "editPositions": "Positionen", + "enrolledAssignmentHint": "Dieses Terminal ist bereits enrollt. Bestehende Positionen können entfernt werden; eine weitere Position erfordert einen neu angelegten und enrollten Multi-Positions-Slot.", + "currentAssignment": "Zugeordnet" + }, + "activationTokens": { + "registrationHint": "Lege das logische Token hier an und ordne es zu. Registriere seine WebAuthn-Credential von einem enrollten Terminal aus, damit die Registrierung den RP-kompatiblen Origin der Terminal-Anwendung verwendet.", + "label": "Token-Bezeichnung", + "create": "Token anlegen", + "empty": "Der Position sind noch keine eigenen Aktivierungs-Token zugeordnet.", + "notRegistered": "Noch an keiner RP registriert", + "revokeTitle": "Aktivierungs-Token widerrufen?", + "revokeConfirm": "Der Widerruf ist endgültig und beendet sofort alle Staffing-Sessions, die mit diesem Token aktiviert wurden.", + "token": "Token" }, "staffingSessions": { "sectionTitle": "Staffing-Sessions", @@ -543,7 +601,9 @@ "passkeyDeleted": "Passkey gelöscht", "grantSuspended": "Berechtigung ausgesetzt", "grantRevoked": "Berechtigung widerrufen", - "clientDisabled": "OAuth-Client deaktiviert" + "clientDisabled": "OAuth-Client deaktiviert", + "policyTightened": "Sicherheitsrichtlinie verschärft", + "credentialInvalidated": "Aktivierungs-Credential ungültig geworden" } }, "serviceAccountCredentials": { @@ -1136,6 +1196,9 @@ "webAuthnRpIdHint": "Optional. Eigene Relying-Party-Domain für native Passkeys dieser App (z. B. app.example.com). Leer = Realm-Domain. Achtung: Eine Änderung macht alle bereits registrierten Passkeys dieser App ungültig.", "clientSecret": "Client Secret (leer = generieren)", "clientSecretHint": "Leer lassen, um beim Erstellen ein starkes einmalig sichtbares Secret zu erzeugen.", + "position": "Zugehörige Position", + "position.placeholder": "Position wählen…", + "positionHint": "Wähle genau eine terminalfähige Position oder lege sie zusammen mit diesem Client neu an.", "enabled": "Aktiv", "redirectCount": "Redirects", "grantCount": "Grants", @@ -1180,7 +1243,18 @@ "noServiceAccount": "client_credentials benötigt einen Service Account.", "newServiceAccountNameRequired": "Für den neuen Service Account ist ein Account-Name erforderlich.", "newServiceAccountNameInvalid": "Der Account-Name des neuen Service Accounts ist ungültig.", - "noAuthorizationCodeRedirect": "authorization_code benötigt mindestens eine Redirect-URI." + "noAuthorizationCodeRedirect": "authorization_code benötigt mindestens eine Redirect-URI.", + "incompleteShort": "Konfiguration unvollständig ({count})", + "invalidTerminalGrants": "staffing kann nicht mit anderen Grants kombiniert werden. device_code und refresh_token werden beim Erstellen technisch ergänzt.", + "noPosition": "Das Terminalprofil benötigt eine bestehende oder neue Position.", + "noTerminalName": "Für den Terminal-Slot ist ein Anzeigename erforderlich.", + "noTerminalRpId": "Für Personal-Passkeys ist eine WebAuthn RP-ID erforderlich.", + "positionBindingMismatch": "Die gewählte Position erlaubt diese Gerätebindung nicht.", + "newPositionNameRequired": "Für die neue Position ist ein Account-Name erforderlich.", + "newPositionNameInvalid": "Der Account-Name der neuen Position ist ungültig.", + "newPositionTerminalsDisabled": "Bei der neuen Position muss die Terminal-Nutzung aktiviert sein.", + "newPositionBindingMismatch": "Die neue Position erlaubt die gewählte Gerätebindung nicht.", + "terminalBindingBelowRealmFloor": "Die Gerätebindung erfüllt die Sicherheitsvorgabe des Realms nicht." }, "tabs": { "general": "Allgemein", @@ -1194,6 +1268,7 @@ "lifetimes": "Token-Laufzeiten", "tokensAndSessions": "Tokens & Sessions", "security": "Sicherheit", + "terminal": "Terminal", "dcr": "Registrierungs-Info" }, "lifetimesHint": "Werte in Sekunden. Leer = Default des IdP.", @@ -1244,6 +1319,8 @@ "grantTypes.helpSpa": "SPA / Mobile", "grantTypes.helpMachine": "Server-zu-Server", "grantTypes.helpDevice": "TV / CLI / Gerät ohne Browser", + "grantTypes.helpTerminal": "Geteiltes Arbeitsplatz-Terminal", + "grantTypes.helpTerminalProfile": "staffing + Position; technische Grants werden automatisch ergänzt", "grantTypes.helpSelectionTitle": "Mehrfachauswahl", "grantTypes.helpMulti": "Strg/Cmd + Klick wählt einzelne Einträge.", "grantTypes.helpRange": "Shift + Klick wählt einen Bereich.", @@ -1267,6 +1344,40 @@ "newServiceAccount.noPurpose": "Kein Verwendungszweck angegeben", "newServiceAccount.useExisting": "Vorhandenen auswählen", "newServiceAccount.invalidName": "2–64 Zeichen; nur Kleinbuchstaben, Ziffern, Punkt, Bindestrich und Unterstrich.", + "newPosition.button": "Neu anlegen", + "newPosition.discard": "Verwerfen", + "newPosition.noPurpose": "Kein Verwendungszweck angegeben", + "terminal": { + "name": "Terminalname", + "nameHint": "Benennung des physischen Slots, z. B. „Tor links“.", + "location": "Standort", + "locationHint": "Optionale Ortsangabe für Administration und Audit.", + "rpId": "WebAuthn RP-ID", + "rpIdHint": "Hostname, gegen den die Passkeys des Personals geprüft werden.", + "rpIdCreateHint": "Für Staffing erforderlich. Diese RP-ID bindet die Personal-Passkeys an den angegebenen Host.", + "binding": "Gerätebindung", + "bindingHint": "Legt das unveränderbare Authentifizierungsprofil dieses Terminal-Slots fest.", + "atomicHintShort": "Position, Terminal-Slot und OAuth-Client werden gemeinsam erstellt.", + "atomicHint": "Die Anlage erfolgt atomar: Schlägt ein Teil fehl, wird nichts gespeichert.", + "bindingMismatchShort": "Die Gerätebindung passt nicht zur Position.", + "bindingMismatch": "Die gewählte Position erlaubt diese Gerätebindung nicht. Wähle eine andere Bindung oder passe die Position an.", + "noneWarningShort": "Keine Gerätebindung ausgewählt.", + "noneWarning": "Ohne Gerätebindung besitzt das Terminal keine nachweisbare Geräteidentität; die Admin-Freigabe ist dann die einzige Ausgabebarriere.", + "identityProfileNoticeShort": "Staffing legt Client-Typ und Aktivstatus fest.", + "identityProfileNotice": "Client ID und Display Name bleiben frei wählbar. Client-Typ und Aktivstatus werden aus Gerätebindung und Terminal-Lifecycle abgeleitet.", + "loginProfileNoticeShort": "Staffing verwendet implizite Zustimmung.", + "loginProfileNotice": "Die WebAuthn RP-ID bleibt konfigurierbar, weil sie festlegt, gegen welchen Host die Personal-Passkeys geprüft werden.", + "appsProfileNoticeShort": "Staffing-Clients haben keine App-Zuordnung.", + "appsProfileNotice": "Ihr fachlicher Besitzer ist der zugeordnete Terminal-Slot der Position.", + "scopesProfileNoticeShort": "Staffing-Clients haben keine frei wählbaren Scopes.", + "scopesProfileNotice": "Ihre Berechtigungen entstehen aus Position, Besetzung und Ziel-API.", + "urlsProfileNoticeShort": "Staffing benötigt keine Redirect- oder CORS-Adressen.", + "urlsProfileNotice": "Staffing nutzt den Device Flow. Redirect-, Post-Logout- und CORS-Adressen sind deshalb für dieses Clientprofil nicht konfigurierbar.", + "securityProfileNoticeShort": "Die Gerätebindung legt Secret und DPoP fest.", + "securityProfileNotice": "Secret- und DPoP-Einstellungen werden aus der Gerätebindung abgeleitet. Das Client-Secret wird bei Bedarf sicher vom Server erzeugt.", + "lifetimesProfileNoticeShort": "Staffing verwendet Reference-Tokens und geerbte Laufzeiten.", + "lifetimesProfileNotice": "Token- und Session-Laufzeiten werden aus den übergeordneten App-/Realm- und Positionsrichtlinien übernommen." + }, "postLogoutRedirectUri": { "placeholder": "https://app.example.com/signout-callback-oidc" }, @@ -1716,6 +1827,36 @@ "rotateConfirmMessage": "Ein frischer Schlüssel wird sofort aktiv. Der aktuelle Schlüssel wird in das 30-Tage-Overlap-Fenster stillgelegt. Das kann nicht rückgängig gemacht werden.", "rotated": "Signing-Schlüssel rotiert (neuer kid {kid}). Der bisherige Schlüssel bleibt während des Overlap-Fensters für laufende Tokens gültig." }, + "positionSecurity": { + "title": "Sicherheits-Mindeststandard für Positions-Terminals", + "hint": "Jede von einer Position erlaubte Aktivierungsmethode und Terminal-Bindung muss alle ausgewählten Eigenschaften erfüllen. Eine Verschärfung wird vor dem Wirksamwerden mit ihren Folgen angezeigt.", + "proofCapabilities": "Erforderliche Eigenschaften des Aktivierungsnachweises", + "bindingCapabilities": "Erforderliche Eigenschaften der Gerätebindung", + "capability": { + "identifiedActor": { + "label": "Identifizierte Person", + "hint": "Die Aktivierung identifiziert eine konkrete Person." + }, + "phishingResistant": { + "label": "Phishing-resistent", + "hint": "Der Nachweis widersteht Credential-Weitergabe und Phishing." + }, + "individuallyRevocable": { + "label": "Einzeln widerrufbar", + "hint": "Die konkrete Aktivierungs-Credential kann eigenständig widerrufen werden." + }, + "deviceIdentity": { + "label": "Geräteidentität", + "hint": "Das Terminal besitzt eine individuelle Geräteidentität." + }, + "senderConstrained": { + "label": "Sendergebunden", + "hint": "Tokens können nur mit dem enrollten Sender-Schlüssel verwendet werden." + } + }, + "warning": "Eine bestätigte Verschärfung beendet betroffene Staffing-Sessions sofort. Nicht konforme Slots können erst wieder aktiviert werden, wenn die Richtlinie ihrer Position korrigiert wurde.", + "confirm": "Dieser Mindeststandard macht {positions} Positionen und {terminals} Terminal-Slots nicht konform und beendet sofort {sessions} aktive Staffing-Sessions. Fortfahren?" + }, "deletion": { "hint": "Steuert den Konto-Löschungs-Lebenszyklus dieses Realms. Selbst-Löschungen erhalten eine Karenzzeit, in der der Benutzer abbrechen kann; Admin-Löschungen landen im Papierkorb, der nach Ablauf der Aufbewahrung automatisch geleert wird.", "graceDays": "Karenzzeit Selbst-Löschung (Tage)", @@ -1960,9 +2101,12 @@ "terminal": "Terminal", "location": "Standort", "client": "OAuth-Client", + "binding": "Gerätebindung", "fingerprint": "Geräteschlüssel", "noKey": "fehlt", "warning": "Dieses Gerät wird dauerhaft als Terminal dieser Position registriert. Die Registrierung allein erlaubt noch keinen Alarmzugriff.", + "clientSecretHint": "Das Gerät authentifiziert sich mit seinem einmalig angezeigten Client-Secret. Die Freigabe bindet diesen Client trotzdem an den Terminal-Slot.", + "noneHint": "Dieses Terminal besitzt keine Geräteidentität. Die Admin-Freigabe ist die einzige Ausgabebarriere; schütze die Client ID und beschränke, wo das Control-Token verwendet werden darf.", "noKeyHint": "Die Geräte-Anfrage enthielt keinen Geräteschlüssel — die Registrierung wird abgelehnt. Starte die Registrierung auf dem Terminal neu.", "approve": "Terminal registrieren", "approvedTitle": "Terminal registriert", diff --git a/src/frontend-vue/src/main.ts b/src/frontend-vue/src/main.ts index 5f168281..777fe749 100644 --- a/src/frontend-vue/src/main.ts +++ b/src/frontend-vue/src/main.ts @@ -62,6 +62,7 @@ app.use(CoarIconPlugin, { 'columns-3': '', 'cog': '', 'building-2': '', + 'briefcase': '', 'scroll-text': '', 'lock': '', 'sliders-horizontal': '', diff --git a/src/frontend-vue/src/models/device.ts b/src/frontend-vue/src/models/device.ts index 10419aed..eaec5aa3 100644 --- a/src/frontend-vue/src/models/device.ts +++ b/src/frontend-vue/src/models/device.ts @@ -17,6 +17,7 @@ export interface TerminalConsentInfo { TerminalName: string Location?: string | null ClientId: string + Binding: 'dpop' | 'client-secret' | 'none' /** Null/absent when the device request carried no DPoP proof — the approval * will be refused server-side in that case. */ DpopFingerprint?: string | null diff --git a/src/frontend-vue/src/models/oauth.ts b/src/frontend-vue/src/models/oauth.ts index f6e7559a..9114b78c 100644 --- a/src/frontend-vue/src/models/oauth.ts +++ b/src/frontend-vue/src/models/oauth.ts @@ -99,6 +99,7 @@ export interface OAuthClientDto { * grid and the deep-link into the position modal. */ LinkedPositionPrincipalId?: string | null + ManagedTerminalEnrollmentId?: string | null } export interface CreateOAuthClientDto { @@ -164,6 +165,7 @@ export interface CreateOAuthClientDto { TerminalDisplayName?: string | null /** Optional physical location of that slot ("Gate 3"). */ TerminalLocation?: string | null + TerminalBinding?: 'dpop' | 'client-secret' | 'none' } export interface UpdateOAuthClientDto { diff --git a/src/frontend-vue/src/models/position.ts b/src/frontend-vue/src/models/position.ts index 1e40fd54..6929579c 100644 --- a/src/frontend-vue/src/models/position.ts +++ b/src/frontend-vue/src/models/position.ts @@ -7,6 +7,8 @@ import type { EntityStatus } from './common' export interface PositionTerminalPolicyDto { Enabled: boolean + AllowedActivationProofs: string[] + AllowedDeviceBindings: string[] StaffingSessionLifetimeMinutes: number MaximumStaffingSessionLifetimeMinutes: number } @@ -18,6 +20,7 @@ export interface PositionPrincipalDto { IsActive: boolean Status: EntityStatus TerminalPolicy: PositionTerminalPolicyDto + CreatedTerminals?: TerminalDto[] | null } export interface PositionCreateDto { @@ -35,6 +38,8 @@ export interface TerminalCreateDto { DisplayName: string Location?: string WebAuthnRpId: string + Binding?: string + AllowedPositionIds?: string[] } export interface PositionUpdateDto { @@ -42,15 +47,24 @@ export interface PositionUpdateDto { Purpose?: string | null IsActive?: boolean TerminalPolicy?: PositionTerminalPolicyUpdateDto + ConfirmTerminalPolicyConsequences?: boolean } /** Partial policy update — omitted fields keep the persisted value. */ export interface PositionTerminalPolicyUpdateDto { Enabled?: boolean + AllowedActivationProofs?: string[] + AllowedDeviceBindings?: string[] StaffingSessionLifetimeMinutes?: number MaximumStaffingSessionLifetimeMinutes?: number } +export interface PositionTerminalPolicyConsequencesDto { + TerminalIds: string[] + StaffingSessionIds: string[] + HasConsequences: boolean +} + // ── Terminal slots (MG-FT-03) ──────────────────────────────────────────── export type TerminalStatus = 'Pending' | 'Active' | 'Disabled' | 'Revoked' @@ -58,16 +72,20 @@ export type TerminalStatus = 'Pending' | 'Active' | 'Disabled' | 'Revoked' export interface TerminalDto { Id: string PositionId: string + AllowedPositionIds: string[] DisplayName: string Location?: string | null ClientId: string WebAuthnRpId: string + Binding: string Status: TerminalStatus Enrolled: boolean CreatedAt: string EnrolledAt?: string | null DisabledAt?: string | null RevokedAt?: string | null + /** Only returned once, on creation of a client-secret terminal. */ + ClientSecret?: string | null } // ── Activation grants (MG-FT-02) ───────────────────────────────────────── @@ -95,10 +113,23 @@ export type StaffingSessionStatus = 'Active' | 'Ended' export interface StaffingSessionDto { Id: string TerminalId: string - ActivatedByUserId: string + ActivatedByUserId?: string | null + ActivationProof: string + ActivationTokenId?: string | null Status: StaffingSessionStatus StartedAt: string AbsoluteExpiresAt: string EndedAt?: string | null EndReason?: string | null } + +export type ActivationTokenStatus = 'PendingRegistration' | 'Active' | 'Disabled' | 'Revoked' + +export interface ActivationTokenDto { + Id: string + Label: string + Status: ActivationTokenStatus + AssignedPositionIds: string[] + RegisteredRpIds: string[] + CreatedAt: string +} diff --git a/src/frontend-vue/src/models/realmSettings.ts b/src/frontend-vue/src/models/realmSettings.ts index 4e7f64e3..0c4ff4c0 100644 --- a/src/frontend-vue/src/models/realmSettings.ts +++ b/src/frontend-vue/src/models/realmSettings.ts @@ -10,6 +10,7 @@ export interface RealmSettingsDto { NativeGrants: NativeGrantSettingsDto BrowserSessions: BrowserSessionPolicyDto ClientSessions: ClientSessionPolicyDto + PositionSecurity: PositionSecuritySettingsDto AuthRateLimits: AuthRateLimitsDto Branding: BrandingSettingsDto EmailBranding: EmailBrandingSettingsDto @@ -28,6 +29,8 @@ export interface UpdateRealmSettingsDto { NativeGrants?: UpdateNativeGrantSettingsDto | null BrowserSessions?: UpdateBrowserSessionPolicyDto | null ClientSessions?: UpdateClientSessionPolicyDto | null + PositionSecurity?: UpdatePositionSecuritySettingsDto | null + ConfirmPositionSecurityConsequences?: boolean AuthRateLimits?: UpdateAuthRateLimitsDto | null Branding?: UpdateBrandingSettingsDto | null EmailBranding?: UpdateEmailBrandingSettingsDto | null @@ -36,6 +39,31 @@ export interface UpdateRealmSettingsDto { Audit?: UpdateAuditSettingsDto | null } +export type ProofCapability = 'IdentifiedActor' | 'PhishingResistant' | 'IndividuallyRevocable' +export type BindingCapability = 'DeviceIdentity' | 'SenderConstrained' + +export interface PositionSecuritySettingsDto { + RequiredProofCapabilities?: ProofCapability[] | null + RequiredBindingCapabilities?: BindingCapability[] | null +} + +export interface UpdatePositionSecuritySettingsDto { + RequiredProofCapabilities?: ProofCapability[] | null + RequiredBindingCapabilities?: BindingCapability[] | null +} + +export interface PositionSecurityConsequencesDto { + Positions: Array<{ + Id: string + AccountName: string + ViolatingActivationProofs: string[] + ViolatingDeviceBindings: string[] + }> + TerminalIds: string[] + StaffingSessionIds: string[] + HasConsequences: boolean +} + export interface BrowserSessionPolicyDto { IdleLifetimeMinutes: number AbsoluteLifetimeMinutes: number diff --git a/src/frontend-vue/src/stores/realmSettings.store.ts b/src/frontend-vue/src/stores/realmSettings.store.ts index ac870b28..403335b9 100644 --- a/src/frontend-vue/src/stores/realmSettings.store.ts +++ b/src/frontend-vue/src/stores/realmSettings.store.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useHttpClient } from '@/composables/useHttpClient' -import type { RealmSettingsDto, UpdateRealmSettingsDto } from '@/models/realmSettings' +import type { RealmSettingsDto, UpdateRealmSettingsDto, UpdatePositionSecuritySettingsDto, PositionSecurityConsequencesDto } from '@/models/realmSettings' /** * Realm-wide settings store. One singleton doc per tenant DB — the @@ -40,5 +40,12 @@ export const useRealmSettingsStore = defineStore('realmSettings', () => { return res?.Kid ?? '' } - return { settings, loaded, load, patch, rotateSigningKey } + async function previewPositionSecurity( + dto: UpdatePositionSecuritySettingsDto, + ): Promise { + return http.addPath('position-security').addPath('preview') + .post(dto) + } + + return { settings, loaded, load, patch, previewPositionSecurity, rotateSigningKey } }) diff --git a/src/frontend-vue/src/views/admin/RealmSettingsView.vue b/src/frontend-vue/src/views/admin/RealmSettingsView.vue index debc20e6..4388d9ea 100644 --- a/src/frontend-vue/src/views/admin/RealmSettingsView.vue +++ b/src/frontend-vue/src/views/admin/RealmSettingsView.vue @@ -47,6 +47,10 @@ import type { FieldRequirement, RealmSettingsDto, UpdateRealmSettingsDto, + PositionSecuritySettingsDto, + UpdatePositionSecuritySettingsDto, + ProofCapability, + BindingCapability, } from '@/models/realmSettings' const { t, language } = useI18n() @@ -72,6 +76,61 @@ const settingsContentRef = ref(null) const canRotateSigningKey = computed(() => authStore.hasPermission('realm-settings:write')) const rotating = ref(false) +const proofCapabilityOptions = computed>(() => [ + { + id: 'IdentifiedActor', + label: t('admin.realmSettings.positionSecurity.capability.identifiedActor.label', {}, 'Identified actor'), + hint: t('admin.realmSettings.positionSecurity.capability.identifiedActor.hint', {}, 'The activation identifies an individual actor.'), + }, + { + id: 'PhishingResistant', + label: t('admin.realmSettings.positionSecurity.capability.phishingResistant.label', {}, 'Phishing resistant'), + hint: t('admin.realmSettings.positionSecurity.capability.phishingResistant.hint', {}, 'The proof resists credential forwarding and phishing.'), + }, + { + id: 'IndividuallyRevocable', + label: t('admin.realmSettings.positionSecurity.capability.individuallyRevocable.label', {}, 'Individually revocable'), + hint: t('admin.realmSettings.positionSecurity.capability.individuallyRevocable.hint', {}, 'The concrete activation credential can be revoked on its own.'), + }, +]) +const bindingCapabilityOptions = computed>(() => [ + { + id: 'DeviceIdentity', + label: t('admin.realmSettings.positionSecurity.capability.deviceIdentity.label', {}, 'Device identity'), + hint: t('admin.realmSettings.positionSecurity.capability.deviceIdentity.hint', {}, 'The terminal has an individual device identity.'), + }, + { + id: 'SenderConstrained', + label: t('admin.realmSettings.positionSecurity.capability.senderConstrained.label', {}, 'Sender constrained'), + hint: t('admin.realmSettings.positionSecurity.capability.senderConstrained.hint', {}, 'Tokens can only be used by the enrolled sender key.'), + }, +]) +const originalPositionSecurity = ref(null) +const positionSecurityForm = ref<{ + RequiredProofCapabilities: ProofCapability[] + RequiredBindingCapabilities: BindingCapability[] +}>({ RequiredProofCapabilities: [], RequiredBindingCapabilities: [] }) + +function setPositionCapability( + collection: 'RequiredProofCapabilities' | 'RequiredBindingCapabilities', + id: ProofCapability | BindingCapability, + enabled: boolean, +) { + if (collection === 'RequiredProofCapabilities') { + const value = id as ProofCapability + const values = positionSecurityForm.value.RequiredProofCapabilities + positionSecurityForm.value.RequiredProofCapabilities = enabled + ? Array.from(new Set([...values, value])) + : values.filter((candidate) => candidate !== value) + } else { + const value = id as BindingCapability + const values = positionSecurityForm.value.RequiredBindingCapabilities + positionSecurityForm.value.RequiredBindingCapabilities = enabled + ? Array.from(new Set([...values, value])) + : values.filter((candidate) => candidate !== value) + } +} + // ── PageBuilder: pick the active page variant per slot (ADR-0001) ── const appConfig = useAppConfigStore() const pageBuilderOn = computed(() => appConfig.config.Features.PageBuilder) @@ -392,6 +451,11 @@ onMounted(async () => { browserSessionsForm.value = { ...dto.BrowserSessions } originalClientSessions.value = dto.ClientSessions clientSessionsForm.value = { ...dto.ClientSessions } + originalPositionSecurity.value = dto.PositionSecurity + positionSecurityForm.value = { + RequiredProofCapabilities: [...(dto.PositionSecurity.RequiredProofCapabilities ?? [])], + RequiredBindingCapabilities: [...(dto.PositionSecurity.RequiredBindingCapabilities ?? [])], + } originalAuthRateLimits.value = dto.AuthRateLimits authRateLimitsForm.value = authRateLimitsFromDto(dto.AuthRateLimits) originalDeletion.value = dto.Deletion @@ -542,6 +606,19 @@ function buildAuthRateLimitsPatch(): UpdateAuthRateLimitsDto | undefined { return Object.keys(patch).length === 0 ? undefined : patch } +function buildPositionSecurityPatch(): UpdatePositionSecuritySettingsDto | undefined { + const orig = originalPositionSecurity.value + if (!orig) return undefined + const proof = positionSecurityForm.value.RequiredProofCapabilities + const binding = positionSecurityForm.value.RequiredBindingCapabilities + if (arrayEqual(proof, orig.RequiredProofCapabilities ?? []) && + arrayEqual(binding, orig.RequiredBindingCapabilities ?? [])) return undefined + return { + RequiredProofCapabilities: [...proof], + RequiredBindingCapabilities: [...binding], + } +} + function buildDeletionPatch(): UpdateDeletionSettingsDto | undefined { const orig = originalDeletion.value if (!orig) return undefined @@ -594,6 +671,7 @@ function buildTabPayload(tab: SavableTabId): UpdateRealmSettingsDto { payload.NativeGrants = buildNativeGrantsPatch() } else if (tab === 'security') { payload.AuthRateLimits = buildAuthRateLimitsPatch() + payload.PositionSecurity = buildPositionSecurityPatch() } else if (tab === 'data-retention') { payload.Audit = buildAuditPatch() payload.Deletion = buildDeletionPatch() @@ -629,6 +707,11 @@ function syncSavedTab(tab: SavableTabId, updated: RealmSettingsDto) { } else if (tab === 'security') { originalAuthRateLimits.value = updated.AuthRateLimits authRateLimitsForm.value = authRateLimitsFromDto(updated.AuthRateLimits) + originalPositionSecurity.value = updated.PositionSecurity + positionSecurityForm.value = { + RequiredProofCapabilities: [...(updated.PositionSecurity.RequiredProofCapabilities ?? [])], + RequiredBindingCapabilities: [...(updated.PositionSecurity.RequiredBindingCapabilities ?? [])], + } } else if (tab === 'data-retention') { originalAudit.value = updated.Audit auditForm.value = { ...updated.Audit } @@ -686,6 +769,22 @@ async function save(tab: SavableTabId) { saving.value = true error.value = null try { + if (payload.PositionSecurity) { + const consequences = await settingsStore.previewPositionSecurity(payload.PositionSecurity) + if (consequences.HasConsequences) { + const confirmed = confirm(t( + 'admin.realmSettings.positionSecurity.confirm', + { + positions: consequences.Positions.length, + terminals: consequences.TerminalIds.length, + sessions: consequences.StaffingSessionIds.length, + }, + `This floor makes ${consequences.Positions.length} positions and ${consequences.TerminalIds.length} terminal slots non-conforming, and immediately ends ${consequences.StaffingSessionIds.length} active staffing sessions. Continue?`, + )) + if (!confirmed) return + payload.ConfirmPositionSecurityConsequences = true + } + } const updated = await settingsStore.patch(payload) syncSavedTab(tab, updated) savedFlash.value = true @@ -1016,6 +1115,50 @@ async function rotateSigningKey() {