Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/integrate/native-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <access_token>
```
```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 <access_token>
```

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).
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The cookieless, Bearer-authenticated passkey MANAGEMENT pair
/// (<c>GET /connect/passkey</c> + <c>DELETE /connect/passkey/{id}</c>): 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
/// <c>urn:cocoar:passkey</c> assertion.
/// </summary>
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;
}

/// <summary>Mints a real Bearer access token for <see cref="IntegrationTestBase.DefaultUser"/>
/// 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).</summary>
private async Task<string> 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<string, string>
{
["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()!;
}

/// <summary>Seeds a <see cref="MagicLinkChallenge"/> directly (opaque token, SHA-256
/// at rest) — bypasses the rate-limited request endpoint, exactly as the Phase-1
/// grant tests do.</summary>
private async Task SeedMagicLinkAsync(Guid userId, string token)
{
using var scope = NewSystemTenantScope();
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();
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);
}

/// <summary>Seeds a passkey credential owned by <paramref name="userId"/> and
/// returns its addressable <see cref="StoredPasskeyCredential.Id"/>.</summary>
private async Task<Guid> SeedOwnedCredentialAsync(Guid userId, string displayName, DateTimeOffset? lastUsedAt = null)
{
using var scope = NewSystemTenantScope();
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();
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<StoredPasskeyCredential?> LoadStoredCredentialAsync(Guid id)
{
using var scope = NewSystemTenantScope();
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();
return await session.LoadAsync<StoredPasskeyCredential>(id, TestContext.Current.CancellationToken);
}

private async Task DisableNativeGrantsAsync()
{
using var scope = NewSystemTenantScope();
var settings = scope.ServiceProvider.GetRequiredService<IRealmSettingsService>();
await settings.PatchAsync(new UpdateRealmSettingsDto
{
NativeGrants = new UpdateNativeGrantSettingsDto { Enabled = false },
}, TestContext.Current.CancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ namespace Modgud.Api.Tests.Authorization;
/// on bad/missing/expired ceremonies, and single-use ceremony consumption.
/// </summary>
[Collection(IntegrationTestCollection.Name)]
public class CocoarPasskeyGrantFlowTests : IntegrationTestBase
public partial class CocoarPasskeyGrantFlowTests : IntegrationTestBase
{
public CocoarPasskeyGrantFlowTests(SharedPostgresFixture fixture) : base(fixture) { }

Expand Down
57 changes: 57 additions & 0 deletions src/dotnet/Modgud.Api/Features/Auth/NativeBearerEndpointSupport.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Shared building blocks for the cookieless, Bearer-authenticated native passkey
/// endpoints (enroll / list / delete). The per-realm <c>NativeGrants</c> 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.
/// </summary>
internal static class NativeBearerEndpointSupport
{
/// <summary>
/// Per-(App ⊕ realm) <c>NativeGrants</c> 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, <c>null</c> when the caller may proceed.
/// </summary>
public static async Task<IResult?> 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;
}

/// <summary>
/// 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.
/// </summary>
public static async Task<(ApplicationUser? user, string? clientId, IResult? unauthorized)> ResolvePrincipalAsync(
HttpContext context, UserManager<ApplicationUser> 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);
}
}
Loading
Loading