misaki/server/oidc_test.go
q 119b6d73c4
Some checks failed
Build & push Docker image / build (push) Has been cancelled
totp 2fac
2026-11-07 11:43:40 -05:00

309 lines
11 KiB
Go

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