Files
keeporg/internal/server/oidc_test.go
T
root 7eed722ff5 Fix single sign-on from the web, and make passwords changeable
Why: two questions about operating this turned up two gaps. "Where do I get
the admin password" had no answer once the line scrolled out of the log,
and "how do I set up OIDC" ran straight into a sign-in link that could not
work.

What:
- /oidc/login with no parameters now signs a browser in and ends in a
  session cookie. It previously required the user code of a waiting client,
  so the link on the web sign-in page always failed. With a code it still
  approves a device, and the two paths no longer overlap: an empty user
  code matches no pending device.
- POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults.
  Changing a password requires the current one, and the one-time code when
  a second factor is enrolled, and revokes that account's other sessions.
  Until now nothing could change a password, which made the advice printed
  at first start impossible to follow.
- keeporgd -reset-admin, for when that first password was not kept: a new
  one printed once, any second factor cleared, sessions revoked. It writes
  the state document directly and says to stop the server first.
- The client read stdin through a new buffered reader per prompt, so the
  first prompt swallowed the rest and anything asking twice could not be
  scripted. One shared reader now. A prompt for a password being set no
  longer reads $KEEPORG_PASSWORD either; that variable holds the password
  you sign in with, and taking it here would have set the new password to
  the old one.
- HANDOVER gains a first-sign-in section and an OIDC setup section covering
  the redirect URI, the config block, both entry points and how an identity
  becomes an account.

Proved: OIDC now has tests, against a stub provider that serves discovery,
authorization, token and userinfo. They cover the web sign-in that was
broken, device approval, linking an existing account by username, the
refusal when auto_create is off, and a forged callback state. Eight more
cover the password change. Checked by hand with the real binaries: the
first-start password logs once and works, -reset-admin replaces it while
the old one stops working, and `keeporg passwd` changes it from a pipe.
Server 0.5.0, desktop clients 0.3.0.
2026-10-30 10:47:51 +01:00

219 lines
7.1 KiB
Go

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