309 lines
11 KiB
Go
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)
|
|
}
|
|
}
|