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.
219 lines
7.1 KiB
Go
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")
|
|
}
|
|
}
|