Skip to content

Commit 96cd886

Browse files
committed
[Server] add WLCG IAM support: scope export, xrdtoken, and docs.
Normalize JSON-array scope claims for XrdAccToken, add IAM/WLCG xrdtoken providers, and document CERN SSO vs WLCG IAM configuration. Assisted-by: Cursor:Opus-4.8 CursorAI
1 parent aa5b620 commit 96cd886

5 files changed

Lines changed: 225 additions & 11 deletions

File tree

src/XrdOuc/XrdOucOIDC.cc

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,33 @@ bool getStringClaim(const std::string &json, const char *claim, std::string &val
217217
return getStringClaim(obj, claim, val);
218218
}
219219

220+
bool getEntityClaimValue(const nlohmann::json &obj, const char *claim,
221+
std::string &val)
222+
{
223+
if (!obj.is_object()) return false;
224+
auto it = obj.find(claim);
225+
if (it == obj.end()) return false;
226+
if (it->is_string())
227+
{
228+
val = it->get<std::string>();
229+
return !val.empty();
230+
}
231+
if (it->is_array())
232+
{
233+
val.clear();
234+
for (const auto &elem : *it)
235+
{
236+
if (!elem.is_string()) continue;
237+
const std::string one = elem.get<std::string>();
238+
if (one.empty()) continue;
239+
if (!val.empty()) val.push_back(' ');
240+
val += one;
241+
}
242+
return !val.empty();
243+
}
244+
return false;
245+
}
246+
220247
struct EntityClaimMapping {
221248
std::string jwtClaim;
222249
std::string attrKey;
@@ -353,7 +380,7 @@ void collectEntityClaims(const std::string &payloadJSON,
353380
if (!parseJsonObject(payloadJSON, payloadObj)) return;
354381
for (const auto &mapping : EntityClaimMappings)
355382
{std::string val;
356-
if (getStringClaim(payloadObj, mapping.jwtClaim.c_str(), val)
383+
if (getEntityClaimValue(payloadObj, mapping.jwtClaim.c_str(), val)
357384
&& isSafeEntityAttrValue(val))
358385
out[mapping.attrKey] = val;
359386
}

src/XrdSecoidc/README.md

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,68 @@ sec.protocol oidc \
162162
-expiry required
163163
```
164164

165+
## CERN SSO vs WLCG IAM
166+
167+
CERN operates two related but distinct OIDC ecosystems:
168+
169+
| | CERN SSO (Keycloak) | WLCG IAM (INDIGO IAM) |
170+
| - | --------------------- | ---------------------- |
171+
| Role | Login / identity for CERN apps | VO-scoped tokens for WLCG storage/compute |
172+
| Typical issuer | `https://auth.cern.ch/auth/realms/cern` | VO-specific IAM URL (e.g. `https://wlcg.cloud.cnaf.infn.it/`) |
173+
| Token for XRootD | `id_token` or SSO `access_token` | WLCG-profile **`access_token` JWT** |
174+
| Key claims | `sub`, `email`, `preferred_username` | `scope` (`storage.*`), `wlcg.ver`, `sub` |
175+
| Storage authorization | **No** `storage.*` scopes in SSO tokens | **Yes**, with `XrdAccToken` |
176+
177+
`XrdSecoidc` accepts **both** issuers when configured. Use CERN SSO for
178+
authentication-only setups. For EOS/XRootD path authorization, clients must
179+
present an **IAM access token** with WLCG storage scopes — a CERN SSO
180+
`id_token` from `xrdtoken CERNOIDC` is not sufficient on its own.
181+
182+
### WLCG IAM server configuration
183+
184+
**`xrootd.cf` (authentication + claim export):**
185+
186+
```conf
187+
xrootd.seclib libXrdSec.so
188+
xrootd.tls all
189+
190+
sec.protparm oidc -issuer https://wlcg.cloud.cnaf.infn.it/
191+
sec.protparm oidc -audience <iam-client-id>
192+
sec.protparm oidc -expiry required
193+
sec.protparm oidc -entity-claim scope
194+
sec.protparm oidc -entity-claim wlcg.ver
195+
sec.protocol oidc
196+
197+
ofs.authorize
198+
ofs.authlib libXrdAccToken.so
199+
acctoken.basepath /eos/user
200+
acctoken.onmissing deny
201+
```
202+
203+
**INI equivalent:** see `src/XrdSecoidc/configs/wlcg-iam.example.cfg`.
204+
205+
**Client token acquisition:**
206+
207+
```sh
208+
./utils/xrdtoken WLCG \
209+
--client-id <iam-client-id> \
210+
--scope "storage.read:/public storage.modify:/alice"
211+
# or: xrdtoken IAM --issuer https://<vo-iam>/ --client-id ... --scope ...
212+
```
213+
214+
The stored JWT should contain `wlcg.ver` and a space-separated `scope` claim
215+
(or a JSON array of scope strings, which is normalized on export).
216+
217+
### Dual-issuer deployments
218+
219+
A single server may configure multiple `[issuer ...]` blocks — for example CERN
220+
SSO for interactive identity checks and a WLCG IAM issuer for storage tokens.
221+
Each issuer may define its own `base_path` and `restricted_path` values.
222+
165223
## Standard CERN SSO configuration
166224

225+
Use this for CERN Keycloak identity only (not WLCG storage scopes):
226+
167227
Inline `xrootd.cf` style:
168228

169229
```conf
@@ -338,12 +398,13 @@ logs which token source/file the client selected.
338398

339399
For quick manual testing, helper scripts are available in `utils/`:
340400

341-
- `utils/xrdtoken create [tokenfile] [CERN|CERNOIDC|GOOGLE|GITHUB]`: creates token
342-
(`CERN` uses OAuth2 access token flow, `CERNOIDC` requests OIDC scopes and
343-
requires/stores `id_token`, `GOOGLE` defaults to device flow, `GITHUB` stores
344-
an opaque OAuth `access_token`).
345-
- Shortcut: `utils/xrdtoken CERNOIDC [tokenfile]` is equivalent to
346-
`utils/xrdtoken create [tokenfile] CERNOIDC`.
401+
- `utils/xrdtoken create [tokenfile] [CERN|CERNOIDC|GOOGLE|GITHUB|IAM|WLCG]`:
402+
creates token (`CERN` uses OAuth2 access token flow, `CERNOIDC` requests OIDC
403+
scopes and requires/stores `id_token`, `GOOGLE` defaults to device flow, `GITHUB`
404+
stores an opaque OAuth `access_token`, `IAM`/`WLCG` request WLCG storage
405+
scopes and store the IAM `access_token` JWT).
406+
- Shortcuts: `utils/xrdtoken CERNOIDC [tokenfile]`, `utils/xrdtoken WLCG ...`,
407+
`utils/xrdtoken IAM ...` (IAM and WLCG are aliases).
347408
- `utils/xrdtoken show [tokenfile]`: decodes and prints JWT header/payload.
348409
- If `<tokenfile>` is omitted, default is `${XDG_RUNTIME_DIR}/bt_u<uid>`
349410
(or `/tmp/bt_u<uid>` when `XDG_RUNTIME_DIR` is unset).
@@ -361,12 +422,22 @@ settings. `GITHUB` supports `--flow device|pkce` (`device` default); PKCE
361422
requires `--client-secret` (or `GITHUB_OAUTH_CLIENT_SECRET`).
362423
- Device flow prints a prefilled verification URL when possible; GitHub prints
363424
the verification page URL and a separate user code to enter manually.
425+
- For `IAM`/`WLCG`, provide `--client-id` (or `WLCG_IAM_CLIENT_ID`) and a
426+
required `--scope` with WLCG storage scopes. Override the IAM issuer with
427+
`--issuer` (or `WLCG_IAM_ISSUER`; default CNAF test instance).
364428

365429
Example:
366430

367431
```sh
368432
./utils/xrdtoken create
369433
./utils/xrdtoken CERNOIDC
434+
./utils/xrdtoken WLCG \
435+
--client-id "<iam-client-id>" \
436+
--scope "storage.read:/public storage.modify:/alice"
437+
./utils/xrdtoken IAM \
438+
--issuer https://wlcg.cloud.cnaf.infn.it/ \
439+
--client-id "<iam-client-id>" \
440+
--scope "storage.read:/"
370441
./utils/xrdtoken create GOOGLE \
371442
--client-id "<google-oauth-client-id>" \
372443
--client-secret "<google-oauth-client-secret>"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Example OIDC configuration for a WLCG IAM issuer (CERN/WLCG token profile).
2+
#
3+
# Replace <iam-client-id> with the OAuth client ID registered at your IAM
4+
# instance. Production VO deployments use VO-specific issuer URLs; the CNAF
5+
# test instance is shown below.
6+
#
7+
# Use together with XrdAccToken (ofs.authlib libXrdAccToken.so) so storage.*
8+
# scopes and wlcg.ver from the access token are enforced.
9+
10+
[global]
11+
expiry = required
12+
entity-claim = scope
13+
entity-claim = wlcg.ver
14+
15+
[issuer "https://wlcg.cloud.cnaf.infn.it/"]
16+
audience = <iam-client-id>
17+
base_path = /eos
18+
restricted_path = /public/
19+
restricted_path = /shared/
20+
21+
# Production VO issuers differ, for example:
22+
# [issuer "https://atlas-wlcg.cern.ch/"]
23+
# audience = <vo-iam-client-id>
24+
# base_path = /eos/atlas
25+
# restricted_path = /public/

tests/XrdSec/XrdSecOIDC.cc

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,33 @@ TEST(XrdSecOIDCTest, PreferredUsernameWinsOverSub)
915915
freeKeys(IssuerPolicies[0]->jwksKeys);
916916
}
917917

918+
TEST(XrdSecOIDCTest, EntityClaimsExportScopeJsonArray)
919+
{
920+
ResetGlobalsForTest();
921+
std::string emsg;
922+
ASSERT_TRUE(storeEntityClaimEntry("scope", emsg)) << emsg;
923+
924+
KeyMaterial km = MakeKeyAndJWKS();
925+
ASSERT_NE(km.pkey, nullptr);
926+
InstallTestKey(km);
927+
928+
const std::string hdr = R"({"alg":"RS256","kid":"k1","typ":"JWT"})";
929+
const std::string payload = "{" + IssuerClaim() +
930+
R"(,"aud":"xrootd","exp":)" +
931+
std::to_string(static_cast<long long>(NowSeconds() + 600)) +
932+
R"(,"sub":"alice","scope":["storage.read:/public","storage.modify:/alice"],"wlcg.ver":"1.0"})";
933+
const std::string token = MakeJWT(km.pkey, hdr, payload);
934+
935+
std::string identity;
936+
std::map<std::string, std::string> entityAttrs;
937+
ASSERT_TRUE(validateToken(token.c_str(), identity, emsg, nullptr, &entityAttrs)) << emsg;
938+
EXPECT_EQ(entityAttrs["token.scope"],
939+
"storage.read:/public storage.modify:/alice");
940+
941+
EVP_PKEY_free(km.pkey);
942+
freeKeys(IssuerPolicies[0]->jwksKeys);
943+
}
944+
918945
TEST(XrdSecOIDCTest, EntityClaimsPopulateMappedAttributes)
919946
{
920947
ResetGlobalsForTest();

utils/xrdtoken

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
2222
from pathlib import Path
2323
from typing import Any, Optional
2424

25-
PROVIDERS = frozenset({"CERN", "CERNOIDC", "GOOGLE", "GITHUB"})
25+
PROVIDERS = frozenset({"CERN", "CERNOIDC", "GOOGLE", "GITHUB", "IAM", "WLCG"})
2626

2727

2828
@dataclass
@@ -38,7 +38,7 @@ class ProviderSettings:
3838

3939
def usage() -> str:
4040
return """Usage:
41-
xrdtoken create [tokenfile] [CERN|CERNOIDC|GOOGLE|GITHUB] [--provider CERN|CERNOIDC|GOOGLE|GITHUB] [--flow device|pkce] [--client-id ID] [--client-secret SECRET] [--scope SCOPE] [--auth-ep URL] [--device-ep URL] [--token-ep URL]
41+
xrdtoken create [tokenfile] [CERN|CERNOIDC|GOOGLE|GITHUB|IAM|WLCG] [--provider CERN|CERNOIDC|GOOGLE|GITHUB|IAM|WLCG] [--flow device|pkce] [--issuer URL] [--client-id ID] [--client-secret SECRET] [--scope SCOPE] [--auth-ep URL] [--device-ep URL] [--token-ep URL]
4242
xrdtoken show [tokenfile]
4343
4444
Defaults:
@@ -71,6 +71,14 @@ Defaults:
7171
client-id : must be provided (enable Device flow in the OAuth app settings)
7272
client-secret : not required for device flow; required for --flow pkce
7373
stored token : access_token (opaque GitHub OAuth token, not a JWT)
74+
IAM / WLCG (aliases):
75+
flow : device
76+
issuer : https://wlcg.cloud.cnaf.infn.it/ (override with --issuer or WLCG_IAM_ISSUER)
77+
device-ep : <issuer>/devicecode
78+
token-ep : <issuer>/token
79+
scope : required WLCG storage scopes (e.g. storage.read:/public storage.modify:/alice)
80+
client-id : must be provided (or WLCG_IAM_CLIENT_ID / OIDC_CLIENT_ID)
81+
stored token : access_token (WLCG-profile JWT for XrdSecoidc + XrdAccToken)
7482
tokenfile : ${XDG_RUNTIME_DIR}/bt_u<uid> (fallback: /tmp/bt_u<uid>)"""
7583

7684

@@ -87,8 +95,39 @@ def default_tokenfile() -> Path:
8795
return Path(f"/tmp/bt_u{uid}")
8896

8997

98+
def iam_issuer_base(issuer: Optional[str] = None) -> str:
99+
base = (
100+
issuer
101+
or os.environ.get("WLCG_IAM_ISSUER")
102+
or os.environ.get("OIDC_ISSUER")
103+
or "https://wlcg.cloud.cnaf.infn.it/"
104+
)
105+
return base.rstrip("/")
106+
107+
108+
def endpoints_from_issuer(issuer: str) -> tuple[str, str, str]:
109+
base = iam_issuer_base(issuer)
110+
return (
111+
f"{base}/devicecode",
112+
f"{base}/token",
113+
f"{base}/authorize",
114+
)
115+
116+
90117
def provider_defaults(provider: str) -> ProviderSettings:
91118
provider = provider.upper()
119+
if provider in {"IAM", "WLCG"}:
120+
device_ep, token_ep, auth_ep = endpoints_from_issuer("")
121+
return ProviderSettings(
122+
client_id=os.environ.get("WLCG_IAM_CLIENT_ID")
123+
or os.environ.get("OIDC_CLIENT_ID")
124+
or "",
125+
flow="device",
126+
scope="",
127+
auth_ep=auth_ep,
128+
device_ep=device_ep,
129+
token_ep=token_ep,
130+
)
92131
if provider == "CERN":
93132
return ProviderSettings(
94133
client_id="public-client",
@@ -120,7 +159,7 @@ def provider_defaults(provider: str) -> ProviderSettings:
120159
token_ep="https://github.com/login/oauth/access_token",
121160
scope="read:user",
122161
)
123-
die(f"unsupported provider '{provider}' (expected CERN, CERNOIDC, GOOGLE, or GITHUB)")
162+
die(f"unsupported provider '{provider}' (expected CERN, CERNOIDC, GOOGLE, GITHUB, IAM, or WLCG)")
124163

125164

126165
def parse_oauth_response(body: str) -> dict[str, Any]:
@@ -194,6 +233,8 @@ def chosen_token(provider: str, token_resp: dict[str, Any]) -> str:
194233
id_token = token_resp.get("id_token") or ""
195234
if provider == "CERNOIDC":
196235
return id_token
236+
if provider in {"IAM", "WLCG"}:
237+
return access_token
197238
if provider == "GOOGLE" and id_token:
198239
return id_token
199240
return access_token
@@ -224,6 +265,7 @@ def parse_create_options(argv: list[str], provider: str) -> tuple[ProviderSettin
224265
parser = argparse.ArgumentParser(add_help=False)
225266
parser.add_argument("--provider")
226267
parser.add_argument("--flow")
268+
parser.add_argument("--issuer")
227269
parser.add_argument("--client-id")
228270
parser.add_argument("--client-secret")
229271
parser.add_argument("--scope")
@@ -239,6 +281,11 @@ def parse_create_options(argv: list[str], provider: str) -> tuple[ProviderSettin
239281
provider = args.provider.upper()
240282
if args.flow:
241283
settings.flow = args.flow.lower()
284+
if args.issuer:
285+
device_ep, token_ep, auth_ep = endpoints_from_issuer(args.issuer)
286+
settings.device_ep = device_ep
287+
settings.token_ep = token_ep
288+
settings.auth_ep = auth_ep
242289
if args.client_id is not None:
243290
settings.client_id = args.client_id
244291
if args.client_secret is not None:
@@ -442,9 +489,24 @@ def cmd_create(argv: list[str]) -> None:
442489
if settings.flow == "pkce" and not settings.client_secret:
443490
die("GITHUB pkce flow requires --client-secret (or GITHUB_OAUTH_CLIENT_SECRET)")
444491

445-
if provider in {"CERN", "CERNOIDC"} and settings.flow != "device":
492+
if provider in {"CERN", "CERNOIDC", "IAM", "WLCG"} and settings.flow != "device":
446493
die(f"{provider} provider currently supports only --flow device")
447494

495+
if provider in {"IAM", "WLCG"}:
496+
if not settings.client_id:
497+
settings.client_id = (
498+
os.environ.get("WLCG_IAM_CLIENT_ID")
499+
or os.environ.get("OIDC_CLIENT_ID")
500+
or ""
501+
)
502+
if not settings.client_id:
503+
die("IAM/WLCG provider requires --client-id (or WLCG_IAM_CLIENT_ID)")
504+
if not settings.scope:
505+
die(
506+
"IAM/WLCG provider requires --scope with WLCG storage scopes "
507+
"(e.g. 'storage.read:/public storage.modify:/alice')"
508+
)
509+
448510
if provider in {"GOOGLE", "GITHUB"} and settings.flow == "pkce":
449511
token_resp = pkce_flow(provider, settings)
450512
else:
@@ -467,6 +529,8 @@ def cmd_create(argv: list[str]) -> None:
467529
print("Stored token type: access_token (non-JWT possible)")
468530
if provider == "CERNOIDC":
469531
print("Stored token type: id_token (JWT)")
532+
if provider in {"IAM", "WLCG"}:
533+
print("Stored token type: access_token (WLCG-profile JWT)")
470534
if provider == "GITHUB":
471535
print("Stored token type: access_token (GitHub OAuth token, not a JWT)")
472536
expires_in = token_resp.get("expires_in")

0 commit comments

Comments
 (0)