Public Access
Give the realm's users their roles, and sign in as one in the E2E suite
Signing in failed at the token exchange with `400 Offline tokens not allowed
for the user or client`. A user declared in a realm import gets no role
mappings at all unless realmRoles lists them — 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. alice and bob had none, and
offline_access lives inside that composite, which the desktop client requests.
Verified against the running Keycloak: alice's role-mappings were {} before and
resolve to default-roles-dodossh, offline_access, uma_authorization after.
The authorization request succeeds and the failure lands one step later, at the
code redemption, which makes it read like a client bug. It is not.
The E2E suite could not catch this because it created its own account through
the admin API — exercising a provisioning path no real user takes, and passing
while the account the README tells you to use could not sign in at all. It now
signs in as the realm's own alice, which is sound because the Keycloak and
PostgreSQL containers are per-run so the account is pristine, and this assembly
holds one test. Removing the roles again fails it with exactly the reported
message; that is what makes the coverage real rather than nominal.
Two traps recorded in docs/platform-flags.md, the second found by shipping it
for a moment: Keycloak's RealmRepresentation deserialises with
FAIL_ON_UNKNOWN_PROPERTIES enabled, so the "_comment" key I first used to
explain the roles inside the JSON did not get ignored — the import threw and
the container refused to start. Explanations go in the docs, not in the realm
file.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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-<realm>` 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
|
||||
|
||||
@@ -172,53 +172,28 @@ public sealed class DevStack : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fresh account rather than the realm's <c>alice</c>. 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.
|
||||
/// <para>
|
||||
/// 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 <c>default-roles-<realm></c> 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 <c>realmRoles</c>. So the suite exercised a provisioning path no real user
|
||||
/// takes, and passed while the realm's own <c>alice</c> could not complete a sign-in — the token
|
||||
/// endpoint answered <em>"Offline tokens not allowed for the user or client"</em>, because
|
||||
/// <c>offline_access</c> lives inside that composite and the desktop client asks for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<DevStackUser> 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");
|
||||
|
||||
/// <remarks>
|
||||
/// Through the same design-time factory <c>dotnet ef database update</c> uses, so the migrations and
|
||||
@@ -234,28 +209,7 @@ public sealed class DevStack : IAsyncLifetime
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<string> AdminTokenAsync(HttpClient http, CancellationToken cancellationToken)
|
||||
{
|
||||
using var form = new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>(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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An account created for one test run.</summary>
|
||||
/// <summary>Credentials for one identity in the development realm.</summary>
|
||||
internal sealed record DevStackUser(string Username, string Password);
|
||||
|
||||
@@ -55,7 +55,9 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
||||
[Fact]
|
||||
public async Task TheWholeSlice()
|
||||
{
|
||||
var account = await stack.CreateUserAsync(Token);
|
||||
// The realm file's own account, deliberately — see DevStack.RealmUser. A runtime-minted one hid a
|
||||
// sign-in failure that only the committed configuration had.
|
||||
var account = DevStack.RealmUser;
|
||||
var browser = new ScriptedBrowser(account.Username, account.Password);
|
||||
|
||||
using var connection = await ServerConnection
|
||||
|
||||
Reference in New Issue
Block a user