Files
keeporg/internal/server/oidc_test.go
T
ReactorcoremeltdownandClaude Opus 5.5 318bd68908 Collect Android browser sign-ins after the app was stopped
The Android app kept a pending browser sign-in in a thread inside the
login screen. Android often kills the process while the browser is in
front, so the approval reached the server but nothing collected it, and
the app came back signed out with nothing saved.

The pending device code is now kept in Prefs and collected whenever the
login screen resumes. The app passes return_url=keeporg://device-approved
when it starts the sign-in, and the server's approval page, after a
password or single sign-on, links back to it. Only the keeporg: scheme is
accepted. Against a server older than 0.9, which rejects the field, the
app retries without it.

Verified on an Android 15 emulator: approval collected after the process
was killed, and the fallback against the 0.8 server.

Server 0.9.0, Android 0.3.0 (versionCode 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:16:23 +01:00

224 lines
7.3 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": "phone", "return_url": "keeporg://device-approved"})
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 phone is handed back to its app from the provider's redirect too.
if !strings.Contains(page, `href="keeporg://device-approved"`) {
t.Fatalf("no link back to the app after single sign-on:\n%s", 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")
}
}