Authentification OIDC : tests du package auth avec un fournisseur simulé
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018GBsBpHJW7GxFDBV7z1zmo
This commit is contained in:
1 parent
d44ff8aab3
commit
2ff594d0b9
1 file changed
+332
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user