diff --git a/docs/vcm.md b/docs/vcm.md index ddc4bbf..c392275 100644 --- a/docs/vcm.md +++ b/docs/vcm.md @@ -23,9 +23,16 @@ Both `login` and `renew` accept: | Flag | Default | Description | | -------------- | -------------------------------- | -------------------------------------------------- | | `--all` | `false` | Operate on every configured context. | -| `--method ` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`). | +| `--method ` | context/config, else `ldap` | Override the auth method (`ldap`, `userpass`, `okta`, `radius`, `token`, `oidc`). | | `--user ` | context/config, else `$USER` | Override the login username. | +`login` also accepts: + +| Flag | Default | Description | +| -------------- | -------------------------------- | -------------------------------------------------- | +| `--role ` | context `role`, else mount default | OIDC role to log in with. | +| `--listen ` | 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//login/`. - **`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//oidc/auth_url` with + `redirect_uri=http:///oidc/callback`, opens the URL with + `xdg-open` (printing it too), and exchanges the returned code via + `auth//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 diff --git a/examples/config.yaml b/examples/config.yaml index a851b34..219953f 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -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) diff --git a/shared/config.go b/shared/config.go index 62e2ac4..c35b9df 100644 --- a/shared/config.go +++ b/shared/config.go @@ -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 } diff --git a/shared/config_test.go b/shared/config_test.go index f7d5f39..cfe6de5 100644 --- a/shared/config_test.go +++ b/shared/config_test.go @@ -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) + } +} diff --git a/shared/oidc.go b/shared/oidc.go new file mode 100644 index 0000000..cc1a73a --- /dev/null +++ b/shared/oidc.go @@ -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//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)} +} diff --git a/shared/oidc_test.go b/shared/oidc_test.go new file mode 100644 index 0000000..8446625 --- /dev/null +++ b/shared/oidc_test.go @@ -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) + } +} diff --git a/shared/vault.go b/shared/vault.go index bb55264..5539cbb 100644 --- a/shared/vault.go +++ b/shared/vault.go @@ -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) } diff --git a/vcm/main.go b/vcm/main.go index 9982f76..3ac7ec7 100644 --- a/vcm/main.go +++ b/vcm/main.go @@ -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++ diff --git a/vcm/main_test.go b/vcm/main_test.go index 418130a..e13f65a 100644 --- a/vcm/main_test.go +++ b/vcm/main_test.go @@ -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" {