Files
vault-tools/shared/oidc.go
T
unkin-agent ddc25139f7
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Support OIDC form_post, require loopback listener, redact request URLs in errors
2026-10-23 22:01:42 +11:00

131 lines
4.4 KiB
Go

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)}
}