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
5 changes: 4 additions & 1 deletion docs/integrate/native-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.") |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,17 +193,22 @@ 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<InMemoryEmailService>();
emailService.Clear();

var anon = Factory.CreateClient();
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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
Expand Down Expand Up @@ -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<InMemoryEmailService>();
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<HttpResponseMessage> PostAsync(string url, string host, string email)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
21 changes: 19 additions & 2 deletions src/dotnet/Modgud.Authentication/Api/Account/NativeOtpEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,7 @@ public static WebApplication MapNativeOtpEndpoints(this WebApplication applicati
IPasswordlessUserFactory passwordlessUserFactory,
IRegistrationInviteService inviteService,
UserManager<ApplicationUser> userManager,
ILoggerFactory loggerFactory,
CancellationToken ct) =>
{
const string genericMessage = "If your email is registered, you will receive a verification code.";
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.";
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/frontend-vue/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions src/frontend-vue/src/views/admin/oauth/ClientDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -592,6 +601,9 @@ async function copySecret() {
<CoarNote v-if="nativeGrantsEnabled" variant="info">
{{ 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.') }}
</CoarNote>
<CoarNote v-if="hasNativeGrantWithRealmOff" variant="warning">
{{ 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.') }}
</CoarNote>
<section class="flex-section">
<CoarDualListbox
class="flex-1 min-h-0"
Expand Down
Loading