Files
vault-tools/shared/oidc_test.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

179 lines
6.5 KiB
Go

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