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:
+26
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user