diff --git a/docs/guides/operator-dashboard-sso.md b/docs/guides/operator-dashboard-sso.md
index cf2c9f2d..0a111234 100644
--- a/docs/guides/operator-dashboard-sso.md
+++ b/docs/guides/operator-dashboard-sso.md
@@ -11,9 +11,10 @@ public client). Distinct from the agent/callout machine auth
secret), redirect URI `https://fleet-stg./auth/callback`, post-logout URI
`https://fleet-stg./`. Copy its **Client ID**.
1b. **Roles** — the dashboard requires the **`fleet-admin`** project role. Create that
- role on the project, enable the project's **Assert Roles on Authentication** (so the
- role lands in the ID token), and grant it to each operator user. Without it they
- authenticate but get **403 Access denied**.
+ role on the project and grant it to each operator user. The login flow requests
+ roles **in-band** via the OIDC scope `urn:zitadel:iam:org:project:roles`, so the
+ project's **Assert Roles on Authentication** checkbox is **not** needed. Without
+ the role grant, users authenticate but get **403 Access denied**.
2. **Seed config** in OpenBao (namespace `fleet-staging`) — the deploy derives every
host from `base_domain`, so you set only:
- `FleetDeployConfig.operator_oidc_client_id` = the Client ID
@@ -49,8 +50,9 @@ on the app's **Development Mode** (Zitadel rejects non-HTTPS redirects otherwise
The operator reads `ZitadelAuthConfig` + `OperatorCookieKey` via ConfigClient. The
deploy derives `zitadel_base` / `base_url` / `logout_redirect_uri` from `base_domain`
(`https://sso-stg.`, `https://fleet-stg.`, `…/`) and fixes
-`scope = openid profile email`; you supply `client_id`, `trusted_audiences`,
-`cookie_key_b64`. All endpoints derive from `zitadel_base`:
+`scope = openid profile email` (the login flow appends
+`urn:zitadel:iam:org:project:roles` so roles are always asserted); you supply
+`client_id`, `trusted_audiences`, `cookie_key_b64`. All endpoints derive from `zitadel_base`:
`/.well-known/openid-configuration`, `/oauth/v2/authorize`, `/oauth/v2/token`,
`/oidc/v1/end_session`.
diff --git a/harmony_zitadel_auth/src/login.rs b/harmony_zitadel_auth/src/login.rs
index c92aae1b..8be81bef 100644
--- a/harmony_zitadel_auth/src/login.rs
+++ b/harmony_zitadel_auth/src/login.rs
@@ -134,6 +134,22 @@ pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result String {
+ if scope
+ .split_whitespace()
+ .any(|s| s == ZITADEL_PROJECT_ROLES_SCOPE)
+ {
+ scope.to_string()
+ } else {
+ format!("{scope} {ZITADEL_PROJECT_ROLES_SCOPE}")
+ }
+}
+
pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result {
let state = random_url_token(32);
let pkce_code_verifier = random_url_token(32);
@@ -145,7 +161,7 @@ pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result {
.append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", &config.redirect_uri())
.append_pair("response_type", "code")
- .append_pair("scope", &config.scope)
+ .append_pair("scope", &ensure_roles_scope(&config.scope))
.append_pair("code_challenge", &code_challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", &state)
@@ -225,4 +241,18 @@ mod tests {
let challenge = pkce_s256_challenge(code_verifier);
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
+
+ #[test]
+ fn ensure_roles_scope_appends_when_absent() {
+ assert_eq!(
+ ensure_roles_scope("openid profile email"),
+ "openid profile email urn:zitadel:iam:org:project:roles"
+ );
+ }
+
+ #[test]
+ fn ensure_roles_scope_is_idempotent() {
+ let s = "openid urn:zitadel:iam:org:project:roles email";
+ assert_eq!(ensure_roles_scope(s), s);
+ }
}