From 113798c17f8a42d8b1f7240271b88d35e55a07b9 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 23 Jun 2026 16:08:50 +0200 Subject: [PATCH] fix(native-grants): fail loudly when native OTP/register hit a realm with grants disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering an email and clicking login on a realm whose Native Passwordless Grants flag is off produced NO email and NO error — the OTP-request endpoint returned a silent uniform 200 ("If your email is registered…") and dispatched nothing. Indistinguishable from "code sent", impossible to diagnose without already knowing the realm toggle exists. Whether native grants are enabled is a realm/App configuration state, not a per-email signal, so surfacing it leaks nothing the anti-enumeration branch must protect. The native passkey-begin endpoint and the /connect/token grant already reject loudly when the flag is off; the OTP-request and native-register endpoints were the silent outliers. Align them. Backend: - NativeOtpEndpoints: realm-grants-off → 400 NativeGrants.Disabled + WARN log (lands in the per-realm error feed) instead of a silent 200. The email-existence branch below is untouched, so anti-enumeration is preserved. - NativeRegisterEndpoints: split the realm-grants check out of the combined eligible flag — grants-off is now a loud 400; the posture gate stays a uniform no-op (a per-App routing choice, not a misconfiguration). Frontend: - ClientDetails: warn when a client carries a native grant but the realm flag is OFF (the inverse of the existing "enabled" info note) — catches the misconfiguration at setup time, before the first login attempt. Tests: - NativeOtpRequest realm-off now asserts 400 + NativeGrants.Disabled + no send. - New NativeRegister realm-off coverage. - Rate-limit test asserts under-limit requests are NOT throttled (flag-independent). Docs: native-apps.md Flow 1 note + errors-table row. Surfaced by the AmZettel onboarding (Atlas: requests-amzettel-bff-single-credential). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/integrate/native-apps.md | 5 ++- .../CocoarNativeGrantFlowTests.cs | 11 +++++-- .../NativeExplicitRegistrationFlowTests.cs | 25 +++++++++++++++ .../Authorization/NativeOtpRateLimitTests.cs | 9 +++--- .../Api/Account/NativeOtpEndpoints.cs | 21 ++++++++++-- .../Api/Account/NativeRegisterEndpoints.cs | 32 +++++++++++++++---- src/frontend-vue/public/i18n/de.json | 1 + .../src/views/admin/oauth/ClientDetails.vue | 12 +++++++ 8 files changed, 99 insertions(+), 17 deletions(-) diff --git a/docs/integrate/native-apps.md b/docs/integrate/native-apps.md index 509a22ca..f2737ad7 100644 --- a/docs/integrate/native-apps.md +++ b/docs/integrate/native-apps.md @@ -80,6 +80,8 @@ Content-Type: application/json { "Message": "If your email is registered, you will receive a verification code." } ``` +> If the realm has **native grants disabled**, this endpoint does **not** return the uniform "code sent" response — it fails fast with `400 NativeGrants.Disabled`. Whether the feature is enabled is a realm/App configuration state (not a per-email signal), so surfacing it is safe and saves you from a silent "no email, no error" dead end. Enable it under **Realm Settings → Native Passwordless Grants** (and give the client the `gt:urn:cocoar:otp` permission). + **Step 2 — redeem the code for tokens:** ```http @@ -285,7 +287,8 @@ From then on, the app uses **Flow 3 (passkey)** as the steady-state login. | Condition | Response | |---|---| -| Realm hasn't enabled native grants | `unsupported_grant_type` | +| Realm hasn't enabled native grants (token endpoint) | `unsupported_grant_type` | +| Realm hasn't enabled native grants (OTP-request / native-register endpoint) | `400` `NativeGrants.Disabled` — an explicit error, **not** a silent "code sent". Enable the grants in Realm Settings. | | Client lacks the `gt:urn:cocoar:*` permission | `unauthorized_client` | | Wrong/expired code, link, or passkey assertion | `invalid_grant` — **uniform** message + jitter (anti-enumeration); don't parse it for "which part was wrong" | | TOTP required but missing/invalid (OTP & magic flows) | `invalid_grant` ("Two-factor authentication is required; supply totp_code.") | diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs index fe083736..a89635fb 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs @@ -193,9 +193,12 @@ public async Task NativeOtpRequest_UnknownEmail_UniformResponse_NoSend() } [Fact] - public async Task NativeOtpRequest_RealmFlagOff_NoSend() + public async Task NativeOtpRequest_RealmFlagOff_Rejected_NoSend() { - // Flag left OFF. + // Flag left OFF. A disabled realm is a configuration error, not an + // email-existence signal, so the endpoint rejects LOUDLY (400 + // NativeGrants.Disabled) instead of returning a silent uniform 200 — the + // old behaviour looked identical to "code sent" and was un-diagnosable. var emailService = Factory.Services.GetRequiredService(); emailService.Clear(); @@ -203,7 +206,9 @@ public async Task NativeOtpRequest_RealmFlagOff_NoSend() var resp = await anon.PostAsJsonAsync("/api/account/native/otp/request", new { Email = TestEmail }, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("NativeGrants.Disabled", body); Assert.Null(emailService.GetLastEmailTo(TestEmail)); } diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/NativeExplicitRegistrationFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/NativeExplicitRegistrationFlowTests.cs index b3e1aac0..81713063 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/NativeExplicitRegistrationFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/NativeExplicitRegistrationFlowTests.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Json; using System.Text.Json; using System.Text.RegularExpressions; @@ -94,6 +95,30 @@ public async Task JitPosture_Register_Endpoint_Creates_Nothing() Assert.Null(await QuerySystemUserByEmailAsync(email)); } + [Fact] + public async Task RealmGrantsOff_Register_Rejected_NoSend() + { + var email = "explicit-reg-grants-off@example.test"; + + // Native grants left OFF (deliberately NO EnableRealmNativeGrantsAsync) on an + // otherwise valid App host. A disabled realm is a configuration error, not an + // email-existence signal, so the register endpoint rejects LOUDLY (400 + // NativeGrants.Disabled) rather than returning a silent uniform 200 — mirrors + // the OTP-request endpoint. + var app = await CreateAppAsync("p-explicit-off-app"); + await MapApplicationDomainsAsync(("p-explicit-off.localhost", app.Id)); + + var emailService = Factory.Services.GetRequiredService(); + emailService.Clear(); + var resp = await PostAsync("/api/account/native/register", "p-explicit-off.localhost", email); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("NativeGrants.Disabled", body); + Assert.Null(emailService.GetLastEmailTo(email)); + Assert.Null(await QuerySystemUserByEmailAsync(email)); + } + // ── Helpers ────────────────────────────────────────────────────────────── private Task PostAsync(string url, string host, string email) diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/NativeOtpRateLimitTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/NativeOtpRateLimitTests.cs index 915a8a69..0ea2ea29 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/NativeOtpRateLimitTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/NativeOtpRateLimitTests.cs @@ -29,15 +29,16 @@ public async Task NativeOtpRequest_ExceedingPerIpLimit_Returns429() { // The limiter runs before the endpoint handler, so the result is // independent of the native-grants flag / email existence: the first - // PermitLimit (5) requests pass the limiter (200, uniform body), the - // 6th is rejected with 429. + // PermitLimit (5) requests pass the limiter (the handler then rejects with + // 400 NativeGrants.Disabled since the flag is left off — that is fine, the + // point here is that they were NOT throttled), the 6th is rejected with 429. const int permitLimit = 5; var anon = Factory.CreateClient(); for (var i = 1; i <= permitLimit; i++) { - var ok = await SendAsync(anon, $"probe{i}@nowhere.example"); - Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + var passed = await SendAsync(anon, $"probe{i}@nowhere.example"); + Assert.NotEqual(HttpStatusCode.TooManyRequests, passed.StatusCode); } var rejected = await SendAsync(anon, "probe-over@nowhere.example"); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/NativeOtpEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/NativeOtpEndpoints.cs index 1e5de758..469b4ed5 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/NativeOtpEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/NativeOtpEndpoints.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; using Modgud.Authentication.Applications; using Modgud.Authentication.Domain; using Modgud.Authentication.Identity; @@ -50,6 +51,7 @@ public static WebApplication MapNativeOtpEndpoints(this WebApplication applicati IPasswordlessUserFactory passwordlessUserFactory, IRegistrationInviteService inviteService, UserManager userManager, + ILoggerFactory loggerFactory, CancellationToken ct) => { const string genericMessage = "If your email is registered, you will receive a verification code."; @@ -62,8 +64,23 @@ public static WebApplication MapNativeOtpEndpoints(this WebApplication applicati var settings = await settingsResolver.ResolveForRequestAsync(httpContext, clientId: null, ct); if (settings.NativeGrants is null || !settings.NativeGrants.Enabled) { - await AntiTimingDelayAsync(); - return Results.Ok(new { Message = genericMessage }); + // Surfaced LOUDLY, not as a silent uniform 200. Whether native grants + // are enabled is a realm/App configuration state, NOT a signal about + // whether a given email exists — so returning an explicit error here + // leaks nothing the email-existence branch below must protect. It + // matches the native passkey-begin and the /connect/token grant, both + // of which already reject loudly when the realm flag is off. The old + // silent no-op meant a misconfigured realm looked exactly like "email + // sent" — no mail, no error, no way to diagnose. The WARN lands in the + // per-realm error feed so the admin sees the misconfiguration. + loggerFactory.CreateLogger("Modgud.Authentication.NativeOtp").LogWarning( + "Native OTP requested but native passwordless grants are disabled for this realm/App. " + + "Enable them under Realm Settings → Native Passwordless Grants (and grant the client the " + + "matching gt:urn:cocoar:otp permission)."); + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "NativeGrants.Disabled", + detail: "Native passwordless sign-in is not enabled for this realm."); } // Required-field gate (configurable per App⊕realm). Surfaced as a hard diff --git a/src/dotnet/Modgud.Authentication/Api/Account/NativeRegisterEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/NativeRegisterEndpoints.cs index 73746e1e..21936d9b 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/NativeRegisterEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/NativeRegisterEndpoints.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; using Modgud.Authentication.Applications; using Modgud.Authentication.Domain; using Modgud.Authentication.Identity; @@ -49,6 +50,7 @@ public static WebApplication MapNativeRegisterEndpoints(this WebApplication appl IApplicationSettingsResolver settingsResolver, IEmailOtpService emailOtpService, IPasswordlessUserFactory passwordlessUserFactory, + ILoggerFactory loggerFactory, CancellationToken ct) => { const string genericMessage = "If registration is available, you will receive a verification code."; @@ -57,13 +59,29 @@ public static WebApplication MapNativeRegisterEndpoints(this WebApplication appl // App (if any) comes from the request Host (an Application subdomain). var settings = await settingsResolver.ResolveForRequestAsync(httpContext, clientId: null, ct); - // Two gates, both required: the (App⊕realm) NativeGrants master flag - // (the code is a native OTP redeemed at /connect/token) AND the - // ExplicitEndpoint posture. Under Off/JitOnOtp this endpoint does - // nothing — JIT sign-up flows through the OTP-request endpoint, and - // Off has no self-registration at all. - var eligible = settings.NativeGrants is { Enabled: true } - && settings.SelfRegPosture == SelfRegPosture.ExplicitEndpoint; + // Realm/App config error — surfaced LOUDLY (matching the OTP-request and + // passkey-begin endpoints). Whether native grants are enabled is a + // configuration state, not an email-existence signal, so an explicit + // error here leaks nothing the uniform branch below must protect — and a + // silent 200 with no code is exactly the un-diagnosable trap we are + // removing. The posture gate stays uniform (see below) because that is a + // per-App routing choice, not a hard misconfiguration. + if (settings.NativeGrants is null || !settings.NativeGrants.Enabled) + { + loggerFactory.CreateLogger("Modgud.Authentication.NativeRegister").LogWarning( + "Native registration requested but native passwordless grants are disabled for this realm/App. " + + "Enable them under Realm Settings → Native Passwordless Grants."); + return Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "NativeGrants.Disabled", + detail: "Native passwordless sign-in is not enabled for this realm."); + } + + // Posture gate: this endpoint only acts under the ExplicitEndpoint + // posture. Under Off/JitOnOtp it stays a uniform no-op — JIT sign-up + // flows through the OTP-request endpoint, and Off has no self-registration + // at all. Kept silent (not loud) so it is not a per-App posture oracle. + var eligible = settings.SelfRegPosture == SelfRegPosture.ExplicitEndpoint; // Required-field gate (configurable per App⊕realm). Surfaced as a hard // 400 BEFORE the uniform branch and only when the endpoint is eligible to diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index befad257..3f9dce8b 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -1046,6 +1046,7 @@ "grantTypes.available": "Verfügbare Grant-Types", "grantTypes.hint": "Keine stillen Defaults: Bleibt dies leer, entsteht ein Client, der keine Tokens ausstellen kann. SPAs / Mobile-Apps: authorization_code + refresh_token. Server-zu-Server: client_credentials. Wähle, was der Client tatsächlich braucht.", "grantTypes.nativeHint": "Native passwortlose Grants (urn:cocoar:otp / :magic / :passkey) sind für diesen Realm aktiviert und unten verfügbar. Füge hier einen hinzu, um diesem Client die passende gt:urn:cocoar:*-Permission zu geben — erst dann kann er einen passwortlosen Nachweis an /connect/token eintauschen.", + "grantTypes.nativeDisabledWarning": "Dieser Client hat einen nativen passwortlosen Grant (urn:cocoar:otp / :magic / :passkey) ausgewählt, aber native Grants sind für diesen Realm DEAKTIVIERT — er funktioniert also nicht: Der Token-Endpoint weist den Grant ab und der OTP-Request-Endpoint liefert einen Fehler, statt einen Code zu mailen. Aktiviere sie unter Realm-Einstellungen → Native passwortlose Grants.", "grantTypes.searchPlaceholder": "Suchen…", "grantTypes.selected": "Aktiviert", "postLogoutRedirectUri": { diff --git a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue index 73c91523..7c7701ec 100644 --- a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue +++ b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue @@ -112,6 +112,15 @@ const cocoarGrantTypeOptions = [ const nativeGrantsEnabled = computed( () => realmSettingsStore.settings?.NativeGrants?.Enabled ?? false) +// The inverse of nativeGrantsEnabled's info note: this client carries a native +// grant but the realm flag is OFF, so the grant silently won't work at +// /connect/token. The grant stays visible (see grantTypeOptions) precisely so an +// existing selection isn't hidden — which is exactly when this warning is needed. +const cocoarGrantValues = new Set(cocoarGrantTypeOptions.map((o) => o.value)) +const hasNativeGrantWithRealmOff = computed( + () => !nativeGrantsEnabled.value + && form.value.AllowedGrantTypes.some((g) => cocoarGrantValues.has(g))) + const grantTypeOptions = computed(() => { const selected = new Set(form.value.AllowedGrantTypes) const cocoar = cocoarGrantTypeOptions.filter( @@ -592,6 +601,9 @@ async function copySecret() { {{ t('admin.oauthClients.grantTypes.nativeHint', {}, 'Native passwordless grants (urn:cocoar:otp / :magic / :passkey) are enabled for this realm and available below. Add one here to give this client the matching gt:urn:cocoar:* permission — only then can it exchange a passwordless proof at /connect/token.') }} + + {{ t('admin.oauthClients.grantTypes.nativeDisabledWarning', {}, 'This client has a native passwordless grant (urn:cocoar:otp / :magic / :passkey) selected, but native grants are DISABLED for this realm — so it will not work: the token endpoint rejects the grant and the OTP-request endpoint returns an error instead of emailing a code. Enable them under Realm Settings → Native Passwordless Grants.') }} +