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
2 changes: 1 addition & 1 deletion docs/admin/applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ re-inherits the realm.

| Section | What it does |
| --- | --- |
| **Origin** | The App's own subdomain (e.g. `acme.cocoar.app`), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. |
| **Origin** | The App's own subdomain (e.g. `acme.cocoar.app`), which must be a child of the realm's primary domain. Setting it routes that host to this App and serves the branded login there; clearing it falls back to the tenant URL. The host must not belong to another realm; it may also be listed among this realm's own domains (it routes to the realm either way, the App match takes precedence). The OIDC issuer stays the tenant's (anchored to the realm primary domain) — a subdomain is not its own issuer. |
| **Branding** | Product name, primary colour, logo/favicon — the look of the login + consent UI when reached via this App. |
| **Email branding** | Product name, sender display name, sender address, validated reply-to address, optional subject prefix, preheader and footer used in this App's outbound emails (OTP, magic link, reset, verification, ...). The sender address falls back to the realm's, then the deployment's; a custom address must be deliverable from your mail provider (SPF/DKIM). Logo and button colour follow effective App branding. See [Transactional email](../platform/email-customization). |
| **Login methods** | Enable/disable internal password/passkey and magic-link entry points, and select/order the external OIDC/SAML providers exposed to this App. The allow-list is enforced by the public list and protocol start endpoints, not only hidden in the SPA. An explicit empty provider list disables all external providers. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,67 @@ public async Task Subdomain_Is_Unique_Across_Apps()
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}

/// <summary>
/// Reported from the test instance: the realm listed an App's host among its own
/// plain domains as well (a legal state — the host routes to the realm either
/// way, the App match layers on top), and from then on every manifest apply died
/// on that App with <c>SubdomainTaken</c> ("already a realm domain"), whatever
/// the draft changed. Only ANOTHER realm's domain makes a host ambiguous.
/// </summary>
[Fact]
public async Task Subdomain_May_Be_A_Domain_Of_The_Own_Realm_But_Not_Of_Another()
{
var ct = TestContext.Current.CancellationToken;
var app = await SeedAppAsync("as-own-domain");
var appShort = new ShortGuid(app.Id).ToString();
var primary = await SystemPrimaryDomainAsync();
var ownHost = $"as-own-domain.{primary}";
var foreignHost = $"as-foreign-domain.{primary}";
var globalStore = Factory.Services.GetRequiredService<IGlobalStore>();
var other = new Realm
{
Id = Guid.NewGuid(), Slug = "as-other-realm", DisplayName = "Other",
Domains = [foreignHost], PrimaryDomain = foreignHost, IsActive = false,
};

await using (var gs = globalStore.LightweightSession())
{
var system = await gs.Query<Realm>().FirstAsync(r => r.Slug == "system", ct);
system.Domains = [.. system.Domains, ownHost];
gs.Store(system);
gs.Store(other);
await gs.SaveChangesAsync(ct);
}

try
{
// Own realm's domain: accepted, and accepted again on the re-apply an
// idempotent manifest run is.
(await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = ownHost } }, ct)).EnsureSuccessStatusCode();
(await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = ownHost } }, ct)).EnsureSuccessStatusCode();
await using (var gs = globalStore.QuerySession())
{
var system = await gs.Query<Realm>().FirstAsync(r => r.Slug == "system", ct);
Assert.Equal(app.Id, system.ApplicationDomains[ownHost]);
Assert.Contains(ownHost, system.Domains);
}

// Another realm's domain: still refused.
var foreign = await PutSettingsAsync(appShort, new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = foreignHost } }, ct);
Assert.Equal(HttpStatusCode.Conflict, foreign.StatusCode);
Assert.Contains("another realm", await foreign.Content.ReadAsStringAsync(ct));
}
finally
{
await using var gs = globalStore.LightweightSession();
var system = await gs.Query<Realm>().FirstAsync(r => r.Slug == "system", ct);
system.Domains = system.Domains.Where(d => d != ownHost).ToArray();
gs.Store(system);
gs.Delete(other);
await gs.SaveChangesAsync(ct);
}
}

[Fact]
public async Task Clearing_Origin_Removes_The_Global_Route()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,17 @@ public async Task<ErrorOr<Success>> ValidateOriginAsync(
return Error.Validation("Application.SubdomainNotUnderPrimary",
$"Subdomain must be a child of the realm's primary domain ('{realm.PrimaryDomain}').");

// Cross-realm uniqueness: the host must not be claimed by any realm's
// plain domains or another App's route.
// Cross-realm uniqueness: the host must not be claimed by ANOTHER realm's
// plain domains or by another App's route. The own realm's plain domain
// list is not a conflict: such a host resolves to this tenant either way,
// and the App route layers on top (the App match wins in RealmCacheLookup).
// Refusing it made every manifest apply on a realm that lists an App's host
// among its own domains die on that App — whatever the draft changed.
var allRealms = await gsession.Query<Realm>().ToListAsync(ct);
foreach (var r in allRealms)
{
if (r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase)))
return Error.Conflict("Application.SubdomainTaken", "That host is already a realm domain.");
if (r.Id != realm.Id && r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase)))
return Error.Conflict("Application.SubdomainTaken", "That host is already a domain of another realm.");
foreach (var kv in r.ApplicationDomains)
{
if (string.Equals(kv.Key, subdomain, StringComparison.OrdinalIgnoreCase)
Expand Down
Loading