Skip to content

feat(cli): support fixed callback port for browser login in containers - #5065

Merged
TaylorMutch merged 3 commits into
NVIDIA:mainfrom
jhjaggars:feat/cli-fixed-callback-port
Oct 30, 2026
Merged

TaylorMutch merged 3 commits into
NVIDIA:mainfrom
jhjaggars:feat/cli-fixed-callback-port

Conversation

@jhjaggars

@jhjaggars jhjaggars commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Both browser login flows (OIDC Authorization Code + PKCE and the edge/mTLS browser flow) bind their callback listener to an ephemeral port on 127.0.0.1. When the CLI runs inside a container — for example the hypershell installer's wrapper — the wrapper cannot know a randomly chosen port in advance, so it cannot publish the port to the host and the browser can never reach the callback. This PR adds two environment variables that make browser login work in that setting, with defaults unchanged.

Related Issue

Fixes #5066

Changes

  • New crates/openshell-cli/src/callback.rs module with a shared bind_callback_listener() helper used by both browser login flows. It honors:
    • OPENSHELL_CALLBACK_PORT — pin the callback listener to a fixed TCP port (validated integer from 1 to 65535) so a container wrapper can publish -p 127.0.0.1:<port>:<port> before login starts.
    • OPENSHELL_CALLBACK_BIND_ADDRESS — override the listener bind address (validated IP address, IPv4 or IPv6; e.g. 0.0.0.0 for pasta/slirp4netns user-mode container networking, ::1 for IPv6-only hosts).
  • oidc_browser_auth_flow (crates/openshell-cli/src/oidc_auth.rs) and browser_auth_flow (crates/openshell-cli/src/auth.rs) now bind through the shared helper; the port used in the redirect URI / callback_port URL parameter is still derived from the bound listener.
  • Clear errors: an invalid port or address value, or a fixed port that is already in use, fails the login command with a message naming the variable and the remediation, instead of falling back silently.
  • IPv6 callback routing (b676200, addressing review feedback): the browser-facing callback target derives from the bound address — wildcard binds map to the matching loopback (0.0.0.0 → 127.0.0.1, :: → [::1]), and IPv6 addresses render bracketed (http://[::1]:<port>/callback). The edge flow passes a strictly loopback-validated callback_host parameter to the gateway connect page (crates/openshell-server/src/auth/http.rs), which brackets IPv6 in the fetch URL and CSP connect-src, falls back to 127.0.0.1 for anything malformed or non-loopback (so the embedded edge token can never be directed to an external host), and preserves the host across the waiting-page refresh. The bundled Keycloak realm (scripts/keycloak-realm.json) now also allows http://[::1]:* redirects for the CLI client.
  • Docs: new "Browser Login Callbacks" section in docs/how-it-works/gateways/authentication.mdx with the env-var table, container example, IPv6 behavior, and security notes.

Design choices

  • Default unchanged. With both variables unset, the listener binds 127.0.0.1:0 exactly as before — no behavior change for host users.
  • Bind address is a separate, explicit control. 0.0.0.0 is never implied by setting a fixed port. Binding 0.0.0.0 on a host machine would expose the short-lived callback server to the local network, so widening the bind address requires the explicit OPENSHELL_CALLBACK_BIND_ADDRESS=0.0.0.0, which only a container wrapper that also publishes the port to host loopback (-p 127.0.0.1:<port>:<port>) should set. The port is never exposed beyond host loopback.
  • The callback URL derives from the bound address, so the callback matches the listener. IPv4 binds (including the default and 0.0.0.0 in a container, where the wrapper publishes the port to host loopback) use http://127.0.0.1:<port>/callback; IPv6 binds use the bracketed RFC 8252 form http://[::1]:<port>/callback. The bundled Keycloak realm allows all three loopback redirect prefixes (127.0.0.1, localhost, [::1]). IPv6 callback binds are fully supported for OIDC login.
  • Known limitation — IPv6 edge login. The gateway connect page enforces a CSP that must allow the callback target, and Chrome does not currently support IPv6 literals in CSP connect-src source expressions, so an IPv6-bound CLI cannot complete edge login in Chrome (the page's POST is blocked by CSP and it reports it could not reach the CLI). This is a browser limitation, documented in the auth reference; the connect page keeps the IPv4 loopback source in its policy so it never degrades to empty. IPv6 edge login is out of scope here; all IPv4 paths, including the container use case, are unaffected.
  • Why a fixed port rather than --network=host: --network=host works on Linux but not with a podman machine VM on macOS, so the fixed published port is the portable approach.

How the hypershell installer will use it

The wrapper will set OPENSHELL_CALLBACK_PORT=<fixed> and OPENSHELL_CALLBACK_BIND_ADDRESS=0.0.0.0 for the CLI inside the container and run the container with -p 127.0.0.1:<port>:<port>, exposing the callback only on host loopback. That installer change is intentionally out of scope here and waits for this to ship in a release.

Testing

  • mise run pre-commit passes (the only failure is a pre-existing openshell-supervisor-process clippy lint that fails identically on main; verified via git stash)
  • Unit tests added/updated
  • E2E tests added/updated (if applicable)

Unit tests cover the shared helper and both flows:

  • callback.rs: default ephemeral loopback bind; fixed port; fixed port already in use (clear error naming OPENSHELL_CALLBACK_PORT); valid/invalid port values (0, negative, 65536, non-numeric, whitespace); wildcard 0.0.0.0 bind; fixed port + wildcard combined; IPv6 ::1 and :: binds; invalid bind addresses (hostname, ip:port, garbage); callback host/target derivation table; callback reachability probe (connecting to the advertised [::1] target succeeds while 127.0.0.1 is refused on a ::1 bind)
  • oidc_auth.rs: default bind produces http://127.0.0.1:<ephemeral>/callback; fixed port + 0.0.0.0 bind keeps the redirect URI on 127.0.0.1 with the fixed port; ::1 and :: binds produce http://[::1]:<port>/callback; invalid port value fails
  • auth.rs: default bind advertises the ephemeral port in the /auth/connect URL; fixed port + 0.0.0.0 bind advertises the fixed port; ::1 bind advertises callback_host=::1; invalid port value fails
  • openshell-server/src/auth/http.rs: sanitize_callback_host accepts only loopback IP literals (IPv4 and IPv6; hostnames, non-loopback IPs, brackets, and garbage fall back to 127.0.0.1); connect page targets 127.0.0.1 by default and bracketed [::1] when provided; waiting page preserves callback_host across refresh

Verification run on top of current main: cargo test -p openshell-cli -p openshell-server all green (405 + 2522 lib tests plus integration targets, including 33 callback/flow tests), cargo clippy -p openshell-cli -p openshell-server --all-targets -- -D warnings clean, cargo fmt --check clean, markdownlint clean.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable) — not needed; user-facing behavior is documented in docs/how-it-works/gateways/authentication.mdx

@copy-pr-bot

copy-pr-bot Bot commented Oct 30, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Browser login (OIDC Authorization Code + PKCE and the edge/mTLS browser
flow) binds its callback listener to an ephemeral port on 127.0.0.1.
When the CLI runs inside a container, the wrapper cannot know a random
port in advance, so it cannot publish it to the host and the browser
never reaches the callback.

Add a shared bind helper honoring two environment variables:

- OPENSHELL_CALLBACK_PORT pins the listener to a fixed port (validated
  integer 1..=65535) so a wrapper can publish
  -p 127.0.0.1:<port>:<port> before login starts.
- OPENSHELL_CALLBACK_BIND_ADDRESS overrides the bind address (validated
  IP address) so user-mode container networking (pasta/slirp4netns),
  which can only reach 0.0.0.0 listeners, can forward to it.

Defaults are unchanged: with both variables unset the listener binds
127.0.0.1:0 exactly as before, and the bind address never widens to
0.0.0.0 without the explicit variable, since a wildcard bind on a host
machine would expose the callback server to the local network. The
browser-facing redirect URL stays http://127.0.0.1:<port>/callback
even for a wildcard bind, so no identity-provider allowlist change is
needed. A busy fixed port or an invalid value fails with a clear error
instead of falling back silently.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>
@jhjaggars
jhjaggars force-pushed the feat/cli-fixed-callback-port branch from dfb3c9b to a9426dc Compare October 30, 2026 14:20
@jhjaggars

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing! Two maintainer actions are needed to unblock CI on this fork PR:

  1. Issue acceptance: the linked feature request feat(cli): fixed callback port for browser login inside containers #5066 needs triage and state:accepted for the PR Issue Acceptance check to pass.
  2. Mirror: Branch Checks / Helm Lint are waiting for /ok to test a9426dcb3 to mirror this PR and run the Rust/doc checks.

The Publish required CI gate statuses failure is a transient API blip (empty JSON response while checks were still pending — the same evaluation passed on the previous push); it re-runs automatically when Branch Checks completes. Local verification on top of current main (9f1cdb4): cargo test -p openshell-cli all green (399 lib + integration tests, including 17 new tests for the callback binding), cargo clippy --workspace --all-targets -- -D warnings clean, cargo fmt --check clean, markdownlint clean.

@TaylorMutch TaylorMutch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed locally at a9426dc. The shared helper preserves existing defaults and authentication checks. I recommend addressing the IPv6 callback mismatch noted inline before merging. Validation: cargo test -p openshell-cli --lib callback passed 23 tests; cargo test -p openshell-cli --lib browser_flow passed 6 tests. A separate connection probe using the exact binding helper confirmed the IPv6 mismatch. Full container/IdP login was not exercised.

Comment thread crates/openshell-cli/src/callback.rs Resolved
@TaylorMutch

Copy link
Copy Markdown
Collaborator

/ok to test a9426dc

An IPv6-only callback bind (OPENSHELL_CALLBACK_BIND_ADDRESS=::1) was
accepted but both browser flows still directed callbacks at
127.0.0.1, so the listener was unreachable and login timed out.

Route callbacks to the address the listener actually bound:

- The CLI derives the browser-facing callback target from the bound
  address. Wildcard binds map to the matching loopback (0.0.0.0 to
  127.0.0.1, :: to [::1]) because a wildcard listener accepts
  loopback connections of its family; IPv6 addresses render
  bracketed per RFC 3986/RFC 8252.
- The OIDC redirect URI therefore becomes
  http://[::1]:<port>/callback for ::1-bound listeners, and the
  bundled Keycloak realm now allows http://[::1]:* redirects for
  the CLI client alongside the existing IPv4 entries.
- The edge flow passes the unbracketed loopback literal to the
  gateway connect page as callback_host. The page strictly
  validates it to a loopback IP literal (anything missing,
  non-loopback, or malformed falls back to 127.0.0.1) before
  embedding it in the fetch URL and CSP connect-src, so a crafted
  parameter can never direct the embedded edge token to an
  external host. The host is preserved across the waiting-page
  refresh, and older gateways that ignore the new parameter keep
  the historical 127.0.0.1 behavior.

Defaults are unchanged: the listener still binds 127.0.0.1 on an
ephemeral port unless the environment variables say otherwise.

Adds a callback-reachability test (connecting to the advertised
[::1] target succeeds while 127.0.0.1 is refused for a ::1 bind),
host-derivation table tests, OIDC redirect tests for ::1 and ::
binds, edge URL tests, and server-side validation and render tests.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>
@jhjaggars

Copy link
Copy Markdown
Contributor Author

Good catch — the PR description was accidentally overwritten with unrelated content from a local temp-file collision; it's now restored with Fixes #5066 and the b676200 changes. The PR Issue Acceptance check passes again.

Still needing a maintainer /ok to test b67620015 to run Branch Checks/Helm Lint on the new commit.

@TaylorMutch TaylorMutch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the update at b676200. The earlier IPv6 listener/redirect mismatch is addressed, and the IPv4 fixed-port/container path looks reasonable. One remaining IPv6 edge-login limitation is noted inline. IPv6 edge login can be out of scope for this PR if acknowledged explicitly and the documentation/PR description reflects that limitation; fixing it here is not required on that basis. The linked issue #5066 is now state:accepted, and the PR description has been corrected. Validation: local Chrome CSP reproduction with an IPv4 control; full container/IdP login was not exercised and Rust tests did not complete locally.

@TaylorMutch

Copy link
Copy Markdown
Collaborator

/ok to test b676200

Review feedback on b676200: Chrome does not support IPv6 literals
in CSP host-source expressions, so the edge flow's connect page
blocks the callback POST to http://[::1]:<port> with a connect-src
violation even though the policy allows it. The CLI listener is
reachable; the browser refuses to fetch.

Keep the callback routing as-is (correct per the CSP specification,
and the failure mode is an explicit page error rather than a hang)
and scope the claim precisely instead:

- The connect page CSP always keeps the IPv4 loopback source so a
  dropped IPv6 source expression cannot degrade the policy to empty.
- The auth reference and PR description now state that IPv6
  callback binds are fully supported for OIDC login but are
  best-effort for edge login until browsers implement IPv6 CSP
  source expressions; all IPv4 paths, including the container use
  case, are unaffected.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>
@TaylorMutch

Copy link
Copy Markdown
Collaborator

/ok to test 2fee68f

@TaylorMutch TaylorMutch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final review at 2fee68f: looks good for the documented scope. The IPv4 fixed-port/container login path preserves the existing defaults and authentication checks, and IPv6 callback routing is supported for OIDC. The remaining Chrome CSP limitation for IPv6 edge login is explicitly acknowledged and documented as out of scope, which is acceptable for this PR. No new blocking findings. Local validation passed: 27 CLI callback tests, 9 browser-flow tests, and 21 gateway auth HTTP tests, including the updated CSP test. Full container/IdP login was not exercised. The linked issue #5066 is accepted. Merge remains conditional on required CI passing.

@TaylorMutch
TaylorMutch enabled auto-merge October 30, 2026 23:10
@TaylorMutch
TaylorMutch added this pull request to the merge queue Oct 30, 2026
Merged via the queue into NVIDIA:main with commit 73ae222 Oct 30, 2026
83 of 84 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(cli): fixed callback port for browser login inside containers

2 participants