Position terminals (consumer contract)
Status: behind the
PositionTerminalsfeature flag (default off). This page describes Control-Plane V2 and the position business-token contract.
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
Consumers MUST branch on token_use; control and business tokens have disjoint audiences and capabilities.
| 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 the V1 staffing notification events described on this page. The separately authorized Application change feed may expose an in-scope ActivatedByUserId and the open activation MethodId in its current-state projection. Those fields are synchronization context only: the Position remains the business actor and token subject.
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 |
|---|---|---|
| Modgud base URL | deployment | |
client_id | consumer provisioning request | stable, caller-selected identifier; never generated by the Management API path |
terminal_id | provisioning response | ShortGuid accepted directly 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 |
| target App(s) | managed OAuth client | owns the business scopes and APIs this terminal may call |
| allowed business scope(s) | managed OAuth client | each scope's Resources become possible staffing-token audiences |
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.
Backends should use the Management API terminal-provisioning contract: it creates or links the Position, terminal slot, and managed OAuth client atomically. The interactive admin quick-add path may still generate a convenience client id, but that is not the consumer provisioning contract.
Apps, business scopes, and the OAuth-client display name remain editable under Admin → OAuth Clients. The terminal lifecycle, grants, binding, RP-ID, and reference-token profile remain terminal-owned and locked. Changing Apps or scopes ends any current staffing session with PolicyTightened; the terminal stays enrolled and the next tap starts a token chain under the new profile.
Wiring a consumer API into aud
Audience and authorization are deliberately two separate inputs:
- Register the consumer as an OAuth API, preferably with an absolute URI such as
https://alerthub.example.com/api, assigned to the AlertHub App. - Register an enabled business scope, for example
alerthub-terminal, whoseResourcescontainshttps://alerthub.example.com/apiand whose App is the AlertHub App. - On the terminal-managed OAuth client, select that App and allow that scope.
- Give the Position the AlertHub roles/permissions through normal groups and roles.
- Request
scope=alerthub-terminalwhen redeeming the staffing proof.
The result contains aud: https://alerthub.example.com/api. Its resource_access["https://alerthub.example.com/api"] block contains the selected Position's AlertHub roles and permissions. An optional resource=https://alerthub.example.com/api parameter may narrow the scope-derived resource set, but it never grants an audience by itself. RFC 8707 requires resource values to be absolute URIs. A legacy bare API identifier can still appear in aud via the scope, but must not be sent as resource.
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:
{
"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:
grant_type=urn:cocoar:params:oauth:grant-type:staffing
client_id=<terminal client>
ceremony_id=<id>
assertion=<method response JSON>
scope=alerthub-terminal
resource=https://alerthub.example.com/apiFor 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:
{
"selectionRequired": true,
"ceremonyId": "single-use-selection-ticket",
"candidates": [
{ "id": "...", "displayName": "Reception" }
]
}Redeem the returned selection ticket once, without resending the proof:
grant_type=urn:cocoar:params:oauth:grant-type:staffing
client_id=<terminal client>
ceremony_id=<single-use-selection-ticket>
position_id=<candidate short guid>
scope=alerthub-terminal
resource=https://alerthub.example.com/apiBoth 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
Admins create and assign a logical activation token. From an enrolled terminal, register an RP-bound credential with:
POST /connect/activation-token/{tokenId}/register/begin- WebAuthn
navigator.credentials.createusing the returned options POST /connect/activation-token/{tokenId}/registerwith ceremony and attestation response
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.
4. Lock
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:
POST /connect/staffing/{terminalId}/step-up
Content-Type: application/json
{
"methodId": "personal-passkey",
"accountName": "anna",
"action": "alarm.acknowledge",
"nonce": "consumer-generated-unpredictable-value"
}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 |
|---|---|---|
| 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 |
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 remain method- and person-agnostic in Modgud.Domain.PositionTerminals.Contracts.V1:
record PositionStaffingSessionStarted(
Guid PositionPrincipalId, Guid TerminalEnrollmentId, Guid StaffingSessionId,
DateTimeOffset StartedAt, DateTimeOffset AbsoluteExpiresAt);
record PositionStaffingSessionEnded(
Guid PositionPrincipalId, Guid TerminalEnrollmentId, Guid StaffingSessionId,
StaffingSessionEndReason Reason, DateTimeOffset EndedAt);
record PositionTerminalStatusChanged(
Guid PositionPrincipalId, Guid TerminalEnrollmentId,
TerminalEnrollmentStatus Status, DateTimeOffset ChangedAt);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.