179 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|