Skip to content

Commit c8e63be

Browse files
committed
Add KeyCloak realm as a CLI config
1 parent 9f5ff94 commit c8e63be

7 files changed

Lines changed: 284 additions & 20 deletions

File tree

docs/configuration/tenant-management-keycloak.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,7 @@ bundled `ncx-service` client uses. Requires `serviceAccount: true` in the NICo c
162162
```
163163

164164
**Option B, a human user.** The bundled realm ships no human users, so create one, set
165-
`oidc_id`, and assign the role. Interactive login goes through the `nico-rest` client,
166-
which already has the standard flow enabled.
165+
`oidc_id`, and assign the role.
167166

168167
```bash
169168
/opt/keycloak/bin/kcadm.sh create users -r nico \
@@ -184,8 +183,48 @@ which already has the standard flow enabled.
184183
Any value for `oidc_id` works as long as it is unique within the realm and stable for
185184
the life of the user. Changing it later makes NICo treat the login as a new user.
186185

186+
The doubled `u` in `--uusername` is not a typo. `kcadm.sh add-roles` prefixes each
187+
option with the target type, so `--uusername` names a user and `--cclientid` names a
188+
client, while `set-password` in the previous command takes a plain `--username`.
189+
187190
Exit the pod shell when finished.
188191

192+
### How a human user signs in
193+
194+
There is no browser step. `nicocli` implements the OAuth password grant, the client
195+
credentials grant, and refresh-token renewal, and has no authorization-code or device-code
196+
flow, so Keycloak's login page is never shown. `nicocli login` collects the username and
197+
password (prompting when they are not supplied) and exchanges them at the realm's token
198+
endpoint directly. This requires `directAccessGrantsEnabled` on the client, which the
199+
bundled `nico-rest` client has.
200+
201+
Two `nicocli` flag defaults do not match a `setup.sh` deployment and have to be overridden:
202+
`--keycloak-realm` defaults to `nico-dev` and `--client-id` defaults to `nico-api`, both of
203+
which are Kustomize dev values.
204+
205+
```bash
206+
nicocli login \
207+
--keycloak-url http://keycloak.nico-rest:8082 \
208+
--keycloak-realm nico \
209+
--client-id nico-rest \
210+
--client-secret nico-local-secret \
211+
--username tenant-admin@acme-corp.example
212+
```
213+
214+
`--keycloak-url` has to be the host in `externalBaseURL`, because that is what the issuer
215+
is validated against. With the in-cluster default that name does not resolve from a
216+
workstation, so interactive sign-in from outside the cluster needs one of:
217+
218+
- Set `externalBaseURL` to an externally resolvable hostname and expose Keycloak through an
219+
ingress, restricted to the endpoints listed in
220+
[Authentication and Authorization](https://docs.nvidia.com/infra-controller/rest-api-reference/authentication-and-authorization).
221+
This is the production answer.
222+
- Or, for evaluation only, port-forward Keycloak and map the in-cluster name to `127.0.0.1`
223+
in `/etc/hosts`, so the issuer in the minted token still matches.
224+
225+
The Keycloak admin console is for realm administration, not for Tenant sign-in. Human
226+
Tenants never need an account in it.
227+
189228
### Verify the token maps to the org
190229

191230
Request a token from inside the cluster. For Option A:

rest-api/cli/README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ The scalar fields listed below can also be set via a `NICO_*` environment variab
174174
| `NICO_TOKEN_COMMAND` | `auth.token_command` | Shell command that prints a bearer token |
175175
| `NICO_AUTH_SCRIPT` | `auth.token_command` | Alias of `NICO_TOKEN_COMMAND` (canonical name wins when both set) |
176176
| `NICO_TOKEN_URL` | `auth.oidc.token_url` | |
177+
| `NICO_KEYCLOAK_REALM` | `auth.oidc.realm` | Used only when the token endpoint is built from `--keycloak-url` |
177178
| `NICO_CLIENT_ID` | `auth.oidc.client_id` | |
178179
| `NICO_CLIENT_SECRET` | `auth.oidc.client_secret` | |
179180
| `NICO_OIDC_USERNAME` | `auth.oidc.username` | |
@@ -185,7 +186,14 @@ The scalar fields listed below can also be set via a `NICO_*` environment variab
185186
| `NICO_AUTHN_URL` | `auth.api_key.authn_url` | Required for legacy NGC keys; ignored for `nvapi-` bearer keys |
186187
| `NICO_API_KEY_TOKEN` | `auth.api_key.token` | Persisted token after NGC exchange |
187188

188-
`NICO_KEYCLOAK_URL` and `NICO_KEYCLOAK_REALM` do not map to a single config field; they feed the login command and construct the OIDC `token_url` at login time.
189+
`NICO_KEYCLOAK_URL` does not map to a config field of its own; it feeds the login command and
190+
constructs the OIDC `token_url`, which is what gets persisted.
191+
192+
`--keycloak-realm` and `--client-id` both carry built-in defaults (`nico-dev` and `nico-api`)
193+
that match the Kustomize development realm, not the realm `helm-prereqs/setup.sh` installs.
194+
Set `auth.oidc.realm` and `auth.oidc.client_id` for your deployment rather than relying on
195+
them. When a login fails, the error names the token endpoint it contacted and any value that
196+
came from a built-in default.
189197

190198
Client-credentials configurations can also set `auth.oidc.scopes` as a YAML list,
191199
`auth.oidc.token_parameters` as a map of additional non-secret form parameters,
@@ -307,7 +315,7 @@ Commands follow `nicocli <resource> [sub-resource] <action> [args] [flags]`.
307315

308316
Nested API paths appear as sub-resource groups:
309317

310-
```
318+
```bash
311319
nicocli allocation list
312320
nicocli allocation constraint list
313321
nicocli allocation constraint create <allocationId>
@@ -330,7 +338,7 @@ nicocli completion fish > ~/.config/fish/completions/nicocli.fish
330338

331339
Each environment (local dev, staging, prod) gets its own config file in `~/.nico/`:
332340

333-
```
341+
```text
334342
~/.nico/config.yaml # default (local dev)
335343
~/.nico/config.staging.yaml # staging
336344
~/.nico/config.prod.yaml # production

rest-api/cli/pkg/auth.go

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,64 @@ func extractNGCToken(body []byte) string {
376376
return resp.AccessToken
377377
}
378378

379+
// resolveOIDCRealm returns the Keycloak realm used to build the token endpoint from
380+
// --keycloak-url, and whether it came from the flag's built-in default rather than from
381+
// the command line or config. An explicit flag wins over config, matching client-id.
382+
func resolveOIDCRealm(c *cli.Context, cfg *ConfigFile) (realm string, fromDefault bool) {
383+
if cliFlagExplicitlySet(c, "keycloak-realm") {
384+
return c.String("keycloak-realm"), false
385+
}
386+
if cfg.Auth.OIDC != nil && cfg.Auth.OIDC.Realm != "" {
387+
return cfg.Auth.OIDC.Realm, false
388+
}
389+
return c.String("keycloak-realm"), true
390+
}
391+
392+
// resolveOIDCClientID returns the OAuth client ID, and whether it came from the flag's
393+
// built-in default rather than from the command line or config.
394+
func resolveOIDCClientID(c *cli.Context, cfg *ConfigFile) (clientID string, fromDefault bool) {
395+
if cliFlagExplicitlySet(c, "client-id") {
396+
return c.String("client-id"), false
397+
}
398+
if cfg.Auth.OIDC != nil && cfg.Auth.OIDC.ClientID != "" {
399+
return cfg.Auth.OIDC.ClientID, false
400+
}
401+
return c.String("client-id"), true
402+
}
403+
404+
// loginFailureHint names the token endpoint that was actually contacted, plus any values
405+
// that came from a built-in default. A realm or client that does not exist in the target
406+
// Keycloak fails with a 404 or invalid_client that identifies neither, so without this
407+
// the built-in defaults are invisible in the error.
408+
func loginFailureHint(tokenURL string, defaulted []string) string {
409+
hint := "\n token endpoint: " + tokenURL
410+
if len(defaulted) > 0 {
411+
hint += "\n using built-in default " + strings.Join(defaulted, " and ")
412+
hint += "\n pass the flag explicitly, or set auth.oidc in " + ConfigPath()
413+
}
414+
return hint
415+
}
416+
379417
func loginWithOIDCCmd(c *cli.Context, cfg *ConfigFile) error {
380418
tokenURL := c.String("token-url")
381419
if tokenURL == "" && cfg.Auth.OIDC != nil {
382420
tokenURL = cfg.Auth.OIDC.TokenURL
383421
}
422+
423+
// Values that fell back to a built-in default, recorded so a failed login can name
424+
// them. Only populated where the value was actually used: a realm supplied through
425+
// --token-url never goes through resolveOIDCRealm.
426+
var defaulted []string
427+
var resolvedRealm string
428+
384429
if tokenURL == "" {
385-
if keycloakURL := c.String("keycloak-url"); keycloakURL != "" {
386-
realm := c.String("keycloak-realm")
430+
keycloakURL := c.String("keycloak-url")
431+
if keycloakURL != "" {
432+
realm, realmFromDefault := resolveOIDCRealm(c, cfg)
433+
if realmFromDefault {
434+
defaulted = append(defaulted, "--keycloak-realm="+realm)
435+
}
436+
resolvedRealm = realm
387437
tokenURL = fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token",
388438
strings.TrimRight(keycloakURL, "/"), realm)
389439
}
@@ -392,9 +442,9 @@ func loginWithOIDCCmd(c *cli.Context, cfg *ConfigFile) error {
392442
return fmt.Errorf("--token-url or --keycloak-url is required (or set auth.oidc.token_url in config)")
393443
}
394444

395-
clientID := c.String("client-id")
396-
if cfg.Auth.OIDC != nil && cfg.Auth.OIDC.ClientID != "" && !cliFlagExplicitlySet(c, "client-id") {
397-
clientID = cfg.Auth.OIDC.ClientID
445+
clientID, clientIDFromDefault := resolveOIDCClientID(c, cfg)
446+
if clientIDFromDefault {
447+
defaulted = append(defaulted, "--client-id="+clientID)
398448
}
399449

400450
clientSecret := c.String("client-secret")
@@ -443,7 +493,7 @@ func loginWithOIDCCmd(c *cli.Context, cfg *ConfigFile) error {
443493
tokenResp, err = passwordGrant(tokenURL, clientID, clientSecret, username, password)
444494
}
445495
if err != nil {
446-
return err
496+
return fmt.Errorf("%w%s", err, loginFailureHint(tokenURL, defaulted))
447497
}
448498

449499
if cfg.Auth.OIDC == nil {
@@ -455,6 +505,11 @@ func loginWithOIDCCmd(c *cli.Context, cfg *ConfigFile) error {
455505
cfg.Auth.OIDC.TokenURL = tokenURL
456506
cfg.Auth.OIDC.ClientID = clientID
457507
cfg.Auth.OIDC.ClientSecret = clientSecret
508+
// Only persist a realm that was used to build tokenURL; a realm is meaningless
509+
// against a token endpoint supplied directly.
510+
if resolvedRealm != "" {
511+
cfg.Auth.OIDC.Realm = resolvedRealm
512+
}
458513

459514
if err := SaveConfig(cfg); err != nil {
460515
return fmt.Errorf("saving config: %w", err)

rest-api/cli/pkg/auth_test.go

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ func TestLoginWithOIDCCmd(t *testing.T) {
115115
name string
116116
clientIDArgs []string
117117
wantClientID string
118+
// rejectStatus, when non-zero, makes the token endpoint fail so the case can
119+
// assert what a failed login reports back to the user.
120+
rejectStatus int
121+
wantErr string
118122
}{
119123
{
120124
name: "preserves configured client ID",
@@ -125,6 +129,12 @@ func TestLoginWithOIDCCmd(t *testing.T) {
125129
clientIDArgs: []string{"--client-id", "override-id"},
126130
wantClientID: "override-id",
127131
},
132+
{
133+
name: "failed login names the token endpoint",
134+
wantClientID: "client-id",
135+
rejectStatus: http.StatusNotFound,
136+
wantErr: "token endpoint: ",
137+
},
128138
}
129139

130140
for _, tt := range tests {
@@ -137,6 +147,10 @@ func TestLoginWithOIDCCmd(t *testing.T) {
137147
require.True(t, ok)
138148
require.Equal(t, tt.wantClientID, clientID)
139149
require.Equal(t, "client-secret", clientSecret)
150+
if tt.rejectStatus != 0 {
151+
w.WriteHeader(tt.rejectStatus)
152+
return
153+
}
140154
w.Header().Set("Content-Type", "application/json")
141155
_, _ = w.Write([]byte(`{"access_token":"new-token","expires_in":3600}`))
142156
}))
@@ -159,11 +173,154 @@ func TestLoginWithOIDCCmd(t *testing.T) {
159173
args := append([]string{"nicocli", "--token-url", server.URL}, tt.clientIDArgs...)
160174
args = append(args, "login")
161175
withArgs(t, args...)
162-
require.NoError(t, app.Run(os.Args))
176+
177+
runErr := app.Run(os.Args)
178+
if tt.wantErr != "" {
179+
require.Error(t, runErr)
180+
require.Contains(t, runErr.Error(), tt.wantErr)
181+
require.Contains(t, runErr.Error(), server.URL)
182+
return
183+
}
184+
require.NoError(t, runErr)
185+
})
186+
}
187+
}
188+
189+
func TestResolveOIDCRealm(t *testing.T) {
190+
tests := []struct {
191+
name string
192+
args []string
193+
configRealm string
194+
wantRealm string
195+
wantFromDefault bool
196+
}{
197+
{
198+
name: "falls back to the flag default",
199+
wantRealm: "nico-dev",
200+
wantFromDefault: true,
201+
},
202+
{
203+
name: "configured realm beats the flag default",
204+
configRealm: "nico",
205+
wantRealm: "nico",
206+
},
207+
{
208+
name: "explicit flag beats the configured realm",
209+
args: []string{"--keycloak-realm", "nico-prod"},
210+
configRealm: "nico",
211+
wantRealm: "nico-prod",
212+
},
213+
}
214+
215+
for _, tt := range tests {
216+
t.Run(tt.name, func(t *testing.T) {
217+
withArgs(t, append([]string{"nicocli"}, tt.args...)...)
218+
c := newRealmFlagContext(t, tt.args)
219+
cfg := &ConfigFile{Auth: ConfigAuth{OIDC: &ConfigOIDC{Realm: tt.configRealm}}}
220+
221+
realm, fromDefault := resolveOIDCRealm(c, cfg)
222+
require.Equal(t, tt.wantRealm, realm)
223+
require.Equal(t, tt.wantFromDefault, fromDefault)
224+
})
225+
}
226+
}
227+
228+
func TestResolveOIDCClientID(t *testing.T) {
229+
tests := []struct {
230+
name string
231+
args []string
232+
configClientID string
233+
wantClientID string
234+
wantFromDefault bool
235+
}{
236+
{
237+
name: "falls back to the flag default",
238+
wantClientID: "nico-api",
239+
wantFromDefault: true,
240+
},
241+
{
242+
name: "configured client beats the flag default",
243+
configClientID: "nico-rest",
244+
wantClientID: "nico-rest",
245+
},
246+
{
247+
name: "explicit flag beats the configured client",
248+
args: []string{"--client-id", "override-id"},
249+
configClientID: "nico-rest",
250+
wantClientID: "override-id",
251+
},
252+
}
253+
254+
for _, tt := range tests {
255+
t.Run(tt.name, func(t *testing.T) {
256+
withArgs(t, append([]string{"nicocli"}, tt.args...)...)
257+
c := newClientIDFlagContext(t, tt.args)
258+
cfg := &ConfigFile{Auth: ConfigAuth{OIDC: &ConfigOIDC{ClientID: tt.configClientID}}}
259+
260+
clientID, fromDefault := resolveOIDCClientID(c, cfg)
261+
require.Equal(t, tt.wantClientID, clientID)
262+
require.Equal(t, tt.wantFromDefault, fromDefault)
263+
})
264+
}
265+
}
266+
267+
func TestLoginFailureHint(t *testing.T) {
268+
tests := []struct {
269+
name string
270+
defaulted []string
271+
wantContain []string
272+
wantAbsent string
273+
}{
274+
{
275+
name: "reports only the endpoint when nothing was defaulted",
276+
wantContain: []string{"token endpoint: https://kc.example/token"},
277+
wantAbsent: "built-in default",
278+
},
279+
{
280+
name: "names a single default",
281+
defaulted: []string{"--keycloak-realm=nico-dev"},
282+
wantContain: []string{"built-in default --keycloak-realm=nico-dev", "auth.oidc"},
283+
},
284+
{
285+
name: "joins multiple defaults",
286+
defaulted: []string{"--keycloak-realm=nico-dev", "--client-id=nico-api"},
287+
wantContain: []string{"--keycloak-realm=nico-dev and --client-id=nico-api"},
288+
},
289+
}
290+
291+
for _, tt := range tests {
292+
t.Run(tt.name, func(t *testing.T) {
293+
hint := loginFailureHint("https://kc.example/token", tt.defaulted)
294+
for _, want := range tt.wantContain {
295+
require.Contains(t, hint, want)
296+
}
297+
if tt.wantAbsent != "" {
298+
require.NotContains(t, hint, tt.wantAbsent)
299+
}
163300
})
164301
}
165302
}
166303

304+
// newRealmFlagContext builds a cli.Context carrying only the keycloak-realm flag, with
305+
// the same default the login command declares.
306+
func newRealmFlagContext(t *testing.T, args []string) *cli.Context {
307+
t.Helper()
308+
set := flag.NewFlagSet("test", flag.ContinueOnError)
309+
set.String("keycloak-realm", "nico-dev", "")
310+
require.NoError(t, set.Parse(args))
311+
return cli.NewContext(nil, set, nil)
312+
}
313+
314+
// newClientIDFlagContext builds a cli.Context carrying only the client-id flag, with the
315+
// same default the login command declares.
316+
func newClientIDFlagContext(t *testing.T, args []string) *cli.Context {
317+
t.Helper()
318+
set := flag.NewFlagSet("test", flag.ContinueOnError)
319+
set.String("client-id", "nico-api", "")
320+
require.NoError(t, set.Parse(args))
321+
return cli.NewContext(nil, set, nil)
322+
}
323+
167324
func TestExtractNGCToken(t *testing.T) {
168325
tests := []struct {
169326
name string

0 commit comments

Comments
 (0)