From ec71a02b0241ef3bf061554b8398ab6d340c3ad5 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 28 Jun 2026 21:22:47 +0200 Subject: [PATCH] feat(native-passkey): Bearer-authenticated passkey list + delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the cookieless, Bearer-authenticated half of native passkey management so a native client (or a brokering web BFF) can show "your passkeys" and revoke a lost device from the access token it already holds — closing the gap left by the enroll/login ceremony, where the only list/delete surface was the cookie-based realm UI (which needs a Modgud session). - GET /connect/passkey — the token subject's own passkeys, user-scoped (mirrors the cookie-based Passkey_List so both management surfaces agree). - DELETE /connect/passkey/{id} — revoke one own passkey; an unknown id or one owned by another user is a 404 (never 403 — no cross-user oracle). Both gated by the per-realm NativeGrants flag and the OpenIddict validation (Bearer) scheme, exactly like the enroll endpoints. The NativeGrants gate and the store-backed (SecurityStamp-authoritative) principal resolution are lifted into a shared NativeBearerEndpointSupport so the enroll and management paths cannot drift. Tests: 8 new integration tests (list owner-scoping + 401 + flag-off; delete own/foreign/unknown/anon; and a delete-then-native-login-fails end-to-end proving a revoked passkey is no longer redeemable). Full Cocoar native-grant suite green (35). Docs: native-apps.md "Manage passkeys" section + error rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/integrate/native-apps.md | 30 ++ .../CocoarPasskeyGrantFlowTests.Management.cs | 264 ++++++++++++++++++ .../CocoarPasskeyGrantFlowTests.cs | 2 +- .../Auth/NativeBearerEndpointSupport.cs | 57 ++++ .../Auth/NativePasskeyEnrollEndpoints.cs | 46 +-- .../Auth/NativePasskeyManagementEndpoints.cs | 101 +++++++ src/dotnet/Modgud.Api/Program.cs | 1 + 7 files changed, 458 insertions(+), 43 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.Management.cs create mode 100644 src/dotnet/Modgud.Api/Features/Auth/NativeBearerEndpointSupport.cs create mode 100644 src/dotnet/Modgud.Api/Features/Auth/NativePasskeyManagementEndpoints.cs diff --git a/docs/integrate/native-apps.md b/docs/integrate/native-apps.md index cc72ea85..37c5c9e1 100644 --- a/docs/integrate/native-apps.md +++ b/docs/integrate/native-apps.md @@ -272,6 +272,33 @@ A passkey is bound to one RP-ID, so the first time a user opens a *new* app they From then on, the app uses **Flow 3 (passkey)** as the steady-state login. +### Manage passkeys — list & revoke + +A profile screen (web BFF or native) lists the user's passkeys and lets them remove a lost or stale one — cookielessly, from the **same access token** the app already holds (no Modgud session needed). Both endpoints are Bearer-authenticated, gated by the realm's native-grants flag, and **strictly owner-scoped**: a caller only ever sees or deletes credentials owned by the token's subject. + +**List** the subject's passkeys: + +```http +GET /connect/passkey +Authorization: Bearer +``` +```json +[ + { "Id": "0190…", "DisplayName": "Passkey", "CreatedAt": "2026-06-28T09:00:00Z", "LastUsedAt": "2026-06-28T10:15:00Z" } +] +``` + +`Id` is the credential's stable management id (**not** the raw WebAuthn credential id) — pass it to delete. `LastUsedAt` is omitted while the passkey has never been used. + +**Revoke** one passkey: + +```http +DELETE /connect/passkey/{id} +Authorization: Bearer +``` + +Returns `204 No Content`. A deleted passkey can no longer satisfy a `urn:cocoar:passkey` assertion. An id that doesn't exist **or** belongs to another user is a `404` (never a `403`) — the endpoint is not a cross-user credential-existence oracle. + ### Tokens: storage, refresh, revocation - **Store** the refresh token in the Keychain. Access tokens are short-lived by design (per-realm native lifetime, default 15 min). @@ -294,6 +321,9 @@ From then on, the app uses **Flow 3 (passkey)** as the steady-state login. | TOTP required but missing/invalid (OTP & magic flows) | `invalid_grant` ("Two-factor authentication is required; supply totp_code.") | | Rate limit hit (OTP request, passkey begin, token endpoint) | `429 Too Many Requests` — back off. The per-IP ceilings are realm-configurable under [Realm Settings → Rate Limits](../admin/realm-settings#rate-limits) (defaults unchanged). | | Passkey begin while realm has no primary domain | `503` (admin must set the realm/client RP-ID) | +| Passkey list / delete without a valid Bearer token | `401 Unauthorized` | +| Passkey delete of an unknown id **or** one owned by another user | `404 Not Found` — never `403`, so it is not a cross-user existence oracle | +| Passkey enroll / list / delete while the realm has native grants off | `400` `NativeGrants.Disabled` | ### Discovery diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.Management.cs b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.Management.cs new file mode 100644 index 00000000..0d3c4413 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.Management.cs @@ -0,0 +1,264 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text.Json; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.RealmSettings; +using Modgud.Authentication.Domain; +using Modgud.Authentication.RealmSettings; +using Modgud.Domain.OAuth.Common; + +namespace Modgud.Api.Tests.Authorization; + +/// +/// The cookieless, Bearer-authenticated passkey MANAGEMENT pair +/// (GET /connect/passkey + DELETE /connect/passkey/{id}): a native +/// client or brokering BFF lists and revokes the token subject's own passkeys +/// without a Modgud cookie/session. Covers the NativeGrants gate, the Bearer +/// requirement, strict owner-scoping (a foreign / unknown id is a 404, never a 403), +/// and the end-to-end guarantee that a deleted passkey can no longer satisfy a +/// urn:cocoar:passkey assertion. +/// +public partial class CocoarPasskeyGrantFlowTests +{ + // ─────────────────────────────── list ───────────────────────────────────── + + [Fact] + public async Task Manage_List_ReturnsOnlyOwnPasskeys() + { + await EnableNativeGrantsAsync(); + var token = await MintAccessTokenAsync(); + + var lastUsed = DateTimeOffset.UtcNow.AddMinutes(-3); + var ownId = await SeedOwnedCredentialAsync(DefaultUser!.Id, "My Phone", lastUsed); + var foreignUser = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Other", lastname: "Person", acronym: "OP", email: "other@test.com"); + var foreignId = await SeedOwnedCredentialAsync(foreignUser.Id, "Foreign Phone"); + + var resp = await BearerClient(token).GetAsync("/connect/passkey", TestContext.Current.CancellationToken); + + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(resp.IsSuccessStatusCode, $"GET /connect/passkey failed ({(int)resp.StatusCode}): {body}"); + + using var json = JsonDocument.Parse(body); + var ids = json.RootElement.EnumerateArray().Select(e => e.GetProperty("Id").GetString()).ToList(); + Assert.Contains(ownId.ToString(), ids); + Assert.DoesNotContain(foreignId.ToString(), ids); // never another user's credentials + Assert.Single(ids); // exactly the one own credential, nothing leaked + + // The DTO is the documented shape { id, displayName, createdAt, lastUsedAt } + // (serialized PascalCase here, per the API's PropertyNamingPolicy = null). + var item = json.RootElement.EnumerateArray().Single(); + Assert.Equal("My Phone", item.GetProperty("DisplayName").GetString()); + Assert.True(item.TryGetProperty("CreatedAt", out _)); + Assert.Equal(lastUsed, item.GetProperty("LastUsedAt").GetDateTimeOffset()); + } + + [Fact] + public async Task Manage_List_Anonymous_Unauthorized() + { + await EnableNativeGrantsAsync(); + var anon = Factory.CreateClient(); + var resp = await anon.GetAsync("/connect/passkey", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Manage_List_NativeGrantsOff_BadRequest() + { + // Mint while enabled, then flip the realm flag off: the token stays valid but + // the feature gate must reject the management call (consistency with the rest + // of the native surface — the flag is the master switch). + await EnableNativeGrantsAsync(); + var token = await MintAccessTokenAsync(); + await DisableNativeGrantsAsync(); + + var resp = await BearerClient(token).GetAsync("/connect/passkey", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("NativeGrants.Disabled", body); + } + + // ─────────────────────────────── delete ─────────────────────────────────── + + [Fact] + public async Task Manage_Delete_OwnPasskey_RemovesIt() + { + await EnableNativeGrantsAsync(); + var token = await MintAccessTokenAsync(); + var ownId = await SeedOwnedCredentialAsync(DefaultUser!.Id, "My Phone"); + + var resp = await BearerClient(token).DeleteAsync($"/connect/passkey/{ownId}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode); + Assert.Null(await LoadStoredCredentialAsync(ownId)); + } + + [Fact] + public async Task Manage_Delete_ForeignPasskey_NotFound_AndNotDeleted() + { + // Owner-scoped: deleting another user's credential is a 404 (not 403 — no + // cross-user existence oracle) and must NOT remove it. + await EnableNativeGrantsAsync(); + var token = await MintAccessTokenAsync(); + var foreignUser = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Other", lastname: "Person", acronym: "OP", email: "other@test.com"); + var foreignId = await SeedOwnedCredentialAsync(foreignUser.Id, "Foreign Phone"); + + var resp = await BearerClient(token).DeleteAsync($"/connect/passkey/{foreignId}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + Assert.NotNull(await LoadStoredCredentialAsync(foreignId)); // untouched + } + + [Fact] + public async Task Manage_Delete_UnknownId_NotFound() + { + await EnableNativeGrantsAsync(); + var token = await MintAccessTokenAsync(); + + var resp = await BearerClient(token).DeleteAsync($"/connect/passkey/{Guid.NewGuid()}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + [Fact] + public async Task Manage_Delete_Anonymous_Unauthorized() + { + await EnableNativeGrantsAsync(); + var anon = Factory.CreateClient(); + var resp = await anon.DeleteAsync($"/connect/passkey/{Guid.NewGuid()}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Manage_Delete_OwnPasskey_ThenNativeLogin_InvalidGrant() + { + // A2 "Done": a deleted passkey is immediately no longer redeemable for the + // urn:cocoar:passkey grant. Bootstrap a real credential, sign in once to get a + // token, delete that credential via the Bearer endpoint, then prove the same + // authenticator can no longer mint a token. + await EnableNativeGrantsAsync(); + await SeedPasskeyClientAsync("native-passkey-app"); + + using var authenticator = new SoftwareWebAuthnAuthenticator(DefaultUser!.Id.ToByteArray()); + await SeedCredentialAsync(authenticator.CredentialId, authenticator.CosePublicKey(), authenticator.UserHandle); + + // Sign in with the seeded credential → access token. + var (ceremonyId, challenge, rpId) = await BeginAsync(); + var assertion = authenticator.CreateAssertionJson(challenge, rpId, $"https://{rpId}"); + var loginResp = await PostPasskeyAsync("native-passkey-app", ceremonyId, assertion); + var loginBody = await loginResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(loginResp.IsSuccessStatusCode, $"bootstrap login failed ({(int)loginResp.StatusCode}): {loginBody}"); + var token = JsonDocument.Parse(loginBody).RootElement.GetProperty("access_token").GetString()!; + + // Delete it via the Bearer management endpoint. + var stored = await LoadCredentialAsync(authenticator.CredentialId); + Assert.NotNull(stored); + var delResp = await BearerClient(token).DeleteAsync($"/connect/passkey/{stored!.Id}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode); + + // The same authenticator can no longer satisfy a fresh passkey ceremony. + var (ceremonyId2, challenge2, rpId2) = await BeginAsync(); + var assertion2 = authenticator.CreateAssertionJson(challenge2, rpId2, $"https://{rpId2}"); + var afterResp = await PostPasskeyAsync("native-passkey-app", ceremonyId2, assertion2); + + Assert.False(afterResp.IsSuccessStatusCode); + var afterBody = await afterResp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("invalid_grant", afterBody); + } + + // ─────────────────────────────── helpers ────────────────────────────────── + + private HttpClient BearerClient(string accessToken) + { + var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return client; + } + + /// Mints a real Bearer access token for + /// via the native magic grant — the lightest cookieless mint (seed a challenge doc + + /// one token POST, no WebAuthn ceremony, no email, no rate-limited begin). + private async Task MintAccessTokenAsync() + { + await SeedClientAsync("native-mgmt-app", [CocoarGrantTypes.Magic, "refresh_token"]); + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + await SeedMagicLinkAsync(DefaultUser!.Id, token); + + var resp = await PostTokenAsync(new Dictionary + { + ["grant_type"] = CocoarGrantTypes.Magic, + ["client_id"] = "native-mgmt-app", + ["client_secret"] = "native-mgmt-app-secret", + ["user_id"] = DefaultUser!.Id.ToString(), + ["magic_token"] = token, + ["scope"] = "openid email profile offline_access", + }); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(resp.IsSuccessStatusCode, $"token mint (magic) failed ({(int)resp.StatusCode}): {body}"); + return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString()!; + } + + /// Seeds a directly (opaque token, SHA-256 + /// at rest) — bypasses the rate-limited request endpoint, exactly as the Phase-1 + /// grant tests do. + private async Task SeedMagicLinkAsync(Guid userId, string token) + { + using var scope = NewSystemTenantScope(); + var session = scope.ServiceProvider.GetRequiredService(); + session.Store(new MagicLinkChallenge + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = MagicLinkChallenge.HashToken(token), + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(15), + CreatedAt = DateTimeOffset.UtcNow, + }); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + /// Seeds a passkey credential owned by and + /// returns its addressable . + private async Task SeedOwnedCredentialAsync(Guid userId, string displayName, DateTimeOffset? lastUsedAt = null) + { + using var scope = NewSystemTenantScope(); + var session = scope.ServiceProvider.GetRequiredService(); + var id = Guid.CreateVersion7(); + session.Store(new StoredPasskeyCredential + { + Id = id, + UserId = userId, + CredentialId = RandomNumberGenerator.GetBytes(32), + PublicKey = RandomNumberGenerator.GetBytes(64), + UserHandle = userId.ToByteArray(), + SignatureCount = 0, + AttestationType = "none", + DisplayName = displayName, + CreatedAt = DateTimeOffset.UtcNow, + LastUsedAt = lastUsedAt, + }); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return id; + } + + private async Task LoadStoredCredentialAsync(Guid id) + { + using var scope = NewSystemTenantScope(); + var session = scope.ServiceProvider.GetRequiredService(); + return await session.LoadAsync(id, TestContext.Current.CancellationToken); + } + + private async Task DisableNativeGrantsAsync() + { + using var scope = NewSystemTenantScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + await settings.PatchAsync(new UpdateRealmSettingsDto + { + NativeGrants = new UpdateNativeGrantSettingsDto { Enabled = false }, + }, TestContext.Current.CancellationToken); + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.cs index 2b368ec3..37ac8982 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarPasskeyGrantFlowTests.cs @@ -29,7 +29,7 @@ namespace Modgud.Api.Tests.Authorization; /// on bad/missing/expired ceremonies, and single-use ceremony consumption. /// [Collection(IntegrationTestCollection.Name)] -public class CocoarPasskeyGrantFlowTests : IntegrationTestBase +public partial class CocoarPasskeyGrantFlowTests : IntegrationTestBase { public CocoarPasskeyGrantFlowTests(SharedPostgresFixture fixture) : base(fixture) { } diff --git a/src/dotnet/Modgud.Api/Features/Auth/NativeBearerEndpointSupport.cs b/src/dotnet/Modgud.Api/Features/Auth/NativeBearerEndpointSupport.cs new file mode 100644 index 00000000..2d93d765 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Auth/NativeBearerEndpointSupport.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Identity; +using Modgud.Authentication.Applications; +using Modgud.Authentication.Domain; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Modgud.Api.Features.Auth; + +/// +/// Shared building blocks for the cookieless, Bearer-authenticated native passkey +/// endpoints (enroll / list / delete). The per-realm NativeGrants master gate +/// and the store-backed principal resolution are identical across them, so they live +/// here once — the security contract (which user a token speaks for, whether the +/// feature is enabled) must never drift between the management and enrollment paths. +/// +internal static class NativeBearerEndpointSupport +{ + /// + /// Per-(App ⊕ realm) NativeGrants master gate (default OFF), ADR-0011. + /// Bearer-authenticated, so the App is resolved client_id-time from the token's + /// client (or the Host pin when on an Application subdomain). Returns a 400 + /// problem result when disabled, null when the caller may proceed. + /// + public static async Task GateDisabledAsync( + IApplicationSettingsResolver settingsResolver, HttpContext context, CancellationToken ct) + { + var clientId = context.User.GetClaim(Claims.ClientId) ?? context.User.GetClaim(Claims.AuthorizedParty); + var settings = await settingsResolver.ResolveForRequestAsync(context, clientId, ct); + if (settings.NativeGrants is null || !settings.NativeGrants.Enabled) + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "NativeGrants.Disabled", + detail: "Native passwordless features are not enabled for this realm."); + return null; + } + + /// + /// Resolves the authenticated subject (store-backed so the SecurityStamp / active + /// / deleted state is authoritative, never trusting token claims as the user + /// record) and the requesting client_id from the validated Bearer access token. + /// Returns a 401 result when there is no usable subject. + /// + public static async Task<(ApplicationUser? user, string? clientId, IResult? unauthorized)> ResolvePrincipalAsync( + HttpContext context, UserManager userManager) + { + var sub = context.User.GetClaim(Claims.Subject); + var clientId = context.User.GetClaim(Claims.ClientId) ?? context.User.GetClaim(Claims.AuthorizedParty); + if (string.IsNullOrEmpty(sub)) + return (null, null, Results.Unauthorized()); + + var user = await userManager.FindByIdAsync(sub); + if (user is null || !user.IsActive || user.IsDeleted) + return (null, null, Results.Unauthorized()); + + return (user, clientId, null); + } +} diff --git a/src/dotnet/Modgud.Api/Features/Auth/NativePasskeyEnrollEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/NativePasskeyEnrollEndpoints.cs index d2cc49b4..40226141 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/NativePasskeyEnrollEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/NativePasskeyEnrollEndpoints.cs @@ -11,9 +11,7 @@ using Modgud.Authentication.Domain; using Modgud.Authentication.ExtensionMethods; using Modgud.Authentication.Identity; -using OpenIddict.Abstractions; using OpenIddict.Validation.AspNetCore; -using static OpenIddict.Abstractions.OpenIddictConstants; namespace Modgud.Api.Features.Auth; @@ -49,9 +47,9 @@ public static WebApplication MapNativePasskeyEnrollEndpoints(this WebApplication RpIdResolver rpIdResolver, CancellationToken ct) => { - if (await GateDisabledAsync(settingsResolver, context, ct) is { } gate) return gate; + if (await NativeBearerEndpointSupport.GateDisabledAsync(settingsResolver, context, ct) is { } gate) return gate; - var (user, clientId, unauthorized) = await ResolvePrincipalAsync(context, userManager); + var (user, clientId, unauthorized) = await NativeBearerEndpointSupport.ResolvePrincipalAsync(context, userManager); if (unauthorized is not null) return unauthorized; var rpId = await rpIdResolver.ResolveAsync(session, clientId, ct); @@ -143,9 +141,9 @@ public static WebApplication MapNativePasskeyEnrollEndpoints(this WebApplication JsonElement body, CancellationToken ct) => { - if (await GateDisabledAsync(settingsResolver, context, ct) is { } gate) return gate; + if (await NativeBearerEndpointSupport.GateDisabledAsync(settingsResolver, context, ct) is { } gate) return gate; - var (user, clientId, unauthorized) = await ResolvePrincipalAsync(context, userManager); + var (user, clientId, unauthorized) = await NativeBearerEndpointSupport.ResolvePrincipalAsync(context, userManager); if (unauthorized is not null) return unauthorized; if (!body.TryGetProperty("ceremonyId", out var cidEl) @@ -250,42 +248,6 @@ public static WebApplication MapNativePasskeyEnrollEndpoints(this WebApplication return application; } - /// Per-(App ⊕ realm) master gate (default OFF), ADR-0011. Bearer- - /// authenticated, so the App is resolved client_id-time from the token's - /// client (or the Host pin when on an Application subdomain). - private static async Task GateDisabledAsync( - IApplicationSettingsResolver settingsResolver, HttpContext context, CancellationToken ct) - { - var clientId = context.User.GetClaim(Claims.ClientId) ?? context.User.GetClaim(Claims.AuthorizedParty); - var settings = await settingsResolver.ResolveForRequestAsync(context, clientId, ct); - if (settings.NativeGrants is null || !settings.NativeGrants.Enabled) - return Results.Problem( - statusCode: StatusCodes.Status400BadRequest, - title: "NativeGrants.Disabled", - detail: "Native passkey sign-in is not enabled for this realm."); - return null; - } - - /// - /// Resolves the authenticated subject (store-backed so the SecurityStamp is - /// authoritative, never trusting token claims as the user record) and the - /// requesting client_id from the validated Bearer access token. - /// - private static async Task<(ApplicationUser? user, string? clientId, IResult? unauthorized)> ResolvePrincipalAsync( - HttpContext context, UserManager userManager) - { - var sub = context.User.GetClaim(Claims.Subject); - var clientId = context.User.GetClaim(Claims.ClientId) ?? context.User.GetClaim(Claims.AuthorizedParty); - if (string.IsNullOrEmpty(sub)) - return (null, null, Results.Unauthorized()); - - var user = await userManager.FindByIdAsync(sub); - if (user is null || !user.IsActive || user.IsDeleted) - return (null, null, Results.Unauthorized()); - - return (user, clientId, null); - } - private static IResult RpUnavailable(HttpContext context, RelyingPartyUnavailableException ex) { context.RequestServices.GetRequiredService() diff --git a/src/dotnet/Modgud.Api/Features/Auth/NativePasskeyManagementEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/NativePasskeyManagementEndpoints.cs new file mode 100644 index 00000000..750a3b9b --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Auth/NativePasskeyManagementEndpoints.cs @@ -0,0 +1,101 @@ +using Marten; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Modgud.Authentication.Applications; +using Modgud.Authentication.Domain; +using OpenIddict.Validation.AspNetCore; + +namespace Modgud.Api.Features.Auth; + +/// +/// The cookieless, Bearer-authenticated native passkey MANAGEMENT pair — list and +/// delete the authenticated user's own passkeys. The cookie-based realm UI +/// (GET/DELETE /api/account/passkey) needs a Modgud session, which a native +/// client (or a brokering BFF holding only an access token) does not have; these two +/// mirror that surface for Bearer callers so a native/BFF profile can show "your +/// passkeys" and revoke a lost device — the missing half of the cookieless +/// enroll/login ceremony (ADR-0009 / ADR-0010). +/// +/// Authenticated by the OpenIddict validation (Bearer) scheme, gated behind the +/// per-realm NativeGrants flag, and strictly owner-scoped: a caller only ever +/// sees or deletes credentials owned by the token's subject. An unknown id or one +/// owned by another user is a 404 (never 403) so the endpoint is not a +/// cross-user credential-existence oracle. +/// +public static class NativePasskeyManagementEndpoints +{ + /// The owner-scoped projection returned by the list endpoint. Id + /// is the (the addressable management id), + /// never the raw WebAuthn CredentialId. + public sealed record PasskeyListItem(string Id, string DisplayName, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt); + + public static WebApplication MapNativePasskeyManagementEndpoints(this WebApplication application) + { + // GET /connect/passkey — the token subject's own passkeys. User-scoped (every + // credential the user holds, across RP IDs), mirroring the cookie-based + // Passkey_List, so a "manage my passkeys / revoke a lost device" surface sees + // exactly what the realm UI would. + application.MapGet("/connect/passkey", async ( + HttpContext context, + IDocumentSession session, + IApplicationSettingsResolver settingsResolver, + UserManager userManager, + CancellationToken ct) => + { + if (await NativeBearerEndpointSupport.GateDisabledAsync(settingsResolver, context, ct) is { } gate) return gate; + + var (user, _, unauthorized) = await NativeBearerEndpointSupport.ResolvePrincipalAsync(context, userManager); + if (unauthorized is not null) return unauthorized; + + var credentials = await session.Query() + .Where(c => c.UserId == user!.Id) + .ToListAsync(ct); + + return Results.Ok(credentials + .OrderByDescending(c => c.CreatedAt) + .Select(c => new PasskeyListItem(c.Id.ToString(), c.DisplayName, c.CreatedAt, c.LastUsedAt))); + }) + .WithName("NativePasskey_List") + .RequireAuthorization(new AuthorizeAttribute + { + AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, + }) + .WithTags("Native Auth"); + + // DELETE /connect/passkey/{id} — revoke ONE of the subject's own passkeys. + // Owner-scoped: a credential owned by another user (or no such id) is a 404, + // not a 403, so it cannot be used to probe another user's credentials. Once + // deleted the credential can no longer satisfy a urn:cocoar:passkey assertion. + application.MapDelete("/connect/passkey/{id:guid}", async ( + Guid id, + HttpContext context, + IDocumentSession session, + IApplicationSettingsResolver settingsResolver, + UserManager userManager, + CancellationToken ct) => + { + if (await NativeBearerEndpointSupport.GateDisabledAsync(settingsResolver, context, ct) is { } gate) return gate; + + var (user, _, unauthorized) = await NativeBearerEndpointSupport.ResolvePrincipalAsync(context, userManager); + if (unauthorized is not null) return unauthorized; + + var credential = await session.LoadAsync(id, ct); + if (credential is null || credential.UserId != user!.Id) + return Results.NotFound(); + + session.Delete(credential); + await session.SaveChangesAsync(ct); + + return Results.NoContent(); + }) + .WithName("NativePasskey_Delete") + .RequireAuthorization(new AuthorizeAttribute + { + AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, + }) + .DisableAntiforgery() + .WithTags("Native Auth"); + + return application; + } +} diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 614e95bd..a54dda88 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -1233,6 +1233,7 @@ app.MapPasskeyEndpoints("api"); app.MapNativePasskeyEndpoints(); app.MapNativePasskeyEnrollEndpoints(); + app.MapNativePasskeyManagementEndpoints(); app.MapMagicLinkEndpoints("api"); app.MapPasswordResetEndpoints("api"); app.MapEmailVerificationEndpoints("api");