package server import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "cc.tiredsysadmin.keeporg/internal/store" ) // fakeIdP is the smallest thing that behaves like an OIDC provider: a // discovery document, an authorization endpoint that redirects straight // back, a token endpoint and a userinfo endpoint. type fakeIdP struct { *httptest.Server subject, email, username string // tokenAuth records the client credentials the server presented. tokenAuth [2]string authCalls int } func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP { idp := &fakeIdP{subject: subject, email: email, username: username} mux := http.NewServeMux() idp.Server = httptest.NewServer(mux) t.Cleanup(idp.Close) mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]string{ "issuer": idp.URL, "authorization_endpoint": idp.URL + "/authorize", "token_endpoint": idp.URL + "/token", "userinfo_endpoint": idp.URL + "/userinfo", }) }) mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) { idp.authCalls++ q := r.URL.Query() back, err := url.Parse(q.Get("redirect_uri")) if err != nil { http.Error(w, "bad redirect_uri", http.StatusBadRequest) return } rq := back.Query() rq.Set("code", "authorization-code") rq.Set("state", q.Get("state")) back.RawQuery = rq.Encode() http.Redirect(w, r, back.String(), http.StatusFound) }) mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) { user, pass, _ := r.BasicAuth() idp.tokenAuth = [2]string{user, pass} _ = r.ParseForm() if r.FormValue("code") != "authorization-code" { http.Error(w, "bad code", http.StatusBadRequest) return } _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300, }) }) mux.HandleFunc("GET /userinfo", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer an-access-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } _ = json.NewEncoder(w).Encode(map[string]string{ "sub": idp.subject, "email": idp.email, "preferred_username": idp.username, }) }) return idp } // withOIDC configures a harness against this provider. func (idp *fakeIdP) withOIDC(autoCreate bool, defaultGroup string) func(*Config) { return func(c *Config) { c.OIDC = OIDCConfig{ Issuer: idp.URL, ClientID: "keeporg", ClientSecret: "a-client-secret", AutoCreate: autoCreate, DefaultGroupID: defaultGroup, } } } func TestOIDCSignsInToTheWebInterface(t *testing.T) { idp := newFakeIdP(t, "subject-1", "sso@example.com", "ssouser") h := newHarnessWith(t, idp.withOIDC(true, "everyone")) b := newBrowser(t, h) // The sign-in page offers it, and following that link is the whole flow. status, page := b.get("/web") if status != http.StatusOK || !strings.Contains(page, "/oidc/login") { t.Fatalf("the sign-in page does not offer single sign-on:\n%s", page) } status, page = b.get("/oidc/login") if status != http.StatusOK { t.Fatalf("oidc login: %d\n%s", status, page) } if !strings.Contains(page, "Your vaults") { t.Fatalf("single sign-on did not land on the vault list:\n%s", page) } if b.cookie() == "" { t.Fatal("no session cookie after signing in with single sign-on") } if idp.authCalls != 1 { t.Fatalf("the provider's authorize endpoint was called %d times", idp.authCalls) } if idp.tokenAuth != [2]string{"keeporg", "a-client-secret"} { t.Fatalf("the server authenticated to the token endpoint as %v", idp.tokenAuth) } // The account was provisioned, linked to the subject, and put in the // configured group. var linked *store.User h.srv.Store().Read(func(st *store.State) { linked = st.UserByOIDC(idp.URL, "subject-1") }) if linked == nil { t.Fatal("no account was linked to the identity") } if linked.Username != "ssouser" || linked.Email != "sso@example.com" { t.Fatalf("provisioned account = %+v", linked) } var inGroup bool h.srv.Store().Read(func(st *store.State) { for _, m := range st.Memberships { if m.UserID == linked.ID && m.GroupID == "everyone" { inGroup = true } } }) if !inGroup { t.Fatal("the new account did not join the default group") } } func TestOIDCApprovesAWaitingClient(t *testing.T) { idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser") h := newHarnessWith(t, idp.withOIDC(true, "everyone")) status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"}) if status != http.StatusOK { t.Fatalf("device start: %d %v", status, start) } userCode := start["user_code"].(string) b := newBrowser(t, h) status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode)) if status != http.StatusOK || !strings.Contains(page, "Device approved") { t.Fatalf("device approval through single sign-on: %d\n%s", status, page) } // A device approval must not also hand the browser a session cookie. if b.cookie() != "" { t.Fatal("approving a device set a web session cookie") } status, body := h.call("POST", "/api/v1/auth/device/poll", "", map[string]string{"device_code": start["device_code"].(string)}) if status != http.StatusOK || body["token"] == "" { t.Fatalf("poll after single sign-on: %d %v", status, body) } if u := body["user"].(map[string]any); u["username"] != "devuser" { t.Fatalf("the session belongs to %v", u["username"]) } } func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) { idp := newFakeIdP(t, "subject-3", "", "admin") h := newHarnessWith(t, idp.withOIDC(false, "")) b := newBrowser(t, h) if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") { t.Fatalf("an existing account was not matched:\n%s", page) } var u *store.User h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] }) if u.OIDCSubject != "subject-3" { t.Fatalf("the existing account was not linked: %+v", u) } } func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) { idp := newFakeIdP(t, "subject-4", "stranger@example.com", "stranger") h := newHarnessWith(t, idp.withOIDC(false, "")) b := newBrowser(t, h) _, page := b.get("/oidc/login") if !strings.Contains(page, "no account is linked") { t.Fatalf("an unknown identity was let in:\n%s", page) } if b.cookie() != "" { t.Fatal("a session cookie was issued to an unknown identity") } } func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) { idp := newFakeIdP(t, "subject-5", "", "someone") h := newHarnessWith(t, idp.withOIDC(true, "")) b := newBrowser(t, h) _, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state") if !strings.Contains(page, "expired") { t.Fatalf("a forged callback was accepted:\n%s", page) } if b.cookie() != "" { t.Fatal("a forged callback issued a session cookie") } } func TestSignInPageHidesSingleSignOnWhenItIsNotConfigured(t *testing.T) { b := newBrowser(t, newHarness(t)) _, page := b.get("/web") if strings.Contains(page, "oidc/login") { t.Fatal("single sign-on is offered although no provider is configured") } }