diff --git a/deploy/keycloak/realm-dodossh.json b/deploy/keycloak/realm-dodossh.json index 7626467..33b2495 100644 --- a/deploy/keycloak/realm-dodossh.json +++ b/deploy/keycloak/realm-dodossh.json @@ -53,6 +53,9 @@ "email": "alice@example.com", "firstName": "Alice", "lastName": "Example", + "realmRoles": [ + "default-roles-dodossh" + ], "credentials": [ { "type": "password", @@ -68,6 +71,9 @@ "email": "bob@example.com", "firstName": "Bob", "lastName": "Example", + "realmRoles": [ + "default-roles-dodossh" + ], "credentials": [ { "type": "password", diff --git a/docs/platform-flags.md b/docs/platform-flags.md index e39db2a..e715211 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -220,6 +220,24 @@ This does not affect the product: the client uses the system browser, which make affect any non-browser automation against a development Keycloak, which has to carry the cookies by hand (see `ScriptedBrowser`) or be given HTTPS. Two hours of "the credentials must be wrong". +**A user declared in a realm import gets no roles unless `realmRoles` says so** — not even the realm's own +`default-roles-` composite, which Keycloak grants automatically to a user created through the admin +API or the registration form. The realm file's `alice` and `bob` therefore had no role mappings at all, and +because `offline_access` lives inside that composite and the desktop client requests that scope, the very +first sign-in died at the token exchange with `400 Offline tokens not allowed for the user or client`. The +authorization succeeds and the failure lands one step later, which makes it read like a client bug. + +Add `"realmRoles": ["default-roles-dodossh"]` to every user the file declares. And note the asymmetry, +because it is what let this ship: `DodoSSH.SystemTests` used to create its own account through the admin +API, so it exercised a provisioning path no real user takes and passed while the documented `alice` could +not sign in at all. The suite now signs in as the realm's own account, and removing these roles fails it. + +**Keycloak rejects unknown fields in a realm file.** `RealmRepresentation` deserialises with +`FAIL_ON_UNKNOWN_PROPERTIES` enabled, so a `"_comment"` key — the usual way to annotate JSON that has no +comment syntax — does not merely get ignored: the import throws +`Unrecognized field ... not marked as ignorable` and **the container refuses to start at all**. Explanations +about the realm belong here or in the compose file, never in the realm JSON. + **`--import-realm` skips a realm that already exists.** Editing `deploy/keycloak/realm-dodossh.json` and running `docker compose restart keycloak` therefore changes nothing, and the stale configuration keeps being served — which reads exactly like the edit being wrong. `start-dev` keeps its state in an H2 diff --git a/tests/DodoSSH.SystemTests/DevStack.cs b/tests/DodoSSH.SystemTests/DevStack.cs index d73bd4a..9340f0e 100644 --- a/tests/DodoSSH.SystemTests/DevStack.cs +++ b/tests/DodoSSH.SystemTests/DevStack.cs @@ -172,53 +172,28 @@ public sealed class DevStack : IAsyncLifetime } /// - /// Creates a Keycloak user this test run owns. + /// The account the committed realm file declares, which is the one the README tells a user to sign in + /// as. /// /// - /// A fresh account rather than the realm's alice. Enrollment happens once per account and cannot - /// be undone from the client, so an account shared between tests would mean the second one exercises a - /// different path from the first and neither could assert an exact vault state. The realm is thrown - /// away with the container, so this only has to be unique within a run — but making it unique per call - /// is what keeps a second test from silently depending on the first. + /// + /// This suite used to create its own account through Keycloak's admin API instead, and that was a hole + /// rather than a detail: Keycloak grants default-roles-<realm> automatically to a user + /// created through the admin API, and grants nothing at all to a user declared in a realm import + /// without an explicit realmRoles. So the suite exercised a provisioning path no real user + /// takes, and passed while the realm's own alice could not complete a sign-in — the token + /// endpoint answered "Offline tokens not allowed for the user or client", because + /// offline_access lives inside that composite and the desktop client asks for it. + /// + /// + /// Using the realm's account closes that, at the cost of one constraint: enrollment happens once per + /// account and cannot be undone from the client, so this only works because the Keycloak and PostgreSQL + /// containers are both per-run and this assembly holds one test. A second test needing a second + /// identity must declare it in the realm file — with its roles — rather than mint one at runtime, or it + /// reopens exactly this hole. + /// /// - internal async Task CreateUserAsync(CancellationToken cancellationToken) - { - var username = string.Create(CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}"); - - const string Password = "e2e-password"; - - using var http = new HttpClient { BaseAddress = KeycloakBaseUrl }; - - var token = await AdminTokenAsync(http, cancellationToken); - - http.DefaultRequestHeaders.Authorization = new("Bearer", token); - - var user = new JsonObject - { - ["username"] = username, - ["email"] = $"{username}@example.test", - ["firstName"] = "End", - ["lastName"] = "ToEnd", - ["enabled"] = true, - ["emailVerified"] = true, - ["credentials"] = new JsonArray - { - new JsonObject - { - ["type"] = "password", - ["value"] = Password, - ["temporary"] = false, - }, - }, - }; - - using var response = await http.PostAsJsonAsync( - $"/admin/realms/{Realm}/users", user, cancellationToken); - - response.EnsureSuccessStatusCode(); - - return new DevStackUser(username, Password); - } + internal static DevStackUser RealmUser { get; } = new("alice", "alice"); /// /// Through the same design-time factory dotnet ef database update uses, so the migrations and @@ -234,28 +209,7 @@ public sealed class DevStack : IAsyncLifetime await context.Database.MigrateAsync(cancellationToken); } - private static async Task AdminTokenAsync(HttpClient http, CancellationToken cancellationToken) - { - using var form = new FormUrlEncodedContent( - new Dictionary(StringComparer.Ordinal) - { - ["client_id"] = "admin-cli", - ["username"] = "admin", - ["password"] = "admin", - ["grant_type"] = "password", - }); - - using var response = await http.PostAsync( - "/realms/master/protocol/openid-connect/token", form, cancellationToken); - - response.EnsureSuccessStatusCode(); - - var body = await response.Content.ReadAsStringAsync(cancellationToken); - - return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString() - ?? throw new InvalidOperationException("Keycloak returned no admin access token."); - } } -/// An account created for one test run. +/// Credentials for one identity in the development realm. internal sealed record DevStackUser(string Username, string Password); diff --git a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs index c01cf1e..7033903 100644 --- a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs +++ b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs @@ -55,7 +55,9 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture