package server_test import ( "crypto/ecdsa" "crypto/elliptic" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "math/big" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "misaki/db" "misaki/server" ) const ( oidcIssuer = "https://misaki.test" oidcSecret = "pix-secret-pix-secret" oidcRedirect = "https://pix.test/auth/oidc/callback" ) func newOIDCServer(t *testing.T) (http.Handler, *db.DB) { t.Helper() d := newTestDB(t) sum := sha256.Sum256([]byte(oidcSecret)) srv := server.New(d, server.ServiceURLs{}, nil, server.SMTPConfig{}, server.Config{ BaseDomain: "misaki.test", TOTPKeyFile: filepath.Join(t.TempDir(), "totp.key"), OIDC: &server.OIDCConfig{ Issuer: oidcIssuer, KeyFile: filepath.Join(t.TempDir(), "oidc.pem"), Clients: []server.OIDCClient{ {ID: "pix", SecretSHA256: hex.EncodeToString(sum[:]), RedirectURIs: []string{oidcRedirect}, Service: "pixelfed", RequireEmail: true}, {ID: "social", SecretSHA256: hex.EncodeToString(sum[:]), RedirectURIs: []string{"https://social.test/oauth/misaki/callback"}, Service: "akkoma"}, }, }, }) return srv, d } // oidcLogin skips the empty cookie-clearing Set-Cookie sent when BaseDomain is set. func oidcLogin(t *testing.T, srv http.Handler, user string) *http.Cookie { t.Helper() rr := doPost(srv, "/login", url.Values{"username": {user}, "password": {"hunter2hunter2"}}, nil) for _, c := range rr.Result().Cookies() { if c.Name == "session" && c.Value != "" { return c } } t.Fatalf("login %s failed: %d", user, rr.Code) return nil } func authorizeURL(extra url.Values) string { v := url.Values{ "response_type": {"code"}, "client_id": {"pix"}, "redirect_uri": {oidcRedirect}, "scope": {"openid profile email"}, "state": {"st8"}, "nonce": {"n0nce"}, } for k, vs := range extra { v[k] = vs } return "/oauth/authorize?" + v.Encode() } func grantedUser(t *testing.T, srv http.Handler, d *db.DB, name string) *http.Cookie { t.Helper() id := makeApprovedUser(t, d, name, "hunter2hunter2") d.SetUserServiceEnabled(id, "pixelfed", true) d.SetUserEmail(id, name+"@thirdculture.diy") return oidcLogin(t, srv, name) } func getCode(t *testing.T, srv http.Handler, c *http.Cookie, extra url.Values) string { t.Helper() rr := doGet(srv, authorizeURL(extra), c) if rr.Code != http.StatusFound { t.Fatalf("authorize: got %d (%s)", rr.Code, rr.Header().Get("Location")) } loc, _ := url.Parse(rr.Header().Get("Location")) if !strings.HasPrefix(loc.String(), oidcRedirect+"?") || loc.Query().Get("state") != "st8" || loc.Query().Get("iss") != oidcIssuer { t.Fatalf("authorize redirect: %s", loc) } return loc.Query().Get("code") } func exchange(srv http.Handler, form url.Values, basic bool) *httptest.ResponseRecorder { if form.Get("grant_type") == "" { form.Set("grant_type", "authorization_code") } if form.Get("redirect_uri") == "" { form.Set("redirect_uri", oidcRedirect) } req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if basic { req.SetBasicAuth("pix", oidcSecret) } rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) return rr } type tokenResp struct { AccessToken string `json:"access_token"` IDToken string `json:"id_token"` Error string `json:"error"` } func verifyIDToken(t *testing.T, srv http.Handler, tok string) map[string]any { t.Helper() var jwks struct{ Keys []map[string]string } json.NewDecoder(doGet(srv, "/oauth/jwks", nil).Body).Decode(&jwks) if len(jwks.Keys) != 1 { t.Fatalf("jwks: %+v", jwks) } k := jwks.Keys[0] b := base64.RawURLEncoding x, _ := b.DecodeString(k["x"]) y, _ := b.DecodeString(k["y"]) pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)} parts := strings.Split(tok, ".") if len(parts) != 3 { t.Fatalf("id_token parts: %d", len(parts)) } var hdr map[string]string h, _ := b.DecodeString(parts[0]) json.Unmarshal(h, &hdr) if hdr["alg"] != "ES256" || hdr["kid"] != k["kid"] { t.Fatalf("header: %v", hdr) } sig, _ := b.DecodeString(parts[2]) sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) if len(sig) != 64 || !ecdsa.Verify(pub, sum[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) { t.Fatal("id_token signature does not verify against jwks") } var claims map[string]any body, _ := b.DecodeString(parts[1]) json.Unmarshal(body, &claims) return claims } func TestOIDCDiscovery(t *testing.T) { srv, _ := newOIDCServer(t) rr := doGet(srv, "/.well-known/openid-configuration", nil) assertStatus(t, rr, http.StatusOK) var d map[string]any json.NewDecoder(rr.Body).Decode(&d) if d["issuer"] != oidcIssuer || d["token_endpoint"] != oidcIssuer+"/oauth/token" { t.Errorf("discovery: %v", d) } } func TestOIDCDisabledWithoutConfig(t *testing.T) { srv, _ := newTestServer(t) if rr := doGet(srv, "/.well-known/openid-configuration", nil); rr.Code == http.StatusOK { t.Error("discovery should not exist without oidc config") } } func TestOIDCAuthorizeRejects(t *testing.T) { srv, d := newOIDCServer(t) rr := doGet(srv, authorizeURL(url.Values{"redirect_uri": {"https://evil.test/cb"}}), nil) assertStatus(t, rr, http.StatusBadRequest) rr = doGet(srv, authorizeURL(url.Values{"client_id": {"nope"}}), nil) assertStatus(t, rr, http.StatusBadRequest) rr = doGet(srv, authorizeURL(nil), nil) assertRedirectTo(t, rr, "/login?next="+url.QueryEscape(oidcIssuer+"/oauth/authorize?")) id := makeApprovedUser(t, d, "nogrant", "hunter2hunter2") c := oidcLogin(t, srv, "nogrant") assertRedirectTo(t, doGet(srv, authorizeURL(nil), c), "/dashboard?err=request+access") d.SetUserServiceEnabled(id, "pixelfed", true) assertRedirectTo(t, doGet(srv, authorizeURL(nil), c), "/dashboard?err=add+a+contact+email") d.SetUserEmail(id, "x@thirdculture.diy") for name, extra := range map[string]url.Values{ "plain pkce": {"code_challenge": {"abc"}, "code_challenge_method": {"plain"}}, "no openid": {"scope": {"profile"}}, "implicit": {"response_type": {"token"}}, "huge state": {"state": {strings.Repeat("a", 600)}}, } { rr := doGet(srv, authorizeURL(extra), c) loc, _ := url.Parse(rr.Header().Get("Location")) if rr.Code != http.StatusFound || loc.Query().Get("error") == "" || loc.Query().Get("code") != "" { t.Errorf("%s: want error redirect, got %d %s", name, rr.Code, loc) } } } func TestOIDCFlow(t *testing.T) { srv, d := newOIDCServer(t) c := grantedUser(t, srv, d, "Misaki_fan") code := getCode(t, srv, c, nil) if rr := exchange(srv, url.Values{"code": {code}, "client_id": {"pix"}, "client_secret": {"wrong"}}, false); rr.Code != http.StatusUnauthorized { t.Errorf("wrong secret: got %d", rr.Code) } // the failed attempt above did not consume the code (client auth runs first) if rr := exchange(srv, url.Values{"code": {code}, "redirect_uri": {"https://pix.test/other"}}, true); rr.Code != http.StatusBadRequest { t.Errorf("redirect mismatch: got %d", rr.Code) } // ...but a mismatched redirect does burn it if rr := exchange(srv, url.Values{"code": {code}}, true); rr.Code != http.StatusBadRequest { t.Errorf("burnt code reused: got %d", rr.Code) } code = getCode(t, srv, c, nil) rr := exchange(srv, url.Values{"code": {code}, "client_id": {"pix"}, "client_secret": {oidcSecret}}, false) assertStatus(t, rr, http.StatusOK) if cc := rr.Header().Get("Cache-Control"); cc != "no-store" { t.Errorf("token Cache-Control: %q", cc) } var tr tokenResp json.NewDecoder(rr.Body).Decode(&tr) claims := verifyIDToken(t, srv, tr.IDToken) for k, want := range map[string]any{"iss": oidcIssuer, "aud": "pix", "nonce": "n0nce", "preferred_username": "Misaki_fan", "nickname": "Misaki_fan", "email": "Misaki_fan@thirdculture.diy"} { if claims[k] != want { t.Errorf("claim %s: got %v, want %v", k, claims[k], want) } } if s, _ := claims["sub"].(string); s == "" || s == "Misaki_fan" { t.Errorf("sub should be the stable id, got %v", claims["sub"]) } if rr := exchange(srv, url.Values{"code": {code}}, true); rr.Code != http.StatusBadRequest { t.Errorf("code reuse: got %d", rr.Code) } req := httptest.NewRequest(http.MethodGet, "/oauth/userinfo", nil) req.Header.Set("Authorization", "Bearer "+tr.AccessToken) ui := httptest.NewRecorder() srv.ServeHTTP(ui, req) assertStatus(t, ui, http.StatusOK) var info map[string]any json.NewDecoder(ui.Body).Decode(&info) if info["sub"] != claims["sub"] || info["email"] != "Misaki_fan@thirdculture.diy" { t.Errorf("userinfo: %v", info) } u, _ := d.GetUserByUsername("Misaki_fan") d.SetUserServiceEnabled(u.ID, "pixelfed", false) ui = httptest.NewRecorder() srv.ServeHTTP(ui, req) assertStatus(t, ui, http.StatusUnauthorized) } func TestOIDCPKCE(t *testing.T) { srv, d := newOIDCServer(t) c := grantedUser(t, srv, d, "pkceuser") verifier := "a-verifier-that-is-long-enough-to-be-realistic-0123456789" sum := sha256.Sum256([]byte(verifier)) challenge := base64.RawURLEncoding.EncodeToString(sum[:]) pkce := url.Values{"code_challenge": {challenge}, "code_challenge_method": {"S256"}} code := getCode(t, srv, c, pkce) if rr := exchange(srv, url.Values{"code": {code}, "code_verifier": {"wrong"}}, true); rr.Code != http.StatusBadRequest { t.Errorf("bad verifier: got %d", rr.Code) } code = getCode(t, srv, c, pkce) if rr := exchange(srv, url.Values{"code": {code}}, true); rr.Code != http.StatusBadRequest { t.Errorf("missing verifier: got %d", rr.Code) } code = getCode(t, srv, c, pkce) if rr := exchange(srv, url.Values{"code": {code}, "code_verifier": {verifier}}, true); rr.Code != http.StatusOK { t.Errorf("good verifier: got %d", rr.Code) } } func TestOIDCCodeBoundToClient(t *testing.T) { srv, d := newOIDCServer(t) c := grantedUser(t, srv, d, "bound") u, _ := d.GetUserByUsername("bound") d.SetUserServiceEnabled(u.ID, "akkoma", true) code := getCode(t, srv, c, nil) // a different (valid) client may not redeem pix's code form := url.Values{"code": {code}, "grant_type": {"authorization_code"}, "redirect_uri": {"https://social.test/oauth/misaki/callback"}, "client_id": {"social"}, "client_secret": {oidcSecret}} if rr := exchange(srv, form, false); rr.Code != http.StatusBadRequest { t.Errorf("cross-client redemption: got %d", rr.Code) } } func TestOIDCApplyNeedsEmail(t *testing.T) { srv, d := newOIDCServer(t) makeApprovedUser(t, d, "applicant", "hunter2hunter2") c := oidcLogin(t, srv, "applicant") assertRedirectTo(t, doPost(srv, "/dashboard/apply", url.Values{"service": {"pixelfed"}}, c), "/dashboard?err=an+email") assertRedirectTo(t, doPost(srv, "/dashboard/apply", url.Values{"service": {"pixelfed"}, "email": {"nope"}}, c), "/dashboard?err=invalid+email") assertRedirectTo(t, doPost(srv, "/dashboard/apply", url.Values{"service": {"pixelfed"}, "email": {"me@thirdculture.diy"}}, c), "/dashboard?msg=applied") // akkoma doesn't need one assertRedirectTo(t, doPost(srv, "/dashboard/apply", url.Values{"service": {"akkoma"}}, c), "/dashboard?msg=applied") u, _ := d.GetUserByUsername("applicant") if e, _ := d.GetUserEmail(u.ID); e != "me@thirdculture.diy" { t.Errorf("stored email: %q", e) } }