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.') }} +