Repository navigation
Conversation
`loginConfig.applyDefaults()` keyed off the zero value rather than set-ness, so it could not distinguish an unset field from one a caller explicitly set to its zero value: - `WithTimeout(0)` was silently replaced by the 2m default. - `WithScopes()` (explicit empty) was replaced by the default scopes, even though `WithScopes` already records `scopesSet`. Switch `applyDefaults` to consult the `*Set` sentinels, add a `timeoutSet` sentinel set by `WithTimeout`, and guard the client-credentials exchange so a zero timeout means "no deadline" instead of creating an already-expired context (matching Login and DeviceFlow). `WithTimeout(0)` now means "no timeout". Unset fields still receive their defaults; non-zero explicit values are unaffected. Signed-off-by: Roland Huß <rhuss@redhat.com>
Login and DeviceLogin authenticate a user, so their requests must be
OpenID Connect ones. Both passed the caller's scopes through verbatim,
so WithScopes("profile") produced an authorization request without
"openid", and buildAuthURL set the parameter unconditionally, so an
explicitly-empty list emitted a bare "scope=".
Normalize the scopes for both interactive flows: "openid" is placed
first and a caller-supplied duplicate is dropped, with the remaining
scopes keeping their order. This mirrors build_scopes in
crates/openshell-cli/src/oidc_auth.rs, which the Go SDK did not follow.
The client credentials grant is left untouched. It has no user and no
ID token, so it keeps sending exactly what the caller asked for,
matching build_ci_scopes.
Also document the WithTimeout contract: the flows all gate on
timeout > 0, so any non-positive duration means "no deadline".
Signed-off-by: Roland Huß <rhuss@redhat.com>
A gateway's metadata.json carries oidc_scopes, and the Rust CLI feeds it into every flow it starts (commands/gateway.rs passes metadata.oidc_scopes to both the interactive and the client-credentials paths). The Go SDK read the field in resolveClientCredentialsConfig only, so Login and DeviceLogin silently ignored it and always used the built-in defaults. Resolve gateway scopes in both interactive flows using the same rule the client-credentials path already applies: they fill only genuinely-unset scopes, so an explicit WithScopes still wins. The openid normalization now runs after gateway resolution, so scopes coming from metadata.json are normalized too. Behavior change: a gateway with oidc_scopes set in metadata.json now affects Login and DeviceLogin from the Go SDK, matching the CLI. Signed-off-by: Roland Huß <rhuss@redhat.com>
rhuss
left a comment
There was a problem hiding this comment.
cc-review Summary
What Went Well
applyDefaultsinopenshell/v1/oidc/options.go(lines 55-66) now keys on thescopesSet/timeoutSetflags instead of zero values, so an explicitly empty scope list or an explicit timeout is finally distinguishable from "unset".requireOpenIDScope(options.go:78-87) is a single normalization step that builds a fresh slice, putsopenidfirst and drops duplicates, so neither caller slices nordefaultScopesget mutated. It runs after all scope sources are resolved inLogin(oidc.go:58) andDeviceLogin(device.go:87), and is deliberately skipped for client credentials.- Gateway
oidc_scopesonly fill genuinely unset scopes (oidc.go:150-153, device.go:78-81), so an explicitWithScopesalways wins over on-disk gateway metadata. accessTokenForExchange(credentials_auth.go:76-81) gets rid of the old born-expired context for negative timeouts, which made every client credentials exchange fail immediately.- The new tests check what is actually sent on the wire:
TestLogin_AlwaysRequestsOpenIDScopeandTestLogin_GatewayScopesparse the authorization URL, andsetupScopeCapturingDeviceProviderin device_test.go fails if the device request never happened. Precedence and empty-gateway fallback are covered with hermeticwithGatewayResolverandt.TempDir()setups. - Scopes are encoded through
url.ValuesinbuildAuthURLandrequestDeviceCode, so whitespace-split gateway scope strings can't inject extra parameters.
Findings
All findings are Minor and posted as inline comments.
| Severity | File | Description | Source |
|---|---|---|---|
| Minor | openshell/v1/oidc/credentials_auth.go:74 | Comment says "zero timeout" and "DeviceFlow", but code treats any non-positive value as no deadline | architecture |
| Minor | openshell/v1/oidc/credentials_auth.go:76 | Detached exchange has no deadline with WithTimeout(0); a blocking secret provider wedges auth |
gator |
| Minor | openshell/v1/oidc/credentials_test.go:363 | "No scope sent for WithScopes()" is only checked on config, not on the wire |
test-quality |
| Minor | openshell/v1/oidc/device.go:78 | Gateway scope fill block now duplicated in three places | architecture |
| Minor | openshell/v1/oidc/device_test.go:121 | TestDeviceLogin_MissingIssuer doc comment now sits on the new scope test |
test-quality |
| Minor | openshell/v1/oidc/options.go:63 | WithTimeout(0) silently changes from the 2 minute default to no deadline |
production |
| Minor | openshell/v1/oidc/options.go:157 | WithTimeout doc now covers all login flows, but ClientCredentials ignores it |
architecture |
| Minor | openshell/v1/oidc/options.go:157 | Behavior changes to WithTimeout/WithScopes aren't called out; docs/src/api/oidc.md is stale |
goal-alignment |
Follow-up Progress
- Mode: full review. This is the first review of this PR, so there were no earlier conversations to carry forward.
- New material findings: none. Nothing reached Critical or Important after calibration.
- Limitations: one external reviewer (CodeRabbit) failed to run, so reviewer coverage for this run is incomplete and no completed review baseline is recorded.
Review Details
- Findings posted: 9 (as 8 inline comments; two reviewers reported the same misplaced doc comment in device_test.go, so those were combined)
- Findings reviewed and not posted: 0
- Gate outcome: PASS
- Participating reviewers: architecture, goal-alignment, production, test-quality, gator
Coverage incomplete: the CodeRabbit reviewer failed, so this run does not record a completed review baseline.
NewClientCredentialsAuth runs each token exchange on a context detached from all callers. With WithTimeout(0) or a negative timeout, that exchange had no deadline at all, so a secret provider that blocks on its context would hold the singleflight key forever and every later GetRequestMetadata call would fail with its own context error. Always bound the background exchange, falling back to the 2 minute default when the timeout is not positive. ClientCredentials ignored WithTimeout entirely. Apply it with the same rule as Login and DeviceLogin: a caller deadline wins, and a non-positive timeout means no deadline. Document which entry points honor WithTimeout and how, and fix the comments that named a non-existent DeviceFlow and claimed only a zero timeout means no deadline. Signed-off-by: Roland Huß <rhuss@redhat.com>
Login, DeviceLogin and the client credentials path each carried the same block that fills unset scopes from the gateway's oidc_scopes. Move it into loginConfig.applyGatewayScopes so a change to how oidc_scopes is parsed lands in every flow at once. Signed-off-by: Roland Huß <rhuss@redhat.com>
WithScopes() with no arguments promises that the client credentials grant sends no scope parameter, but only the resolved config was checked. Assert on the token request itself, for unset and explicitly empty scopes, through both ClientCredentials and NewClientCredentialsAuth. Also move the TestDeviceLogin_MissingIssuer doc comment back onto its test; the new scope tests had been inserted between the two. Signed-off-by: Roland Huß <rhuss@redhat.com>
The options table still described WithTimeout as a plain 2 minute flow timeout and did not say that interactive flows always request openid or that gateway oidc_scopes are honored. Add short Scopes and Timeouts sections that match the doc comments in options.go. Signed-off-by: Roland Huß <rhuss@redhat.com>
Gkrumbach07
left a comment
There was a problem hiding this comment.
Thanks for the follow-ups. I re-checked the four new commits on top of current main and against a real Keycloak, and the timeout and scope rules now hold for all four entry points:
ClientCredentialsandNewClientCredentialsAuthsend noscopeparameter without scopes, send exactly whatWithScopesor the gateway'soidc_scopesnames, and Keycloak accepts every case.DeviceLoginsendsopenidplus the gateway's scopes, and an explicitWithScopeswins.WithTimeoutstops a slow token endpoint at the configured bound. A context deadline wins over it, andWithTimeout(0)means no deadline, except in the background exchange ofNewClientCredentialsAuth, which gets 2 minutes.
The code, the doc comments and docs/src/api/oidc.md say the same thing. Build, vet, race tests and golangci-lint v2.12.2 are clean, and the two new timeout tests fail or hang on the code at 951cf8b. Measurements are below.
| Call, with the token endpoint held for 3 s | Elapsed | Result |
|---|---|---|
ClientCredentials, WithTimeout(1s) |
1.00 s | error |
ClientCredentials, WithTimeout(0) |
3.01 s | ok |
ClientCredentials, 500 ms context deadline and WithTimeout(10s) |
0.50 s | error |
NewClientCredentialsAuth, WithTimeout(1s) |
1.00 s | error |
NewClientCredentialsAuth, WithTimeout(0) |
3.01 s | ok, with a 2m0s deadline on the exchange |
I'm fine with WithTimeout(0) meaning "no deadline" before 1.0: an explicit zero is honored instead of being replaced, the option documents it, and no caller in this repository passes 0. My one concern is where the note lands. hack/automation/tag-release.sh builds the release notes from the body of the pull request that merged the stable pin bump, and bump-pr-body.md doesn't list SDK changes since the last tag. So the "Behavior changes" section of this description won't appear in the v0.1.8 notes unless someone copies it there. Two cheap ways to carry it:
- Squash this PR as
fix(oidc)!: ...with aBREAKING CHANGE:footer namingWithTimeout(0),WithScopes()and gatewayoidc_scopes. That matches thefeat!:convention #4 used and keeps the note ingit log. The title'ssdk/goscope is also left over from core. - Add the note to the next stable bump PR's body, or open a small follow-up that gives the bump template an "SDK changes since the last tag" section.
Three small nonblocking points follow. Two are on lines this PR doesn't change, so they are here rather than inline.
openshell/v1/oidc/credentials.go:142 (nonblocking): Now that ClientCredentials applies WithTimeout, a timeout comes back as oidc: client credentials exchange failed: token request failed, the same text as a refused connection. I measured this against Keycloak behind a slow proxy: errors.Is(err, context.DeadlineExceeded) and errors.Is(err, ErrTimeout) are both false, for WithTimeout(1s) and for a 500 ms context deadline. That matters because WithTimeout is new behavior for this entry point, and a caller has no way to tell "raise the timeout" from "the provider is down". Wrapping the context error when it is set is enough, and it carries nothing secret: if ctxErr := ctx.Err(); ctxErr != nil { return nil, fmt.Errorf("%w: token request failed: %w", ErrClientCredentials, ctxErr) }. The secret provider error at line 100 has the same shape. Returning ErrTimeout instead would also work, but errors.go:28 scopes that error to the interactive flows.
docs/src/api/oidc.md:138 (nonblocking): The sentinel table describes ErrTimeout as "Authentication flow timed out". The new Timeouts section says ClientCredentials and NewClientCredentialsAuth time out too, so a reader would expect errors.Is(err, oidc.ErrTimeout) to match them, and it doesn't. Either say "Interactive flow (browser, keyboard or device code) timed out", as errors.go and doc.go do, or make the client credentials flows return it.
The docs promise two more WithTimeout rules for ClientCredentials than the existing bound test checks: a context deadline wins over WithTimeout, and a zero or negative timeout means no deadline. Record the deadline the secret provider sees, as the NewClientCredentialsAuth test does, and assert both. Signed-off-by: Roland Huß <rhuss@redhat.com>
Now that ClientCredentials applies WithTimeout, a timeout came back as "token request failed", the same text as a refused connection, and errors.Is(err, context.DeadlineExceeded) was false. Wrap the context error when the context has ended, for the secret provider, the token request and the response read. The underlying cause stays out of the message, so nothing secret leaks. ErrTimeout still covers only the interactive flows; say so in the sentinel table and in the Timeouts section. Signed-off-by: Roland Huß <rhuss@redhat.com>
|
Thanks for re-checking against Keycloak. On the notes in the review:
|
|
@Gkrumbach07 would be great if you could give it another approval so that it will then get auto-merged. |
Summary
A gateway's
metadata.jsoncarriesoidc_scopes, and the Rust CLI feeds it into every flow it starts:commands/gateway.rsreadsmetadata.oidc_scopesand passes it to both the interactive path and the client-credentials path.The Go SDK reads that field in
resolveClientCredentialsConfigonly.LoginandDeviceLoginsilently ignored it and always used the built-in defaults (openid profile email), so a gateway registered with custom scopes got them for machine-to-machine auth but not for user login.This resolves gateway scopes in both interactive flows using the same rule the client-credentials path already applies: they fill only genuinely-unset scopes, so an explicit
WithScopesstill wins.Behavior change: a gateway with
oidc_scopesset inmetadata.jsonnow affectsLoginandDeviceLoginfrom the Go SDK, matching what the CLI has always done.Behavior changes
WithTimeout(0)(or a negative duration) now means no deadline forLogin,DeviceLoginandClientCredentials; it used to mean the 2 minute default.NewClientCredentialsAuthstill bounds every background exchange, by the 2 minute default when the timeout is not positive.ClientCredentialsnow appliesWithTimeout(2 minutes by default) when the context has no deadline; it used to ignore the option.WithScopes()with no arguments now sends onlyopenidfromLoginandDeviceLogin, and noscopeparameter from the client credentials flows; it used to fall back toopenid profile email.LoginandDeviceLoginalways requestopenid, even whenWithScopesleaves it out.oidc_scopesnow applies toLoginandDeviceLogin, not only to the client credentials flows.Related Issue
No separate issue. This is a localized bug fix bringing the Go SDK in line with existing CLI behavior for a metadata field the SDK already parses, in the same spirit as NVIDIA/OpenShell#3235, which this PR also contains (see below).
Changes
oidc.go:resolveOIDCConfigresolvesgwCfg.OIDCScopesinto unset scopes, mirroringcredentials.go.Loginnow normalizesopenidafter gateway resolution rather than before, so scopes frommetadata.jsonare normalized too.device.go: the same resolution inDeviceLogin's gateway block, with the same reordering.openid, explicitWithScopeswins, emptyoidc_scopesfalls back to defaults). ExtractedsetupScopeCapturingDeviceProviderso the device tests share one mock provider.Testing
mise run go:cigreen: build,golangci-lint(0 issues),gofmt, fullgo test -race, proto-check, docs-check.mise run pre-commitgreen.TestLogin_GatewayScopesandTestDeviceLogin_GatewayScopes.Checklist
Included: honor explicitly-set zero option values in oidc defaults
The first commit comes from NVIDIA/OpenShell#3235, which this PR stacked on in core. Its description:
Port note
Ported from NVIDIA/OpenShell#3284 (which contains the commits of NVIDIA/OpenShell#3235) after the Go SDK moved out of NVIDIA/OpenShell at 8ed8bf0c907fd06572d616d64f22ede915d0c10f. The commits are the original ones from the core PR at
19567199934498358846d47b355e4bcb1e3ed279, with the same authors and sign-offs; the only change is the module path (github.com/NVIDIA/OpenShell/sdk/gois nowgithub.com/ai-openshell/sdk-go). The core PR changed onlysdk/go, so nothing stays in core.The commits after those (24af026, 8e7ae81, 27b075b, 62ca0cb) are new here and address review feedback.
File paths above are relative to the old
sdk/godirectory (here:openshell/v1/oidc/), and the Testing section refers to core's tasks (mise run go:ciismise run cihere).