Files
excalicloud/internal/auth/auth_test.go
T

333 lines
12 KiB
Go

package auth
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/json"
"io"
"math/big"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeIdP est un fournisseur OIDC minimal : découverte, autorisation (connexion
// immédiate), jetons, JWKS et déconnexion.
type fakeIdP struct {
t *testing.T
srv *httptest.Server
rsaKey *rsa.PrivateKey
ecKey *ecdsa.PrivateKey
alg string // RS256, ES256 ou HS256
secret string
groups []string
codes map[string]url.Values // code -> paramètres de la demande d'autorisation
}
func newIdP(t *testing.T, alg string) *fakeIdP {
rk, _ := rsa.GenerateKey(rand.Reader, 2048)
ek, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
p := &fakeIdP{t: t, rsaKey: rk, ecKey: ek, alg: alg, secret: "secret-client-assez-long", codes: map[string]url.Values{}}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": p.srv.URL,
"authorization_endpoint": p.srv.URL + "/authorize",
"token_endpoint": p.srv.URL + "/token",
"jwks_uri": p.srv.URL + "/jwks",
"end_session_endpoint": p.srv.URL + "/logout",
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
e := big.NewInt(int64(rk.E)).Bytes()
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{
{"kty": "RSA", "kid": "r1", "use": "sig", "n": b64.EncodeToString(rk.N.Bytes()), "e": b64.EncodeToString(e)},
{"kty": "EC", "kid": "e1", "crv": "P-256", "x": b64.EncodeToString(ek.X.FillBytes(make([]byte, 32))), "y": b64.EncodeToString(ek.Y.FillBytes(make([]byte, 32)))},
}})
})
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
code := randomString(8)
p.codes[code] = q
http.Redirect(w, r, q.Get("redirect_uri")+"?code="+code+"&state="+url.QueryEscape(q.Get("state")), http.StatusFound)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
q, ok := p.codes[r.Form.Get("code")]
delete(p.codes, r.Form.Get("code"))
id, sec, _ := r.BasicAuth()
ch := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
switch {
case !ok, id != "excalicloud", sec != p.secret,
r.Form.Get("redirect_uri") != q.Get("redirect_uri"),
q.Get("code_challenge_method") != "S256", b64.EncodeToString(ch[:]) != q.Get("code_challenge"):
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"id_token": p.idToken(q.Get("nonce")), "token_type": "Bearer"})
})
p.srv = httptest.NewServer(mux)
t.Cleanup(p.srv.Close)
return p
}
func (p *fakeIdP) idToken(nonce string) string {
claims := map[string]any{
"iss": p.srv.URL, "sub": "u-42", "aud": "excalicloud", "nonce": nonce,
"exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(),
"preferred_username": "cedric", "email": "cedric@example.org",
}
if p.groups != nil {
claims["groups"] = p.groups
}
return p.sign(claims)
}
func (p *fakeIdP) sign(claims map[string]any) string {
hdr := map[string]string{"alg": p.alg, "typ": "JWT"}
switch p.alg {
case "RS256":
hdr["kid"] = "r1"
case "ES256":
hdr["kid"] = "e1"
}
hb, _ := json.Marshal(hdr)
cb, _ := json.Marshal(claims)
signed := b64.EncodeToString(hb) + "." + b64.EncodeToString(cb)
d := sha256.Sum256([]byte(signed))
var sig []byte
switch p.alg {
case "RS256":
sig, _ = rsa.SignPKCS1v15(rand.Reader, p.rsaKey, crypto.SHA256, d[:])
case "ES256":
r, s, _ := ecdsa.Sign(rand.Reader, p.ecKey, d[:])
sig = append(r.FillBytes(make([]byte, 32)), s.FillBytes(make([]byte, 32))...)
case "HS256":
m := hmac.New(sha256.New, []byte(p.secret))
m.Write([]byte(signed))
sig = m.Sum(nil)
}
return signed + "." + b64.EncodeToString(sig)
}
// app démarre excalicloud (routes /auth/*, une API et une page protégées, un
// chemin public) avec l'authentification OIDC.
func app(t *testing.T, idp *fakeIdP, groups []string) (*httptest.Server, *http.Client) {
t.Helper()
mux := http.NewServeMux()
var srv *httptest.Server
o, err := NewOIDC(context.Background(), Config{
Issuer: idp.srv.URL, ClientID: "excalicloud", ClientSecret: idp.secret,
AllowedGroups: groups, EndSession: true, Secret: []byte("0123456789abcdef0123456789abcdef"),
})
if err != nil {
t.Fatal(err)
}
o.Register(mux)
mux.HandleFunc("GET /api/me", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(UserFrom(r.Context()))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app")) })
srv = httptest.NewServer(o.Middleware(mux, func(p string) bool { return strings.HasPrefix(p, "/s/") }))
t.Cleanup(srv.Close)
jar, _ := cookiejar.New(nil)
return srv, &http.Client{Jar: jar}
}
func get(t *testing.T, c *http.Client, u string) (*http.Response, string) {
t.Helper()
res, err := c.Get(u)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res, string(b)
}
func TestLoginFlow(t *testing.T) {
for _, alg := range []string{"RS256", "ES256", "HS256"} {
t.Run(alg, func(t *testing.T) {
idp := newIdP(t, alg)
srv, c := app(t, idp, nil)
// Sans session : 401 avec l'adresse de connexion pour l'API...
res, _ := get(t, c, srv.URL+"/api/me")
if res.StatusCode != 401 || res.Header.Get(LoginHeader) != "/auth/login" {
t.Fatalf("API sans session : %d %q", res.StatusCode, res.Header.Get(LoginHeader))
}
// ... page de redirection (fragment conservé) pour la navigation.
res, body := get(t, c, srv.URL+"/")
if res.StatusCode != 401 || !strings.Contains(body, "location.hash") {
t.Fatalf("navigation sans session : %d", res.StatusCode)
}
// Chemin public accessible.
if res, _ := get(t, c, srv.URL+"/s/x.y"); res.StatusCode != 200 {
t.Fatalf("chemin public : %d", res.StatusCode)
}
// Connexion complète : login -> fournisseur -> callback -> next.
res, body = get(t, c, srv.URL+"/auth/login?next="+url.QueryEscape("/dossier?x=1"))
if res.StatusCode != 200 || body != "app" || res.Request.URL.RequestURI() != "/dossier?x=1" {
t.Fatalf("après connexion : %d %q %s", res.StatusCode, body, res.Request.URL)
}
res, body = get(t, c, srv.URL+"/api/me")
if res.StatusCode != 200 || !strings.Contains(body, `"name":"cedric"`) || !strings.Contains(body, `"sub":"u-42"`) {
t.Fatalf("/api/me : %d %s", res.StatusCode, body)
}
// Déconnexion : cookie effacé, renvoi vers end_session_endpoint.
nc := &http.Client{Jar: c.Jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
res, _ = get(t, nc, srv.URL+"/auth/logout")
loc, _ := url.Parse(res.Header.Get("Location"))
if res.StatusCode != 302 || loc.Path != "/logout" || loc.Query().Get("client_id") != "excalicloud" ||
loc.Query().Get("post_logout_redirect_uri") != srv.URL+"/auth/logged-out" {
t.Fatalf("logout : %d %s", res.StatusCode, loc)
}
if res, _ := get(t, c, srv.URL+"/api/me"); res.StatusCode != 401 {
t.Fatalf("après déconnexion : %d", res.StatusCode)
}
})
}
}
func TestAllowedGroups(t *testing.T) {
idp := newIdP(t, "RS256")
idp.groups = []string{"/excalicloud-users"} // chemin complet Keycloak
srv, c := app(t, idp, []string{"excalicloud-users"})
if res, body := get(t, c, srv.URL+"/auth/login"); res.StatusCode != 200 || body != "app" {
t.Fatalf("membre du groupe : %d %q", res.StatusCode, body)
}
idp2 := newIdP(t, "RS256")
idp2.groups = []string{"autres"}
srv2, c2 := app(t, idp2, []string{"excalicloud-users"})
if res, _ := get(t, c2, srv2.URL+"/auth/login"); res.StatusCode != 403 {
t.Fatalf("hors groupe : %d", res.StatusCode)
}
if res, _ := get(t, c2, srv2.URL+"/api/me"); res.StatusCode != 401 {
t.Fatalf("hors groupe, pas de session attendue : %d", res.StatusCode)
}
}
func TestCallbackRejectsForgedState(t *testing.T) {
idp := newIdP(t, "RS256")
srv, c := app(t, idp, nil)
// Callback sans cookie d'état (CSRF de connexion) : refusé.
if res, _ := get(t, c, srv.URL+"/auth/callback?code=x&state=y"); res.StatusCode != 400 {
t.Fatalf("callback forgé : %d", res.StatusCode)
}
}
func TestVerifyIDToken(t *testing.T) {
idp := newIdP(t, "RS256")
o, err := NewOIDC(context.Background(), Config{Issuer: idp.srv.URL, ClientID: "excalicloud", Secret: []byte("0123456789abcdef")})
if err != nil {
t.Fatal(err)
}
d, err := o.provider(context.Background())
if err != nil {
t.Fatal(err)
}
base := func() map[string]any {
return map[string]any{"iss": idp.srv.URL, "sub": "s", "aud": []string{"excalicloud"}, "nonce": "n",
"exp": time.Now().Add(time.Minute).Unix(), "iat": time.Now().Unix()}
}
if _, err := o.verifyIDToken(context.Background(), d, idp.sign(base()), "n"); err != nil {
t.Fatalf("jeton valide refusé : %v", err)
}
bad := map[string]func(map[string]any){
"émetteur": func(c map[string]any) { c["iss"] = "https://ailleurs" },
"audience": func(c map[string]any) { c["aud"] = "autre" },
"expiré": func(c map[string]any) { c["exp"] = time.Now().Add(-time.Hour).Unix() },
"nonce": func(c map[string]any) { c["nonce"] = "x" },
}
for name, mutate := range bad {
c := base()
mutate(c)
if _, err := o.verifyIDToken(context.Background(), d, idp.sign(c), "n"); err == nil {
t.Errorf("%s : jeton accepté", name)
}
}
// Signature altérée, alg none et HS256 sans secret client.
tok := idp.sign(base())
if _, err := o.verifyIDToken(context.Background(), d, tok[:len(tok)-4]+"AAAA", "n"); err == nil {
t.Error("signature altérée acceptée")
}
parts := strings.Split(tok, ".")
none := b64.EncodeToString([]byte(`{"alg":"none"}`)) + "." + parts[1] + "."
if _, err := o.verifyIDToken(context.Background(), d, none, "n"); err == nil {
t.Error("alg none accepté")
}
idp.alg = "HS256"
if _, err := o.verifyIDToken(context.Background(), d, idp.sign(base()), "n"); err == nil {
t.Error("HS256 accepté sans secret client")
}
}
func TestSafeNext(t *testing.T) {
for in, want := range map[string]string{
"/a/b?c=1": "/a/b?c=1",
"//evil.example": "/",
"/\\evil.example": "/",
"https://evil": "/",
"/auth/logout": "/",
"": "/",
"/x\r\nSet-Cookie:a": "/",
} {
if got := safeNext(in); got != want {
t.Errorf("safeNext(%q) = %q, attendu %q", in, got, want)
}
}
}
func TestSessionCookieTampering(t *testing.T) {
s := signer{[]byte("0123456789abcdef")}
v, _ := s.encode(session{User: User{Sub: "a"}, Exp: time.Now().Add(time.Hour).Unix()})
var out session
if err := s.decode(v, &out); err != nil || out.Sub != "a" {
t.Fatalf("décodage : %v", err)
}
p, sig, _ := strings.Cut(v, ".")
forged := b64.EncodeToString([]byte(`{"sub":"admin","exp":9999999999}`)) + "." + sig
if s.decode(forged, &out) == nil || s.decode(p+".x", &out) == nil {
t.Fatal("cookie altéré accepté")
}
if (signer{[]byte("autre-cle-0123456")}).decode(v, &out) == nil {
t.Fatal("cookie signé par une autre clé accepté")
}
}
func TestLoadSecret(t *testing.T) {
dir := t.TempDir()
if _, err := LoadSecret("court", dir); err == nil {
t.Fatal("secret trop court accepté")
}
k1, err := LoadSecret("", dir)
if err != nil || len(k1) != 32 {
t.Fatalf("génération : %v", err)
}
fi, _ := os.Stat(filepath.Join(dir, ".session-secret"))
if fi.Mode().Perm() != 0o600 {
t.Fatalf("droits %v", fi.Mode().Perm())
}
k2, _ := LoadSecret("", dir)
if string(k1) != string(k2) {
t.Fatal("clé non conservée entre deux démarrages")
}
}