Merge pull request 'Add OIDC login with role selection to vcm' (#4) from benvin/vcm-oidc into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-10-23 22:43:09 +11:00
9 changed files with 460 additions and 51 deletions
+26 -1
View File
@@ -23,9 +23,16 @@ Both `login` and `renew` accept:
| Flag | Default | Description |
| -------------- | -------------------------------- | -------------------------------------------------- |
| `--all` | `false` | Operate on every configured context. |
| `--method <m>` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`). |
| `--method <m>` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`, `oidc`). |
| `--user <u>` | context/config, else `$USER` | Override the login username. |
`login` also accepts:
| Flag | Default | Description |
| -------------- | -------------------------------- | -------------------------------------------------- |
| `--role <r>` | context `role`, else mount default | OIDC role to log in with. |
| `--listen <a>` | context `oidc_listen`, else `localhost:8250` | OIDC callback listen address. |
A context name or `--all` is required (but not both).
## Configuration
@@ -53,6 +60,11 @@ contexts:
address: https://vault-legacy.example.net
method: userpass
path: userpass2 # override the auth mount path (default = method)
sydney/admin: # OIDC: one context per role
address: https://vault.syd1.au.unkin.net
method: oidc
role: admin
```
### Field resolution
@@ -64,6 +76,9 @@ For each context, every field is resolved with the chain
- `user` — built-in default `$USER`.
- `namespace` — no built-in default (unset means the root namespace).
- `path` — the auth mount path; defaults to the resolved `method`.
- `role` — OIDC role; unset uses the mount's `default_role`.
- `oidc_listen` — OIDC callback listener on a loopback host (`localhost`,
`127.0.0.1` or `[::1]`); defaults to `localhost:8250`.
`--method` / `--user` on the command line override the resolved values. When
`--method` changes the method and the context did not pin an explicit `path`,
@@ -75,6 +90,13 @@ the auth path follows the new method.
for a password (no echo) and POSTs to `auth/<path>/login/<user>`.
- **`token`**: `vcm` prompts for a raw Vault token (no echo), verifies it with
`auth/token/lookup-self`, and caches its details.
- **`oidc`**: the same flow as `vault login -method=oidc`. `vcm` listens on
`oidc_listen`, requests `auth/<path>/oidc/auth_url` with
`redirect_uri=http://<oidc_listen>/oidc/callback`, opens the URL with
`xdg-open` (printing it too), and exchanges the returned code via
`auth/<path>/oidc/callback`. It gives up after 2 minutes. The redirect URI
must be in the role's `allowed_redirect_uris`. `--all` runs each OIDC
context's browser login in turn.
## Token cache
@@ -115,6 +137,9 @@ vcm login --all
# Log in to a context overriding the method + user
vcm login sydney --method okta --user someone.else
# OIDC login with a different role than the context's
vcm login sydney/admin --role readonly
# Renew a slash-named context
vcm renew staging/sydney
+13 -1
View File
@@ -3,7 +3,7 @@
# File-level defaults applied to any context that doesn't set its own value.
defaults:
method: ldap # ldap | userpass | okta | radius | token
method: ldap # ldap | userpass | okta | radius | token | oidc
user: ben # login user (defaults to $USER if omitted)
# namespace: "" # optional default Vault namespace
@@ -23,3 +23,15 @@ contexts:
address: https://vault-legacy.example.net
method: userpass
path: userpass2 # auth mount path (defaults to the method name)
# OIDC: one context per role, all on the same Vault.
sydney/admin:
address: https://vault.syd1.au.unkin.net
method: oidc
role: admin # OIDC role (default: the mount's default_role)
sydney/readonly:
address: https://vault.syd1.au.unkin.net
method: oidc
path: oidc # auth mount path (defaults to the method name)
role: readonly
# oidc_listen: localhost:8250 # local callback listener (default)
+39 -24
View File
@@ -30,7 +30,7 @@ const (
type Context struct {
// Address is the Vault API base URL, e.g. https://vault.syd1.au.unkin.net.
Address string `yaml:"address"`
// Method is the auth method (ldap, userpass, okta, radius, token, ...).
// Method is the auth method (ldap, userpass, okta, radius, token, oidc).
Method string `yaml:"method,omitempty"`
// User is the login username (LDAP/userpass/...); ignored for token auth.
User string `yaml:"user,omitempty"`
@@ -38,6 +38,10 @@ type Context struct {
Namespace string `yaml:"namespace,omitempty"`
// Path overrides the auth mount path (defaults to Method), e.g. "ldap2".
Path string `yaml:"path,omitempty"`
// Role is the OIDC role to log in with (empty = the mount's default_role).
Role string `yaml:"role,omitempty"`
// OIDCListen is the local OIDC callback listener (default localhost:8250).
OIDCListen string `yaml:"oidc_listen,omitempty"`
}
// Defaults holds file-level fallbacks applied to every context that does not
@@ -62,12 +66,23 @@ type Config struct {
// ResolvedContext is a Context with all defaults applied, ready to use. Method
// and User are always populated.
type ResolvedContext struct {
Name string
Address string
Method string
User string
Namespace string
Path string
Name string
Address string
Method string
User string
Namespace string
Path string
Role string
OIDCListen string
}
// Overrides are CLI-flag values that replace resolved context fields; empty
// fields leave the resolved value untouched.
type Overrides struct {
Method string
User string
Role string
Listen string
}
// ConfigDir returns the XDG_CONFIG_HOME/vcm directory.
@@ -141,34 +156,34 @@ func (c *Config) Resolve(name string) (ResolvedContext, error) {
path := firstNonEmpty(ctx.Path, method)
return ResolvedContext{
Name: name,
Address: ctx.Address,
Method: method,
User: user,
Namespace: namespace,
Path: path,
Name: name,
Address: ctx.Address,
Method: method,
User: user,
Namespace: namespace,
Path: path,
Role: ctx.Role,
OIDCListen: firstNonEmpty(ctx.OIDCListen, DefaultOIDCListen),
}, nil
}
// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides for method
// and user; an empty override leaves the resolved value untouched. When the
// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides. When the
// method is overridden and the context did not pin an explicit auth path, the
// auth path follows the new method.
func (c *Config) ResolveWithOverrides(name, method, user string) (ResolvedContext, error) {
func (c *Config) ResolveWithOverrides(name string, o Overrides) (ResolvedContext, error) {
rc, err := c.Resolve(name)
if err != nil {
return rc, err
}
if method != "" {
pinnedPath := c.Contexts[name].Path != ""
rc.Method = method
if !pinnedPath {
rc.Path = method
if o.Method != "" {
rc.Method = o.Method
if c.Contexts[name].Path == "" {
rc.Path = o.Method
}
}
if user != "" {
rc.User = user
}
rc.User = firstNonEmpty(o.User, rc.User)
rc.Role = firstNonEmpty(o.Role, rc.Role)
rc.OIDCListen = firstNonEmpty(o.Listen, rc.OIDCListen)
return rc, nil
}
+40 -3
View File
@@ -149,7 +149,7 @@ func TestResolveWithOverrides(t *testing.T) {
cfg, _ := Load()
// Overriding the method also moves the auth path (context did not pin one).
rc, err := cfg.ResolveWithOverrides("sydney", "okta", "otheruser")
rc, err := cfg.ResolveWithOverrides("sydney", Overrides{Method: "okta", User: "otheruser"})
if err != nil {
t.Fatal(err)
}
@@ -158,7 +158,7 @@ func TestResolveWithOverrides(t *testing.T) {
}
// legacy pins path=userpass2, so a method override must NOT change the path.
rc, err = cfg.ResolveWithOverrides("legacy", "okta", "")
rc, err = cfg.ResolveWithOverrides("legacy", Overrides{Method: "okta"})
if err != nil {
t.Fatal(err)
}
@@ -167,7 +167,7 @@ func TestResolveWithOverrides(t *testing.T) {
}
// Empty overrides leave resolved values untouched.
rc, err = cfg.ResolveWithOverrides("sydney", "", "")
rc, err = cfg.ResolveWithOverrides("sydney", Overrides{})
if err != nil {
t.Fatal(err)
}
@@ -175,3 +175,40 @@ func TestResolveWithOverrides(t *testing.T) {
t.Errorf("no-op override resolved = %+v", rc)
}
}
func TestResolveOIDC(t *testing.T) {
withConfigDir(t, "config.yaml", `
contexts:
admin:
address: https://vault.example
method: oidc
role: admins
dev:
address: https://vault.example
method: oidc
path: oidc-dev
oidc_listen: localhost:8251
`)
cfg, _ := Load()
rc, err := cfg.Resolve("admin")
if err != nil {
t.Fatal(err)
}
if rc.Path != "oidc" || rc.Role != "admins" || rc.OIDCListen != DefaultOIDCListen {
t.Errorf("admin resolved = %+v", rc)
}
rc, err = cfg.ResolveWithOverrides("dev", Overrides{Role: "readers"})
if err != nil {
t.Fatal(err)
}
if rc.Path != "oidc-dev" || rc.Role != "readers" || rc.OIDCListen != "localhost:8251" {
t.Errorf("dev resolved = %+v", rc)
}
rc, _ = cfg.ResolveWithOverrides("admin", Overrides{Listen: "127.0.0.1:9999"})
if rc.OIDCListen != "127.0.0.1:9999" || rc.Role != "admins" {
t.Errorf("listen override resolved = %+v", rc)
}
}
+130
View File
@@ -0,0 +1,130 @@
package shared
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"time"
)
// DefaultOIDCListen is the callback listener, matching the vault CLI default.
const DefaultOIDCListen = "localhost:8250"
// OIDCTimeout bounds how long LoginOIDC waits for the browser callback.
var OIDCTimeout = 2 * time.Minute
// OpenBrowser opens url in the user's browser; a package variable so tests can
// drive the callback instead.
var OpenBrowser = func(u string) error { return exec.Command("xdg-open", u).Start() }
type oidcResult struct {
tok *Token
err error
}
// LoginOIDC runs the vault CLI's OIDC flow (vault-plugin-auth-jwt cli.go):
// fetch auth_url, open the browser, receive code+state on a local callback,
// and exchange them via auth/<path>/oidc/callback.
func LoginOIDC(rc ResolvedContext) (*Token, error) {
host, _, err := net.SplitHostPort(rc.OIDCListen)
if err != nil {
return nil, fmt.Errorf("oidc listen address %q: %w", rc.OIDCListen, err)
}
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
return nil, fmt.Errorf("oidc listen address %q must be on localhost, 127.0.0.1 or [::1]", rc.OIDCListen)
}
ln, err := net.Listen("tcp", rc.OIDCListen)
if err != nil {
return nil, fmt.Errorf("oidc callback listener: %w", err)
}
redirectURI := "http://" + rc.OIDCListen + "/oidc/callback"
nonce := rand.Text()
data, err := doRequest(http.MethodPost, rc.Address, "auth/"+rc.Path+"/oidc/auth_url", rc.Namespace, "",
map[string]string{"role": rc.Role, "redirect_uri": redirectURI, "client_nonce": nonce})
if err != nil {
_ = ln.Close()
return nil, err
}
var ar struct {
Data struct {
AuthURL string `json:"auth_url"`
} `json:"data"`
}
if err := json.Unmarshal(data, &ar); err != nil {
_ = ln.Close()
return nil, fmt.Errorf("decoding auth_url response: %w", err)
}
if ar.Data.AuthURL == "" {
_ = ln.Close()
return nil, fmt.Errorf("vault returned no auth_url for role %q with redirect_uri %q (check the role's allowed_redirect_uris)", rc.Role, redirectURI)
}
done := make(chan oidcResult, 1)
mux := http.NewServeMux()
mux.HandleFunc("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
res := oidcCallback(rc, r, nonce)
if res.err != nil {
http.Error(w, "Vault login failed: "+res.err.Error(), http.StatusBadRequest)
} else {
_, _ = fmt.Fprintln(w, "Vault login successful. You can close this window.")
}
select {
case done <- res:
default:
}
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { _ = srv.Serve(ln) }()
defer func() { _ = srv.Close() }()
fmt.Fprintf(os.Stderr, "Complete the login via your OIDC provider:\n\n %s\n\n", ar.Data.AuthURL)
if err := OpenBrowser(ar.Data.AuthURL); err != nil {
fmt.Fprintf(os.Stderr, "Could not open a browser (%v); visit the URL above manually.\n", err)
}
select {
case res := <-done:
return res.tok, res.err
case <-time.After(OIDCTimeout):
return nil, fmt.Errorf("timed out after %s waiting for the OIDC callback on %s", OIDCTimeout, redirectURI)
}
}
// oidcCallback exchanges the provider's state+code for a Vault token. A POST
// is the form_post response mode: forward the form to Vault, then GET as usual.
func oidcCallback(rc ResolvedContext, r *http.Request, nonce string) oidcResult {
if e := r.FormValue("error"); e != "" {
return oidcResult{err: fmt.Errorf("provider returned %s: %s", e, r.FormValue("error_description"))}
}
state, code := r.FormValue("state"), r.FormValue("code")
if state == "" || (code == "" && r.Method != http.MethodPost) {
return oidcResult{err: errors.New("callback missing code or state")}
}
cb := "auth/" + rc.Path + "/oidc/callback"
if r.Method == http.MethodPost {
if _, err := doRequest(http.MethodPost, rc.Address, cb, rc.Namespace, "",
map[string]string{"state": state, "code": code, "id_token": r.FormValue("id_token"), "client_nonce": nonce}); err != nil {
return oidcResult{err: err}
}
}
v := url.Values{"state": {state}, "code": {code}, "client_nonce": {nonce}}
data, err := doRequest(http.MethodGet, rc.Address, cb+"?"+v.Encode(), rc.Namespace, "", nil)
if err != nil {
return oidcResult{err: err}
}
var ar authResponse
if err := json.Unmarshal(data, &ar); err != nil {
return oidcResult{err: fmt.Errorf("decoding callback response: %w", err)}
}
if ar.Auth.ClientToken == "" {
return oidcResult{err: fmt.Errorf("oidc login for context %q returned no token", rc.Name)}
}
return oidcResult{tok: tokenFromAuth(rc, ar)}
}
+178
View File
@@ -0,0 +1,178 @@
package shared
import (
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func freeListen(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
return addr
}
// fakeOIDCVault serves auth_url and callback on mount "sso", checking the
// nonce/redirect round-trip, and records the redirect_uri it was given.
func fakeOIDCVault(t *testing.T, wantRole string) (*httptest.Server, *string) {
t.Helper()
var redirect, nonce, idToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Vault-Namespace") != "team-a" {
t.Errorf("namespace header = %q", r.Header.Get("X-Vault-Namespace"))
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/sso/oidc/auth_url":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["role"] != wantRole || body["client_nonce"] == "" {
t.Errorf("auth_url body = %v", body)
}
redirect, nonce = body["redirect_uri"], body["client_nonce"]
_, _ = io.WriteString(w, `{"data":{"auth_url":"https://idp.example/authorize?state=st1"}}`)
case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/sso/oidc/callback":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["state"] != "st1" || body["client_nonce"] != nonce {
t.Errorf("form_post body = %v", body)
}
idToken = body["id_token"]
case r.Method == http.MethodGet && r.URL.Path == "/v1/auth/sso/oidc/callback":
q := r.URL.Query()
if q.Get("state") != "st1" || (q.Get("code") != "c0de" && idToken != "idt") || q.Get("client_nonce") != nonce {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"errors":["bad callback"]}`)
return
}
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.oidc","accessor":"acc","policies":["default"],"lease_duration":600,"renewable":true}}`)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv, &redirect
}
// fakeBrowser stands in for the IdP: it hits the local callback with query.
func fakeBrowser(t *testing.T, redirect *string, query string) {
t.Helper()
fakeBrowserDo(t, func() (*http.Response, error) { return http.Get(*redirect + "?" + query) })
}
func fakeBrowserDo(t *testing.T, do func() (*http.Response, error)) {
t.Helper()
prev := OpenBrowser
OpenBrowser = func(u string) error {
if !strings.HasPrefix(u, "https://idp.example/") {
t.Errorf("browser url = %q", u)
}
go func() {
resp, err := do()
if err == nil {
_ = resp.Body.Close()
}
}()
return nil
}
t.Cleanup(func() { OpenBrowser = prev })
}
func TestLoginOIDC(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "admins")
fakeBrowser(t, redirect, "state=st1&code=c0de")
listen := freeListen(t)
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Role: "admins", Namespace: "team-a", OIDCListen: listen}
tok, err := Login(rc, "")
if err != nil {
t.Fatalf("Login: %v", err)
}
if *redirect != "http://"+listen+"/oidc/callback" {
t.Errorf("redirect_uri = %q", *redirect)
}
if tok.Token != "s.oidc" || tok.Accessor != "acc" || tok.LeaseDurationSeconds != 600 || tok.Namespace != "team-a" {
t.Errorf("token = %+v", tok)
}
}
func TestLoginOIDCCallbackRejected(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "")
fakeBrowser(t, redirect, "state=st1&code=wrong")
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "bad callback") {
t.Errorf("want vault callback error, got %v", err)
}
}
func TestLoginOIDCProviderError(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "")
fakeBrowser(t, redirect, url.Values{"error": {"access_denied"}, "error_description": {"user said no"}}.Encode())
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "user said no") {
t.Errorf("want provider error, got %v", err)
}
}
func TestLoginOIDCTimeout(t *testing.T) {
srv, _ := fakeOIDCVault(t, "")
prevB, prevT := OpenBrowser, OIDCTimeout
OpenBrowser = func(string) error { return nil }
OIDCTimeout = 50 * time.Millisecond
t.Cleanup(func() { OpenBrowser, OIDCTimeout = prevB, prevT })
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Errorf("want timeout, got %v", err)
}
}
func TestLoginOIDCNoAuthURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":{"auth_url":""}}`)
}))
defer srv.Close()
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "oidc", Role: "nope", OIDCListen: freeListen(t)}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "allowed_redirect_uris") {
t.Errorf("want no auth_url error, got %v", err)
}
}
func TestLoginOIDCFormPost(t *testing.T) {
srv, redirect := fakeOIDCVault(t, "")
fakeBrowserDo(t, func() (*http.Response, error) {
return http.PostForm(*redirect, url.Values{"state": {"st1"}, "id_token": {"idt"}})
})
rc := ResolvedContext{Name: "sso", Address: srv.URL, Method: "oidc", Path: "sso", Namespace: "team-a", OIDCListen: freeListen(t)}
tok, err := LoginOIDC(rc)
if err != nil || tok.Token != "s.oidc" {
t.Fatalf("form_post login: tok=%+v err=%v", tok, err)
}
}
func TestLoginOIDCRejectsNonLoopbackListen(t *testing.T) {
for _, l := range []string{":8250", "0.0.0.0:8250", "192.0.2.1:8250", "localhost"} {
rc := ResolvedContext{Name: "sso", Address: "http://unused", Method: "oidc", Path: "sso", OIDCListen: l}
if _, err := LoginOIDC(rc); err == nil || !strings.Contains(err.Error(), "oidc listen address") {
t.Errorf("%q: want listen error, got %v", l, err)
}
}
}
func TestDoRequestErrorOmitsQuery(t *testing.T) {
_, err := doRequest(http.MethodGet, "http://127.0.0.1:1", "auth/sso/oidc/callback?code=secretcode&state=st", "", "", nil)
if err == nil || strings.Contains(err.Error(), "secretcode") || !strings.Contains(err.Error(), "/v1/auth/sso/oidc/callback") {
t.Errorf("error = %v", err)
}
}
+15 -4
View File
@@ -3,9 +3,11 @@ package shared
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
@@ -79,8 +81,8 @@ func doRequest(method, address, path, namespace, token string, payload any) ([]b
body = bytes.NewReader(b)
}
url := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/")
req, err := http.NewRequest(method, url, body)
reqURL := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/")
req, err := http.NewRequest(method, reqURL, body)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
@@ -96,7 +98,13 @@ func doRequest(method, address, path, namespace, token string, payload any) ([]b
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request to %s failed: %w", url, err)
// Query strings can carry OIDC code/state/nonce; keep them out of errors.
bare, _, _ := strings.Cut(reqURL, "?")
var ue *url.Error
if errors.As(err, &ue) {
ue.URL = bare
}
return nil, fmt.Errorf("request to %s failed: %w", bare, err)
}
defer func() { _ = resp.Body.Close() }()
@@ -133,11 +141,14 @@ func tokenFromAuth(rc ResolvedContext, ar authResponse) *Token {
// Login authenticates against a context and returns a Token ready to cache.
// For password methods, secret is the password; for the token method, secret is
// the raw client token to adopt (verified via lookup-self).
// the raw client token to adopt (verified via lookup-self); oidc ignores it.
func Login(rc ResolvedContext, secret string) (*Token, error) {
if IsTokenMethod(rc.Method) {
return loginWithToken(rc, secret)
}
if rc.Method == "oidc" {
return LoginOIDC(rc)
}
if !NeedsPassword(rc.Method) {
return nil, fmt.Errorf("unsupported auth method %q", rc.Method)
}
+11 -10
View File
@@ -33,9 +33,8 @@ func main() {
func newRootCmd() *cobra.Command {
var (
method string
user string
all bool
ov shared.Overrides
all bool
)
root := &cobra.Command{
@@ -66,10 +65,12 @@ func newRootCmd() *cobra.Command {
ValidArgsFunction: contextCompletion,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runOverContexts(args, all, method, user, "login", doLogin)
return runOverContexts(args, all, ov, "login", doLogin)
},
}
loginCmd.Flags().BoolVar(&all, "all", false, "Log in to every configured context")
loginCmd.Flags().StringVar(&ov.Role, "role", "", "OIDC role override (default: context role or the mount's default_role)")
loginCmd.Flags().StringVar(&ov.Listen, "listen", "", "OIDC callback listen address (default: context oidc_listen or "+shared.DefaultOIDCListen+")")
renewCmd := &cobra.Command{
Use: "renew [context]",
@@ -78,17 +79,17 @@ func newRootCmd() *cobra.Command {
ValidArgsFunction: contextCompletion,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runOverContexts(args, all, method, user, "renew", doRenew)
return runOverContexts(args, all, ov, "renew", doRenew)
},
}
renewCmd.Flags().BoolVar(&all, "all", false, "Renew every configured context that has a cached token")
// --method / --user apply to both login and renew.
for _, c := range []*cobra.Command{loginCmd, renewCmd} {
c.Flags().StringVar(&method, "method", "", "Auth method override (default: context/config or "+shared.DefaultMethod+")")
c.Flags().StringVar(&user, "user", "", "Login user override (default: context/config or $USER)")
c.Flags().StringVar(&ov.Method, "method", "", "Auth method override (default: context/config or "+shared.DefaultMethod+")")
c.Flags().StringVar(&ov.User, "user", "", "Login user override (default: context/config or $USER)")
_ = c.RegisterFlagCompletionFunc("method", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"ldap", "userpass", "okta", "radius", "token"}, cobra.ShellCompDirectiveNoFileComp
return []string{"ldap", "userpass", "okta", "radius", "token", "oidc"}, cobra.ShellCompDirectiveNoFileComp
})
}
@@ -120,7 +121,7 @@ type contextAction func(rc shared.ResolvedContext) (*shared.Token, error)
// runOverContexts resolves the target context(s) and applies fn to each,
// reporting per-context success/failure and returning an error only when at
// least one context failed.
func runOverContexts(args []string, all bool, method, user, verb string, fn contextAction) error {
func runOverContexts(args []string, all bool, ov shared.Overrides, verb string, fn contextAction) error {
cfg, err := shared.Load()
if err != nil {
return err
@@ -143,7 +144,7 @@ func runOverContexts(args []string, all bool, method, user, verb string, fn cont
var failed int
for _, name := range names {
rc, err := cfg.ResolveWithOverrides(name, method, user)
rc, err := cfg.ResolveWithOverrides(name, ov)
if err != nil {
fmt.Fprintf(os.Stderr, "%s %s: %v\n", verb, name, err)
failed++
+8 -8
View File
@@ -53,7 +53,7 @@ func TestRunOverContextsSingle(t *testing.T) {
seen = append(seen, rc.Name)
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
}
if err := runOverContexts([]string{"sydney"}, false, "", "", "login", fn); err != nil {
if err := runOverContexts([]string{"sydney"}, false, shared.Overrides{}, "login", fn); err != nil {
t.Fatalf("runOverContexts: %v", err)
}
if len(seen) != 1 || seen[0] != "sydney" {
@@ -72,7 +72,7 @@ func TestRunOverContextsAllIteratesEveryContext(t *testing.T) {
seen = append(seen, rc.Name)
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
}
if err := runOverContexts(nil, true, "", "", "login", fn); err != nil {
if err := runOverContexts(nil, true, shared.Overrides{}, "login", fn); err != nil {
t.Fatalf("runOverContexts --all: %v", err)
}
sort.Strings(seen)
@@ -90,7 +90,7 @@ func TestRunOverContextsAllIteratesEveryContext(t *testing.T) {
func TestRunOverContextsContextAndAllConflict(t *testing.T) {
setupVcm(t, vcmConfig)
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil }
err := runOverContexts([]string{"sydney"}, true, "", "", "login", fn)
err := runOverContexts([]string{"sydney"}, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "not both") {
t.Errorf("expected conflict error, got %v", err)
}
@@ -99,7 +99,7 @@ func TestRunOverContextsContextAndAllConflict(t *testing.T) {
func TestRunOverContextsNoTarget(t *testing.T) {
setupVcm(t, vcmConfig)
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil }
if err := runOverContexts(nil, false, "", "", "login", fn); err == nil {
if err := runOverContexts(nil, false, shared.Overrides{}, "login", fn); err == nil {
t.Error("expected error when neither context nor --all given")
}
}
@@ -107,7 +107,7 @@ func TestRunOverContextsNoTarget(t *testing.T) {
func TestRunOverContextsAllEmptyConfig(t *testing.T) {
setupVcm(t, "contexts: {}\n")
fn := func(rc shared.ResolvedContext) (*shared.Token, error) { return nil, nil }
err := runOverContexts(nil, true, "", "", "login", fn)
err := runOverContexts(nil, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "no contexts configured") {
t.Errorf("expected no-contexts error, got %v", err)
}
@@ -121,7 +121,7 @@ func TestRunOverContextsAggregatesFailures(t *testing.T) {
}
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
}
err := runOverContexts(nil, true, "", "", "login", fn)
err := runOverContexts(nil, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "1 of 2") {
t.Errorf("expected '1 of 2' aggregate error, got %v", err)
}
@@ -146,7 +146,7 @@ contexts:
fn := func(rc shared.ResolvedContext) (*shared.Token, error) {
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
}
err := runOverContexts(nil, true, "", "", "login", fn)
err := runOverContexts(nil, true, shared.Overrides{}, "login", fn)
if err == nil || !strings.Contains(err.Error(), "1 of 2") {
t.Errorf("expected resolve failure counted, got %v", err)
}
@@ -159,7 +159,7 @@ func TestRunOverContextsAppliesOverrides(t *testing.T) {
got = rc
return &shared.Token{Context: rc.Name, Address: rc.Address, Token: "s.x"}, nil
}
if err := runOverContexts([]string{"sydney"}, false, "okta", "someone", "login", fn); err != nil {
if err := runOverContexts([]string{"sydney"}, false, shared.Overrides{Method: "okta", User: "someone"}, "login", fn); err != nil {
t.Fatal(err)
}
if got.Method != "okta" || got.User != "someone" || got.Path != "okta" {