Skip to content

Position terminals (consumer contract)

Status: behind the PositionTerminals feature 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 V2Staffing tokenStep-up token
token_useterminal_enrollmentstaffing_sessionstaffing_step_up
principal_typeterminalpositionposition
subterminal idselected position idsame position id as the staffing session
Purposecandidate selection, proof begin, local lockordinary business callsfresh proof for a sensitive business call
Audiencemodgud-terminal-control onlyresources resolved from staffing scopessame resource set, no refresh/offline scope
Lifetimerefreshable while the terminal remains Active10-minute access tokens under the session ceilingreference 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_proofamrPerson in token?
personal-passkeywebauthnnever
personal-passwordpwdnever
personal-email-otpotpnever
position-tokenwebauthnno 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.

ParameterSourceNotes
Modgud base URLdeployment
client_idconsumer provisioning requeststable, caller-selected identifier; never generated by the Management API path
terminal_idprovisioning responseShortGuid accepted directly by lock, registration, and step-up routes
client_secretcreation responseonly for client-secret; shown once
device P-256 keyterminalonly for dpop; ideally non-exportable
RP-IDslot responseWebAuthn RP for personal passkeys and position-token credentials
target App(s)managed OAuth clientowns the business scopes and APIs this terminal may call
allowed business scope(s)managed OAuth clienteach 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:

  1. Register the consumer as an OAuth API, preferably with an absolute URI such as https://alerthub.example.com/api, assigned to the AlertHub App.
  2. Register an enabled business scope, for example alerthub-terminal, whose Resources contains https://alerthub.example.com/api and whose App is the AlertHub App.
  3. On the terminal-managed OAuth client, select that App and allow that scope.
  4. Give the Position the AlertHub roles/permissions through normal groups and roles.
  5. Request scope=alerthub-terminal when 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:

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=<terminal client>
ceremony_id=<id>
assertion=<method response JSON>
scope=alerthub-terminal
resource=https://alerthub.example.com/api

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=<terminal client>
ceremony_id=<single-use-selection-ticket>
position_id=<candidate short guid>
scope=alerthub-terminal
resource=https://alerthub.example.com/api

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

Admins create and assign a logical activation token. From an enrolled terminal, register an RP-bound credential with:

  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

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:

http
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

SituationOAuth/API outcomeTerminal action
Session ended, expired, assignment removed, policy tightened, or proof evidence no longer validinteraction_required / staffing_requiredlock and require fresh activation
Wrong client/binding, reused ceremony, wrong selected positioninvalid_grant / forbiddenrestart the affected flow
Missing/invalid/replayed DPoP proof on a DPoP terminalinvalid_dpop_proof / forbiddencreate one fresh proof; never reuse jti
Adding a position to an enrolled slotTerminal.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:

csharp
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.

Released under the Apache-2.0 License.