diff --git a/docs/integrate/management-api.md b/docs/integrate/management-api.md index 170e30ab..98d7b427 100644 --- a/docs/integrate/management-api.md +++ b/docs/integrate/management-api.md @@ -40,7 +40,8 @@ scope. Use this flow when no person is present: 1. Create a role containing only the required Modgud permissions. For the - currently exposed Position reads, grant `position:read`. + Position reads, grant `position:read`. Terminal provisioning needs both + `position:write` and `oauth-client:write`. 2. Create or choose a group, attach the role, and add the Service Account as a member. 3. On that Service Account, issue a credential and allow the @@ -70,6 +71,72 @@ The token's `sub` is the Service Account id. A token from an unlinked or differently linked client is rejected even if its claims were otherwise well-formed. +## Provision a terminal in one call + +`POST /api/admin/oauth/clients` is the generic client-provisioning operation. +When the request carries the staffing grant, Modgud atomically creates the +terminal-managed OAuth client and terminal slot, and either links an existing +Position or creates one inline. Nothing is committed if any part fails. + +The consumer MUST choose a stable `clientId`; Modgud does not generate one on +this path. For an existing Position: + +```http +POST /api/admin/oauth/clients +Authorization: Bearer +Content-Type: application/json + +{ + "clientId": "alerthub-gate-3", + "displayName": "AlertHub terminal: Gate 3", + "clientType": "public", + "allowedGrantTypes": [ + "urn:cocoar:params:oauth:grant-type:staffing" + ], + "linkedPositionPrincipalId": "", + "terminalDisplayName": "Gate terminal left", + "terminalLocation": "Gate 3", + "terminalBinding": "dpop", + "webAuthnRpId": "terminal.example.com", + "scopes": ["alerthub-terminal"], + "appIds": [""] +} +``` + +To create the Position in the same transaction, omit +`linkedPositionPrincipalId` and send `newPosition` instead: + +```json +{ + "accountName": "gate-3", + "purpose": "Gatehouse response position", + "terminalPolicy": { + "enabled": true + } +} +``` + +The first successful request returns `201 Created` with `client`, +`createdTerminalId`, and—only for inline creation—`createdPosition`. +`client-secret` binding also returns `clientSecret` once. The returned terminal +ShortGuid is accepted directly by the terminal routes; consumers do not need to +convert it to a canonical GUID. + +The caller-selected `clientId` is the retry key: + +- the same normalized request returns `200 OK`, the same terminal id, and + `wasAlreadyProvisioned: true`; +- a different request under the same `clientId` returns `409 Conflict`; +- a replay never repeats a one-time `clientSecret`. If its original response + was lost, rotate that secret deliberately. DPoP provisioning has no secret to + recover. + +Terminal provisioning evaluates both `oauth-client:write` and +`position:write`. Generic client creation needs `oauth-client:write`; linking +or inline-creating a Service Account additionally needs +`service-account:write`. This prevents a client administrator from minting a +credential for a more privileged machine identity. + ## Delegated-person setup Use this flow when the consumer should act with the permissions of a signed-in @@ -116,11 +183,14 @@ scheme does not turn every cookie-only admin route into a remote API. |---|---|---|---| | `GET` | `/api/position` | `position:read` | `PositionTerminals` enabled | | `GET` | `/api/position/{id}` | `position:read` | `PositionTerminals` enabled | - -Position creation, mutation, deletion, grants, terminal enrollment, and all -other admin resources remain cookie-only until their contracts are deliberately -added and tested. The [Admin endpoint reference](/reference/admin-api) is the -source of truth for the exposed surface. +| `POST` | `/api/admin/oauth/clients` | `oauth-client:write` | `position:write` for terminal provisioning; `service-account:write` for an SA link | + +Direct Position creation, mutation, deletion, grants, terminal enrollment, and +all other admin resources remain cookie-only until their contracts are +deliberately added and tested. The atomic OAuth-client create above is the +supported remote terminal-provisioning path. The +[Admin endpoint reference](/reference/admin-api) is the source of truth for the +exposed surface. ## Security rules for consumers diff --git a/docs/integrate/position-terminals.md b/docs/integrate/position-terminals.md index 8aec1dec..dbd9356e 100644 --- a/docs/integrate/position-terminals.md +++ b/docs/integrate/position-terminals.md @@ -71,8 +71,8 @@ assigned to several compatible positions before enrollment. | Parameter | Source | Notes | |---|---|---| | 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_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 | @@ -83,6 +83,12 @@ 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](./management-api#provision-a-terminal-in-one-call): +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 diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md index 37c542e9..61c7f82d 100644 --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -186,16 +186,18 @@ rejects mutating them directly). ## OAuth clients Deletes are gated by `oauth-client:write` — there is no -`oauth-client:delete` tier. +`oauth-client:delete` tier. Client creation is also a **Management API** route. +Terminal intent additionally requires `position:write`; linking or creating a +Service Account additionally requires `service-account:write`. -| Method | Path | Permission | -|---|---|---| -| `GET` | `/api/admin/oauth/clients` | `oauth-client:read` | -| `GET` | `/api/admin/oauth/clients/{id}` | `oauth-client:read` | -| `POST` | `/api/admin/oauth/clients` | `oauth-client:write` | -| `PUT` | `/api/admin/oauth/clients/{id}` | `oauth-client:write` | -| `DELETE` | `/api/admin/oauth/clients/{id}` | `oauth-client:write` | -| `POST` | `/api/admin/oauth/clients/{id}/regenerate-secret` | `oauth-client:write` | +| Method | Path | Permission | Authentication | +|---|---|---|---| +| `GET` | `/api/admin/oauth/clients` | `oauth-client:read` | Cookie only | +| `GET` | `/api/admin/oauth/clients/{id}` | `oauth-client:read` | Cookie only | +| `POST` | `/api/admin/oauth/clients` | `oauth-client:write` plus conditional resource permission | Cookie or Management API bearer | +| `PUT` | `/api/admin/oauth/clients/{id}` | `oauth-client:write` | Cookie only | +| `DELETE` | `/api/admin/oauth/clients/{id}` | `oauth-client:write` | Cookie only | +| `POST` | `/api/admin/oauth/clients/{id}/regenerate-secret` | `oauth-client:write` | Cookie only | ## OAuth scopes diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/TerminalProvisioningManagementTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/TerminalProvisioningManagementTests.cs new file mode 100644 index 00000000..bbe87840 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/TerminalProvisioningManagementTests.cs @@ -0,0 +1,391 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using BuildingBlocks.Helper; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.OAuth; +using Modgud.Application.DTOs.Positions; +using Modgud.Application.DTOs.ServiceAccount; +using Modgud.Application.Services; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.OAuth.Common; +using Modgud.Domain.OAuth.Management; +using Modgud.Domain.PositionTerminals; + +namespace Modgud.Api.Tests.Authorization; + +/// +/// Point-4 consumer contract: a trusted Service Account provisions a terminal, +/// its managed OAuth client and optionally its Position in one request. The +/// caller-selected client id is the retry key and the Service Account remains +/// the recorded actor. +/// +[Collection(IntegrationTestCollection.Name)] +public class TerminalProvisioningManagementTests : IntegrationTestBase +{ + private const string StaffingGrant = "urn:cocoar:params:oauth:grant-type:staffing"; + private const string RpId = "terminal-consumer.localhost"; + + public TerminalProvisioningManagementTests(SharedPostgresFixture fixture) : base(fixture) { } + + [Fact] + public async Task Service_account_can_provision_an_existing_position_in_one_call() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var position = await CreateEnabledPositionAsync($"provision-existing-{Guid.NewGuid():N}"); + var caller = await CreateManagementCallerAsync( + ("position", "write"), ("oauth-client", "write")); + var clientId = $"consumer-terminal-{Guid.NewGuid():N}"; + var request = ExistingPositionRequest(clientId, position.Id); + + using var response = await SendProvisioningAsync(caller.Token, request); + var body = await response.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var created = JsonSerializer.Deserialize(body, JsonOptions)!; + Assert.Equal(clientId, created.Client.ClientId); + Assert.False(created.WasAlreadyProvisioned); + Assert.Null(created.CreatedPosition); + Assert.True(ShortGuid.TryParse(created.CreatedTerminalId!, out Guid terminalId)); + + await using var query = GetTenantedSession(); + var terminal = await query.LoadAsync(terminalId, ct); + Assert.NotNull(terminal); + Assert.Equal(caller.PrincipalId, terminal.CreatedByUserId); + Assert.Equal(new ShortGuid(position.Id).Guid, terminal.PositionPrincipalId); + Assert.Equal(clientId, terminal.ClientId); + + var client = await query.Query() + .SingleAsync(candidate => candidate.ClientId == clientId, ct); + Assert.Equal(terminalId, client.ManagedTerminalEnrollmentId); + Assert.True(client.Properties.ContainsKey( + OAuthApplicationPropertyKeys.TerminalProvisioningFingerprint)); + } + + [Fact] + public async Task Service_account_can_create_position_terminal_and_client_atomically() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var caller = await CreateManagementCallerAsync( + ("position", "write"), ("oauth-client", "write")); + var clientId = $"consumer-inline-{Guid.NewGuid():N}"; + var accountName = $"inline-position-{Guid.NewGuid():N}"; + var request = new CreateOAuthClientDto + { + ClientId = clientId, + ClientType = OAuthClientTypes.Public, + DisplayName = "Consumer inline terminal", + AllowedGrantTypes = [StaffingGrant], + NewPosition = new PositionCreateDto + { + AccountName = accountName, + Purpose = "Provisioned by a consumer", + TerminalPolicy = new PositionTerminalPolicyUpdateDto { Enabled = true }, + }, + TerminalDisplayName = "Reception terminal", + TerminalLocation = "Reception", + TerminalBinding = DeviceBindingIds.Dpop, + WebAuthnRpId = RpId, + }; + + using var response = await SendProvisioningAsync(caller.Token, request); + var body = await response.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var created = JsonSerializer.Deserialize(body, JsonOptions)!; + Assert.Equal(accountName, created.CreatedPosition!.AccountName); + Assert.True(ShortGuid.TryParse(created.CreatedTerminalId!, out Guid terminalId)); + + await using var query = GetTenantedSession(); + var terminal = await query.LoadAsync(terminalId, ct); + Assert.NotNull(terminal); + var position = await query.LoadAsync( + terminal.PositionPrincipalId, ct); + Assert.Equal(accountName, position!.AccountName); + Assert.Equal(caller.PrincipalId, terminal.CreatedByUserId); + } + + [Fact] + public async Task Identical_client_secret_retry_returns_the_same_terminal_but_never_the_secret_again() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var position = await CreateEnabledPositionAsync( + $"provision-retry-{Guid.NewGuid():N}", DeviceBindingIds.ClientSecret); + var caller = await CreateManagementCallerAsync( + ("position", "write"), ("oauth-client", "write")); + var clientId = $"consumer-retry-{Guid.NewGuid():N}"; + var request = ExistingPositionRequest( + clientId, position.Id, DeviceBindingIds.ClientSecret); + + using var firstResponse = await SendProvisioningAsync(caller.Token, request); + var firstBody = await firstResponse.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Created, firstResponse.StatusCode); + var first = JsonSerializer.Deserialize(firstBody, JsonOptions)!; + Assert.False(string.IsNullOrEmpty(first.ClientSecret)); + + using var retryResponse = await SendProvisioningAsync(caller.Token, request); + var retryBody = await retryResponse.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode); + var retry = JsonSerializer.Deserialize(retryBody, JsonOptions)!; + Assert.True(retry.WasAlreadyProvisioned); + Assert.Equal(first.CreatedTerminalId, retry.CreatedTerminalId); + Assert.Null(retry.ClientSecret); + + await using var query = GetTenantedSession(); + var matching = await query.Query() + .Where(candidate => candidate.ClientId == clientId && !candidate.IsDeleted) + .ToListAsync(ct); + Assert.Single(matching); + } + + [Fact] + public async Task Same_client_id_with_different_terminal_intent_conflicts() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var position = await CreateEnabledPositionAsync($"provision-conflict-{Guid.NewGuid():N}"); + var caller = await CreateManagementCallerAsync( + ("position", "write"), ("oauth-client", "write")); + var clientId = $"consumer-conflict-{Guid.NewGuid():N}"; + var request = ExistingPositionRequest(clientId, position.Id); + + using var first = await SendProvisioningAsync(caller.Token, request); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var changed = request with { TerminalDisplayName = "A different physical terminal" }; + using var second = await SendProvisioningAsync(caller.Token, changed); + var body = await second.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + Assert.Contains("already exists", body); + } + + [Fact] + public async Task Terminal_provisioning_requires_both_management_permissions() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var position = await CreateEnabledPositionAsync($"provision-permission-{Guid.NewGuid():N}"); + var request = ExistingPositionRequest( + $"consumer-permission-{Guid.NewGuid():N}", position.Id); + + var onlyClientWrite = await CreateManagementCallerAsync(("oauth-client", "write")); + using var missingPosition = await SendProvisioningAsync(onlyClientWrite.Token, request); + var missingPositionBody = await missingPosition.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Forbidden, missingPosition.StatusCode); + Assert.Contains("position:write", missingPositionBody); + Assert.Contains("Management.PermissionDenied", missingPositionBody); + + var onlyPositionWrite = await CreateManagementCallerAsync(("position", "write")); + using var missingClient = await SendProvisioningAsync(onlyPositionWrite.Token, request); + var missingClientBody = await missingClient.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Forbidden, missingClient.StatusCode); + Assert.Contains("oauth-client:write", missingClientBody); + Assert.Contains("Management.PermissionDenied", missingClientBody); + } + + [Fact] + public async Task OAuth_client_write_cannot_mint_a_credential_for_another_service_account() + { + var ct = TestContext.Current.CancellationToken; + var caller = await CreateManagementCallerAsync(("oauth-client", "write")); + using var targetResponse = await Client.PostAsJsonAsync( + "/api/service-account", + new { AccountName = $"credential-target-{Guid.NewGuid():N}" }, + JsonOptions, + ct); + var targetBody = await targetResponse.Content.ReadAsStringAsync(ct); + Assert.True(targetResponse.IsSuccessStatusCode, targetBody); + var target = JsonSerializer.Deserialize(targetBody, JsonOptions)!; + + var request = new CreateOAuthClientDto + { + ClientId = $"foreign-sa-credential-{Guid.NewGuid():N}", + ClientType = OAuthClientTypes.Confidential, + DisplayName = "Must not be created", + AllowedGrantTypes = ["client_credentials"], + LinkedServiceAccountId = target.Id, + }; + + using var response = await SendProvisioningAsync(caller.Token, request); + var body = await response.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Contains("service-account:write", body); + Assert.Contains("Management.PermissionDenied", body); + } + + [Fact] + public async Task Terminal_provisioning_requires_a_consumer_selected_client_id() + { + var ct = TestContext.Current.CancellationToken; + SetFeatureFlag(true); + var position = await CreateEnabledPositionAsync($"provision-client-id-{Guid.NewGuid():N}"); + var caller = await CreateManagementCallerAsync( + ("position", "write"), ("oauth-client", "write")); + var request = ExistingPositionRequest("", position.Id); + + using var response = await SendProvisioningAsync(caller.Token, request); + var body = await response.Content.ReadAsStringAsync(ct); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("caller-selected client id", body); + } + + [Fact] + public async Task Terminal_provisioning_stays_dark_while_the_feature_flag_is_off() + { + SetFeatureFlag(true); + var position = await CreateEnabledPositionAsync($"provision-dark-{Guid.NewGuid():N}"); + var caller = await CreateManagementCallerAsync( + ("position", "write"), ("oauth-client", "write")); + SetFeatureFlag(false); + try + { + using var response = await SendProvisioningAsync( + caller.Token, + ExistingPositionRequest($"consumer-dark-{Guid.NewGuid():N}", position.Id)); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + finally + { + SetFeatureFlag(true); + } + } + + [Fact] + public async Task Provisioned_short_terminal_id_is_accepted_by_terminal_routes() + { + var ct = TestContext.Current.CancellationToken; + var shortId = new ShortGuid(Guid.NewGuid()).ToString(); + using var response = await Client.PostAsync( + $"/connect/staffing/{shortId}/lock", content: null, ct); + + // No token was supplied. Unauthorized proves the ShortGuid route was + // matched; the old Guid-only constraint returned 404 before auth ran. + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + private void SetFeatureFlag(bool enabled) => + Factory.Services.GetRequiredService().Features.PositionTerminals = enabled; + + private async Task CreateEnabledPositionAsync( + string accountName, + string binding = DeviceBindingIds.Dpop) + { + var ct = TestContext.Current.CancellationToken; + using var response = await Client.PostAsJsonAsync("/api/position", new + { + AccountName = accountName, + TerminalPolicy = new + { + Enabled = true, + AllowedDeviceBindings = new[] { binding }, + }, + }, JsonOptions, ct); + var body = await response.Content.ReadAsStringAsync(ct); + Assert.True(response.IsSuccessStatusCode, + $"position arrange failed ({(int)response.StatusCode}): {body}"); + return JsonSerializer.Deserialize(body, JsonOptions)!; + } + + private async Task CreateManagementCallerAsync( + params (string Resource, string Action)[] permissions) + { + var ct = TestContext.Current.CancellationToken; + var suffix = Guid.NewGuid().ToString("N"); + using var createResponse = await Client.PostAsJsonAsync( + "/api/service-account", + new { AccountName = $"terminal-provisioner-{suffix}" }, + JsonOptions, + ct); + var createBody = await createResponse.Content.ReadAsStringAsync(ct); + Assert.True(createResponse.IsSuccessStatusCode, + $"service-account arrange failed ({(int)createResponse.StatusCode}): {createBody}"); + var serviceAccount = JsonSerializer.Deserialize(createBody, JsonOptions)!; + Assert.True(ShortGuid.TryParse(serviceAccount.Id, out Guid principalId)); + + if (permissions.Length > 0) + { + var role = await Factory.CreateTestRoleAsync( + $"TerminalProvisioner_{suffix}", permissions); + await Factory.CreateTestGroupAsync( + $"TerminalProvisioners_{suffix}", [principalId], [role.Id]); + } + + var managementClientId = $"terminal-provisioner-client-{suffix}"; + using (var scope = Factory.Services.CreateScope()) + { + var oauth = scope.ServiceProvider.GetRequiredService(); + var result = await oauth.CreateClientAsync(new CreateOAuthClientDto + { + ClientId = managementClientId, + ClientSecret = $"{managementClientId}-secret", + ClientType = OAuthClientTypes.Confidential, + ConsentType = OAuthConsentTypes.Implicit, + DisplayName = managementClientId, + Scopes = [ModgudManagementApi.Scope], + AllowedGrantTypes = ["client_credentials"], + RequireConsent = false, + AccessTokenType = AccessTokenType.Reference, + LinkedServiceAccountId = serviceAccount.Id, + }, ct); + if (result.IsError) + throw new InvalidOperationException(string.Join(", ", + result.Errors.Select(error => error.Description))); + } + + var form = new List> + { + new("grant_type", "client_credentials"), + new("client_id", managementClientId), + new("client_secret", $"{managementClientId}-secret"), + new("scope", ModgudManagementApi.Scope), + new("resource", ModgudManagementApi.Audience), + }; + using var tokenClient = Factory.CreateClient(); + using var tokenResponse = await tokenClient.PostAsync( + "/connect/token", new FormUrlEncodedContent(form), ct); + var tokenBody = await tokenResponse.Content.ReadAsStringAsync(ct); + Assert.True(tokenResponse.IsSuccessStatusCode, + $"client_credentials failed ({(int)tokenResponse.StatusCode}): {tokenBody}"); + using var tokenDocument = JsonDocument.Parse(tokenBody); + return new ManagementCaller( + principalId, + tokenDocument.RootElement.GetProperty("access_token").GetString()!); + } + + private static CreateOAuthClientDto ExistingPositionRequest( + string clientId, + string positionId, + string binding = DeviceBindingIds.Dpop) => new() + { + ClientId = clientId, + ClientType = binding == DeviceBindingIds.ClientSecret + ? OAuthClientTypes.Confidential + : OAuthClientTypes.Public, + DisplayName = "Consumer terminal client", + AllowedGrantTypes = [StaffingGrant], + LinkedPositionPrincipalId = positionId, + TerminalDisplayName = "Gate terminal left", + TerminalLocation = "Gate 3", + TerminalBinding = binding, + WebAuthnRpId = RpId, + }; + + private async Task SendProvisioningAsync( + string token, + CreateOAuthClientDto body) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/oauth/clients") + { + Content = JsonContent.Create(body, options: JsonOptions), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var client = Factory.CreateClient(); + return await client.SendAsync(request, TestContext.Current.CancellationToken); + } + + private sealed record ManagementCaller(Guid PrincipalId, string Token); +} diff --git a/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs b/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs index b6d7e3be..a093aa6b 100644 --- a/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Positions/TerminalClientFromClientSideTests.cs @@ -43,7 +43,7 @@ public async Task The_client_side_create_is_dark_while_the_feature_flag_is_off() SetFeatureFlag(false); var resp = await PostClientAsync(new { - ClientId = "", + ClientId = "tc-inline-client", ClientType = "public", AllowedGrantTypes = new[] { StaffingGrant }, NewPosition = new { AccountName = "tc-dark", TerminalPolicy = new { Enabled = true } }, @@ -240,7 +240,7 @@ public async Task A_staffing_client_with_an_inline_position_creates_position_slo var resp = await PostClientAsync(new { - ClientId = "", + ClientId = "tc-inline-client", ClientType = "public", AllowedGrantTypes = new[] { StaffingGrant, "refresh_token" }, NewPosition = new @@ -274,7 +274,7 @@ 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("terminal.", slot.ClientId); + Assert.Equal("tc-inline-client", slot.ClientId); Assert.Equal(created.GetProperty("Client").GetProperty("ClientId").GetString(), slot.ClientId); } @@ -286,7 +286,7 @@ public async Task An_inline_position_rejects_unknown_policy_ids_like_the_positio var resp = await PostClientAsync(new { - ClientId = "", + ClientId = "tc-invalid-policy-client", ClientType = "public", AllowedGrantTypes = new[] { StaffingGrant }, NewPosition = new @@ -316,7 +316,7 @@ public async Task An_inline_position_stages_grant_users_in_the_same_save() var resp = await PostClientAsync(new { - ClientId = "", + ClientId = "tc-grants-client", ClientType = "public", AllowedGrantTypes = new[] { StaffingGrant }, NewPosition = new diff --git a/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs index 9a08dacc..d60b44c3 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/OAuth/OAuthClientsEndpoints.cs @@ -1,6 +1,7 @@ using BuildingBlocks.EventDispatcher; using BuildingBlocks.Helper; using Marten; +using Modgud.Api.Features.Management; using Modgud.Api.Features.Positions; using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; @@ -42,14 +43,20 @@ public static WebApplication MapOAuthClientsEndpoints(this WebApplication app, s .WithName("OAuth_Clients_Get") .RequiresPermission("oauth-client:read"); - group.MapPost("", async (CreateOAuthClientDto dto, HttpContext http, AppSettings settings, IPermissionService permissions, OAuthAdminService svc, IDocumentSession session, DataEventDispatcher dispatcher, CancellationToken ct) => + // Client creation is deliberately exposed through the Management API: + // it is the generic provisioning seam for both ordinary clients and + // terminal-managed clients. The remaining group stays cookie-only. + app.MapPost($"{path}/admin/oauth/clients", async (CreateOAuthClientDto dto, HttpContext http, AppSettings settings, IPermissionService permissions, OAuthAdminService svc, IDocumentSession session, DataEventDispatcher dispatcher, CancellationToken ct) => { - if (dto.NewServiceAccount is not null) + var actorId = ResolveActorId(http); + if (actorId is null) return Results.Unauthorized(); + + if (dto.NewServiceAccount is not null || + !string.IsNullOrWhiteSpace(dto.LinkedServiceAccountId)) { - var userId = http.GetUserId(); - if (userId is null || !await permissions.HasPermissionAsync( - userId.Value, AppSlugs.Modgud, "service-account:write", ct)) - return Results.Forbid(); + if (!await permissions.HasPermissionAsync( + actorId.Value, AppSlugs.Modgud, "service-account:write", ct)) + return ManagementForbidden("service-account:write"); } // MG-FT — terminal-managed create: 404 while the feature flag is off @@ -58,16 +65,15 @@ public static WebApplication MapOAuthClientsEndpoints(this WebApplication app, s if (OAuthAdminService.HasTerminalClientIntent(dto)) { if (!settings.Features.PositionTerminals) return Results.NotFound(); - var userId = http.GetUserId(); - if (userId is null || !await permissions.HasPermissionAsync( - userId.Value, AppSlugs.Modgud, "position:write", ct)) - return Results.Forbid(); + if (!await permissions.HasPermissionAsync( + actorId.Value, AppSlugs.Modgud, "position:write", ct)) + return ManagementForbidden("position:write"); } var result = await svc.CreateClientAsync( - dto, dcrMetadata: null, enlistInTransaction: null, actorId: http.GetUserId(), ct); + dto, dcrMetadata: null, enlistInTransaction: null, actorId: actorId.Value, ct); // Broadcast only the client view (never the one-time secret in the wrapper). - if (!result.IsError) + if (!result.IsError && !result.Value.WasAlreadyProvisioned) { dispatcher.DispatchCreatedEvent("OAuthClient", result.Value.Client, session.TenantId); if (result.Value.CreatedServiceAccount is { } serviceAccount) @@ -78,10 +84,13 @@ public static WebApplication MapOAuthClientsEndpoints(this WebApplication app, s dispatcher.DispatchCreatedEvent("Terminal", await PositionTerminalsEndpoints.LoadDtoAsync(session, terminalGuid, ct), session.TenantId); } - return result.ToResult(created => Results.Created($"{path}/admin/oauth/clients/{created.Client.Id}", created)); + return result.ToResult(created => created.WasAlreadyProvisioned + ? Results.Ok(created) + : Results.Created($"{path}/admin/oauth/clients/{created.Client.Id}", created)); }) .WithName("OAuth_Clients_Create") - .RequiresPermission("oauth-client:write"); + .WithTags("OAuth Clients") + .RequiresManagementPermission("oauth-client:write"); group.MapPut("{id}", async (string id, UpdateOAuthClientDto dto, OAuthAdminService svc, IDocumentSession session, DataEventDispatcher dispatcher, CancellationToken ct) => { @@ -115,4 +124,23 @@ public static WebApplication MapOAuthClientsEndpoints(this WebApplication app, s return app; } + + private static Guid? ResolveActorId(HttpContext http) + { + var nameIdentifier = http.GetUserId(); + if (nameIdentifier.HasValue) return nameIdentifier; + + var subject = http.User.FindFirst("sub")?.Value; + return Guid.TryParse(subject, out var principalId) ? principalId : null; + } + + private static IResult ManagementForbidden(string permission) => + Results.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: "Forbidden", + detail: $"Missing the required permission '{permission}' (app '{AppSlugs.Modgud}').", + extensions: new Dictionary + { + ["code"] = "Management.PermissionDenied", + }); } diff --git a/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs index c0db45a1..aefb3e75 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/Staffing/StaffingEndpoints.cs @@ -55,7 +55,10 @@ public static WebApplication MapStaffingEndpoints(this WebApplication applicatio // access token); DPoP-proofed with the slot's enrolled key. Path note: // under /connect like the other terminal-control surfaces (the plan // sketch shows it root-level). - application.MapPost("/connect/staffing/{terminalId:guid}/lock", LockAsync) + // ShortGuid is Modgud's public admin-wire representation; accept both + // it and the canonical Guid so the provisioning response can be used + // directly without consumer-side identifier conversion. + application.MapPost("/connect/staffing/{terminalId}/lock", LockAsync) .WithName("Staffing_Lock") .WithTags("Position Staffing") .DisableAntiforgery() @@ -64,7 +67,7 @@ public static WebApplication MapStaffingEndpoints(this WebApplication applicatio AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, }); - application.MapPost("/connect/staffing/{terminalId:guid}/step-up", StepUpBeginAsync) + application.MapPost("/connect/staffing/{terminalId}/step-up", StepUpBeginAsync) .WithName("Staffing_StepUpBegin") .WithTags("Position Staffing") .DisableAntiforgery() @@ -101,7 +104,7 @@ public static WebApplication MapStaffingEndpoints(this WebApplication applicatio } private static async Task StepUpBeginAsync( - Guid terminalId, + ShortGuid terminalId, StepUpBeginInput input, HttpContext context, AppSettings settings, @@ -111,12 +114,13 @@ private static async Task StepUpBeginAsync( CancellationToken ct) { if (!settings.Features.PositionTerminals) return Results.NotFound(); + var terminalGuid = terminalId.Guid; 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 || + tokenTerminalId != terminalGuid || !Guid.TryParse(principal.GetClaim(PositionTokenClaimTypes.StaffingSessionId), out var staffingSessionId)) return Forbidden("Staffing.InvalidToken", "An active staffing access token is required."); @@ -130,11 +134,11 @@ private static async Task StepUpBeginAsync( }); var staffing = await session.LoadAsync(staffingSessionId, ct); - var terminal = await session.LoadAsync(terminalId, ct); + var terminal = await session.LoadAsync(terminalGuid, 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 || + staffing.TerminalEnrollmentId != terminalGuid || staffing.PositionPrincipalId != positionId || terminal is not { Status: TerminalEnrollmentStatus.Active } || terminal.ActiveStaffingSessionId != staffing.Id || !terminal.EffectiveAllowedPositionIds.Contains(positionId) || @@ -238,7 +242,7 @@ private static async Task ListSessionsAsync( } private static async Task LockAsync( - Guid terminalId, + ShortGuid terminalId, HttpContext context, AppSettings settings, IDocumentSession session, @@ -247,6 +251,7 @@ private static async Task LockAsync( CancellationToken ct) { if (!settings.Features.PositionTerminals) return Results.NotFound(); + var terminalGuid = terminalId.Guid; var principal = context.User; var tokenUse = principal.GetClaim(PositionTokenClaimTypes.TokenUse); @@ -257,12 +262,12 @@ private static async Task LockAsync( // Own terminal only — the token's terminal claim must be the route's. if (!Guid.TryParse(principal.GetClaim(PositionTokenClaimTypes.TerminalId), out var tokenTerminalId) || - tokenTerminalId != terminalId) + tokenTerminalId != terminalGuid) { return Forbidden("Staffing.ForeignTerminal", "The token does not belong to this terminal."); } - var terminal = await session.LoadAsync(terminalId, ct); + var terminal = await session.LoadAsync(terminalGuid, ct); if (terminal is null) return Forbidden("Staffing.InvalidToken", "A position terminal token is required."); @@ -295,7 +300,7 @@ private static async Task LockAsync( } } - var ended = await revoker.EndAllForTerminalAsync(terminalId, StaffingSessionEndReason.LocalLock, ct); + var ended = await revoker.EndAllForTerminalAsync(terminalGuid, StaffingSessionEndReason.LocalLock, ct); return Results.Ok(new { Ended = ended }); } diff --git a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs index f4990f42..911216d2 100644 --- a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs @@ -305,6 +305,14 @@ public record OAuthClientCreatedDto public string? ClientSecret { get; init; } public ServiceAccountDto? CreatedServiceAccount { get; init; } + /// + /// True when a terminal-provisioning retry used the same caller-selected + /// client id and the same normalized request. A one-time client secret is + /// never repeated; callers using the client-secret binding must rotate it + /// if the original successful response was lost. + /// + public bool WasAlreadyProvisioned { get; init; } + /// /// The Position created inline via — /// the terminal counterpart of . Null when the diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs index ec5f186c..be3a95d1 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminService.Terminals.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using BuildingBlocks.Helper; using ErrorOr; using Marten; @@ -106,6 +109,55 @@ private async Task> CreateTerminalClientAsync( if (terminalDisplayName.Length == 0) return OAuthErrors.TerminalDisplayNameRequired; + // Provisioning is consumer-addressed. The client id is both the OAuth + // protocol identifier and the natural idempotency key; the server must + // never invent it for this generic create surface. + var clientId = (dto.ClientId ?? string.Empty).Trim(); + string? provisioningFingerprint = null; + if (clientId.Length > 0) + { + provisioningFingerprint = ComputeTerminalProvisioningFingerprint(dto, binding); + var existingClient = await _session.Query() + .FirstOrDefaultAsync(x => !x.IsDeleted && x.ClientId == clientId, ct); + if (existingClient is not null) + { + if (!string.Equals( + ReadStringProperty(existingClient.Properties, + OAuthApplicationPropertyKeys.TerminalProvisioningFingerprint), + provisioningFingerprint, + StringComparison.Ordinal) || + existingClient.ManagedTerminalEnrollmentId is not { } existingTerminalId) + { + return OAuthErrors.ClientIdAlreadyExists(clientId); + } + + var existingTerminal = await _session.LoadAsync(existingTerminalId, ct); + if (existingTerminal is null) + return Error.Conflict("OAuth.TerminalProvisioningStateMissing", + $"OAuth client '{clientId}' has no corresponding terminal enrollment."); + + PositionPrincipalDto? replayedPosition = null; + if (dto.NewPosition is not null) + { + var replayPosition = await _session.LoadAsync( + existingTerminal.PositionPrincipalId, ct); + if (replayPosition is null || replayPosition.IsDeleted) + return Error.Conflict("OAuth.TerminalProvisioningStateMissing", + $"OAuth client '{clientId}' has no corresponding Position."); + replayedPosition = MapPosition(replayPosition); + } + + return new OAuthClientCreatedDto + { + Client = MapClient(existingClient), + ClientSecret = null, + CreatedPosition = replayedPosition, + CreatedTerminalId = new ShortGuid(existingTerminalId).ToString(), + WasAlreadyProvisioned = true, + }; + } + } + var realm = await _session.LoadAsync(RealmSettingsDoc.SingletonId, ct); var proofFloor = realm?.PositionSecurity?.RequiredProofCapabilities ?? ProofCapability.None; var bindingFloor = realm?.PositionSecurity?.RequiredBindingCapabilities ?? BindingCapability.None; @@ -219,21 +271,7 @@ private async Task> CreateTerminalClientAsync( _session.Events.StartStream(position.Id, new PositionPrincipalCreatedEvent( position.Id, position.AccountName, position.Purpose, position.IsActive, position.TerminalPolicy)); - createdPosition = new PositionPrincipalDto - { - Id = new ShortGuid(position.Id).ToString(), - AccountName = position.AccountName, - Purpose = position.Purpose, - IsActive = position.IsActive, - 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, - }, - }; + createdPosition = MapPosition(position); } if (!position.TerminalPolicy.Enabled) @@ -245,31 +283,6 @@ private async Task> CreateTerminalClientAsync( 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)) - { - return OAuthErrors.ClientIdAlreadyExists(clientId); - } - var clientDisplayName = string.IsNullOrWhiteSpace(dto.DisplayName) ? $"{position.DisplayName} — {terminalDisplayName}" : dto.DisplayName.Trim(); @@ -278,13 +291,25 @@ private async Task> CreateTerminalClientAsync( if (accessResult.IsError) return accessResult.Errors; + // Preserve the established validation order for malformed terminal + // drafts, then enforce the provisioning identity immediately before + // anything can be committed. + if (string.IsNullOrWhiteSpace(dto.WebAuthnRpId)) + return OAuthErrors.InvalidPositionTerminalClient( + "a WebAuthn RP ID is required — the staffing tap verifies staff passkeys against it."); + if (ValidateWebAuthnRpId(dto.WebAuthnRpId) is { } rpIdError) + return rpIdError; + if (clientId.Length == 0) + return Error.Validation("OAuth.TerminalClientIdRequired", + "A terminal-provisioning request requires a caller-selected client id."); + // ── Stage client + enrollment, commit once ───────────────────────── var enrollmentId = Guid.NewGuid(); var applicationId = Guid.NewGuid(); var clientError = StageCreateTerminalClient( applicationId, clientId, clientDisplayName, position.Id, enrollmentId, dto.WebAuthnRpId ?? string.Empty, - binding, accessResult.Value, out var clientSecret); + binding, accessResult.Value, out var clientSecret, provisioningFingerprint!); if (clientError is not null) return clientError.Value; @@ -332,7 +357,8 @@ private async Task> CreateTerminalClientAsync( string webAuthnRpId, string binding, TerminalClientAccessConfiguration access, - out string? clientSecret) + out string? clientSecret, + string? provisioningFingerprint = null) { clientSecret = null; var grants = TerminalGrantTypes.ToList(); @@ -376,11 +402,15 @@ private async Task> CreateTerminalClientAsync( [OAuthApplicationSettingKeys.WebAuthnRpId] = webAuthnRpId.Trim().ToLowerInvariant(), })); - _session.Events.Append(applicationId, aggregate.SetProperties(BuildClientProperties( + var properties = BuildClientProperties( enabled: true, allowBrowser: false, requireSecret: requireSecret, enableLocal: false, requireConsent: false, allowRemember: false, corsOrigins: [], alwaysSend: false, updateClaims: false, claims: [], roles: [], - requireDpop: requireDpop, requireDpopNonce: false))); + requireDpop: requireDpop, requireDpopNonce: false); + if (!string.IsNullOrEmpty(provisioningFingerprint)) + properties[OAuthApplicationPropertyKeys.TerminalProvisioningFingerprint] = + provisioningFingerprint; + _session.Events.Append(applicationId, aggregate.SetProperties(properties)); // V2 links the client to the terminal only. The position set belongs to // the slot and may change independently; legacy streams retain their @@ -402,6 +432,104 @@ private async Task> CreateTerminalClientAsync( return null; } + private static PositionPrincipalDto MapPosition(PositionPrincipal position) => new() + { + Id = new ShortGuid(position.Id).ToString(), + AccountName = position.AccountName, + Purpose = position.Purpose, + IsActive = position.IsActive, + 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, + }, + }; + + private static string ComputeTerminalProvisioningFingerprint( + CreateOAuthClientDto dto, + string binding) + { + var newPosition = dto.NewPosition; + var policy = newPosition?.TerminalPolicy; + var normalized = new + { + Version = 1, + ClientId = (dto.ClientId ?? string.Empty).Trim(), + DisplayName = NormalizeOptional(dto.DisplayName), + ClientType = dto.ClientType, + LinkedPositionPrincipalId = NormalizeIdentifier(dto.LinkedPositionPrincipalId), + NewPosition = newPosition is null ? null : new + { + AccountName = (newPosition.AccountName ?? string.Empty).Trim().ToLowerInvariant(), + Purpose = NormalizeOptional(newPosition.Purpose), + newPosition.IsActive, + GrantUserIds = NormalizeIdentifiers(newPosition.GrantUserIds), + TerminalPolicy = policy is null ? null : new + { + policy.Enabled, + AllowedActivationProofs = NormalizeStrings(policy.AllowedActivationProofs), + AllowedDeviceBindings = NormalizeStrings(policy.AllowedDeviceBindings), + policy.StaffingSessionLifetimeMinutes, + policy.MaximumStaffingSessionLifetimeMinutes, + }, + }, + TerminalDisplayName = (dto.TerminalDisplayName ?? string.Empty).Trim(), + TerminalLocation = NormalizeOptional(dto.TerminalLocation), + WebAuthnRpId = (dto.WebAuthnRpId ?? string.Empty).Trim().ToLowerInvariant(), + Binding = binding, + Scopes = NormalizeStrings(dto.Scopes), + AppIds = NormalizeIdentifiers(dto.AppIds), + }; + + var json = JsonSerializer.Serialize(normalized); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); + return $"v1:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string? NormalizeIdentifier(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + return ShortGuid.TryParse(value.Trim(), out Guid id) + ? id.ToString("D") + : value.Trim(); + } + + private static string[] NormalizeIdentifiers(IEnumerable? values) => + values?.Select(NormalizeIdentifier) + .Where(value => value is not null) + .Select(value => value!) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray() ?? []; + + private static string[] NormalizeStrings(IEnumerable? values) => + values?.Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray() ?? []; + + private static string? ReadStringProperty( + IReadOnlyDictionary properties, + string key) + { + if (!properties.TryGetValue(key, out var value)) return null; + return value switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } json => json.GetString(), + _ => null, + }; + } + /// /// Resolves and validates the business scopes/apps for a managed terminal /// client. Unlike a normal OAuth client, a terminal client may only carry diff --git a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs index 78f8b9c2..bb49ecf3 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs @@ -38,6 +38,16 @@ public static class OAuthApplicationPropertyKeys public const string ClientClaims = "modgud:client_claims"; public const string Roles = "modgud:roles"; + /// + /// Versioned hash of the normalized terminal-provisioning request. The + /// caller-chosen client id is the natural idempotency key: the same request + /// may safely be replayed, while a different request for that id conflicts. + /// This is server-owned metadata and is never accepted from generic client + /// property input. + /// + public const string TerminalProvisioningFingerprint = + "modgud:terminal_provisioning_fingerprint"; + /// /// RFC 9449 (#118) — boolean. When true, this client MUST present a /// valid DPoP proof at /connect/token; a tokenless request is rejected diff --git a/src/frontend-vue/src/models/oauth.ts b/src/frontend-vue/src/models/oauth.ts index 9114b78c..b3be19d0 100644 --- a/src/frontend-vue/src/models/oauth.ts +++ b/src/frontend-vue/src/models/oauth.ts @@ -225,6 +225,8 @@ export interface OAuthClientCreatedDto { Client: OAuthClientDto ClientSecret?: string | null CreatedServiceAccount?: ServiceAccountDto | null + /** True when the same consumer-selected ClientId and provisioning request were replayed. */ + WasAlreadyProvisioned?: boolean /** Position created inline via NewPosition — the terminal counterpart of CreatedServiceAccount. */ CreatedPosition?: PositionPrincipalDto | null /** ShortGuid of the terminal slot created alongside a terminal-managed client. */