131 lines
4.4 KiB
Go
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)}
|
|
}
|