diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..0e5ca04 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,332 @@ +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") + } +}