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