package oidc import ( "context" "crypto/rand" "crypto/rsa" "database/sql" "encoding/base64" "encoding/json" "math/big" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/golang-jwt/jwt/v5" "trankilou.fr/lassistanoque/backend/internal/adapter/security" "trankilou.fr/lassistanoque/backend/internal/domain" ) type mockOIDCRepo struct { domain.OIDCProviderRepository provider *domain.OIDCProvider } func (m *mockOIDCRepo) FindOIDCProvider(id string) (*domain.OIDCProvider, error) { return m.provider, nil } type mockUserRepo struct { domain.UserRepository created *domain.User } func (m *mockUserRepo) FindUserByEmail(email string) (*domain.User, error) { return nil, sql.ErrNoRows } func (m *mockUserRepo) CreateUser(user *domain.User) (*domain.User, error) { user.ID = "user-1" m.created = user return user, nil } func (m *mockUserRepo) CreateTeam(userid string, team *domain.Team) (*domain.Team, error) { return team, nil } // fakeIdP simule un fournisseur OpenID Connect (Keycloak-like): // document de découverte, JWKS et endpoint token. type fakeIdP struct { server *httptest.Server key *rsa.PrivateKey kid string idToken string provider *domain.OIDCProvider redirectURI string tokenCalls int } func newFakeIdP(t *testing.T) *fakeIdP { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } idp := &fakeIdP{key: key, kid: "test-key-1", redirectURI: "http://localhost:3000/api/auth/oidc/callback"} mux := http.NewServeMux() server := httptest.NewServer(mux) idp.server = server mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "issuer": server.URL, "authorization_endpoint": server.URL + "/authorize", "token_endpoint": server.URL + "/token", "jwks_uri": server.URL + "/jwks", }) }) mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string][]map[string]string{ "keys": {{ "kid": idp.kid, "kty": "RSA", "alg": "RS256", "use": "sig", "n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), }}, }) }) mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { idp.tokenCalls++ if err := r.ParseForm(); err != nil { t.Errorf("invalid token request form: %s", err) } if r.Form.Get("grant_type") != "authorization_code" { t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type")) } if r.Form.Get("redirect_uri") != idp.redirectURI { t.Errorf("unexpected redirect_uri: %s", r.Form.Get("redirect_uri")) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "access_token": "opaque-access-token", "id_token": idp.idToken, "token_type": "Bearer", "expires_in": 300, }) }) idp.provider = &domain.OIDCProvider{ ID: "kc", Label: "Keycloak", ClientID: "lassistanoque", ClientSecret: "secret", WellknownURL: server.URL + "/.well-known/openid-configuration", } return idp } func (idp *fakeIdP) signIDToken(t *testing.T, claims jwt.MapClaims) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token.Header["kid"] = idp.kid signed, err := token.SignedString(idp.key) if err != nil { t.Fatal(err) } return signed } func TestMain(m *testing.M) { os.Setenv("LASSISTANOQUE_JWT_SECRET", "test-secret") os.Exit(m.Run()) } func newAuthenticator(idp *fakeIdP) *OIDCAuthenticator { tokenManager := security.NewJwtTokenManager(time.Hour, 24*time.Hour, "lassistanoque") return NewOIDCAuthenticator(tokenManager, &mockOIDCRepo{provider: idp.provider}, &mockUserRepo{}) } func TestAuthorizeURL(t *testing.T) { idp := newFakeIdP(t) defer idp.server.Close() authenticator := newAuthenticator(idp) state, err := authenticator.tokenManager.GenerateStateToken(idp.provider.ID) if err != nil { t.Fatal(err) } url, err := authenticator.AuthorizeURL(context.Background(), idp.provider, idp.redirectURI, state) if err != nil { t.Fatal(err) } if !strings.HasPrefix(url, idp.server.URL+"/authorize?") { t.Errorf("unexpected authorize url: %s", url) } for _, expected := range []string{ "client_id=lassistanoque", "response_type=code", "scope=openid+profile+email", "state=", } { if !strings.Contains(url, expected) { t.Errorf("authorize url misses %s: %s", expected, url) } } } func TestExchangeCodeCreatesUserAndSession(t *testing.T) { idp := newFakeIdP(t) defer idp.server.Close() idp.idToken = idp.signIDToken(t, jwt.MapClaims{ "iss": idp.server.URL, "aud": idp.provider.ClientID, "exp": time.Now().Add(5 * time.Minute).Unix(), "email": "fabien@example.com", "given_name": "Fabien", "family_name": "Dupont", }) userRepo := &mockUserRepo{} tokenManager := security.NewJwtTokenManager(time.Hour, 24*time.Hour, "lassistanoque") authenticator := NewOIDCAuthenticator(tokenManager, &mockOIDCRepo{provider: idp.provider}, userRepo) session, err := authenticator.ExchangeCode(context.Background(), idp.provider, "auth-code", idp.redirectURI) if err != nil { t.Fatal(err) } if idp.tokenCalls != 1 { t.Errorf("expected 1 token endpoint call, got %d", idp.tokenCalls) } if userRepo.created == nil { t.Fatal("user was not provisioned") } if userRepo.created.Email != "fabien@example.com" || userRepo.created.Firstname != "Fabien" || userRepo.created.Lastname != "Dupont" { t.Errorf("provisioned user mismatch: %+v", userRepo.created) } if !userRepo.created.Enabled { t.Error("provisioned user should be enabled") } if session.AccessToken == "" || session.RefreshToken == "" { t.Error("session tokens are empty") } if session.User == nil || session.User.Email != "fabien@example.com" { t.Errorf("session user mismatch: %+v", session.User) } // le token applicatif doit être valide user, err := tokenManager.ParseAndValidate(session.AccessToken) if err != nil { t.Fatalf("generated access token invalid: %s", err) } if user.ID != "user-1" { t.Errorf("unexpected user id in access token: %s", user.ID) } } func TestExchangeCodeRejectsForgedIDToken(t *testing.T) { idp := newFakeIdP(t) defer idp.server.Close() // token signé par une autre clé que celle du JWKS forgedKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ "iss": idp.server.URL, "aud": idp.provider.ClientID, "exp": time.Now().Add(5 * time.Minute).Unix(), "email": "attacker@example.com", }) token.Header["kid"] = idp.kid idp.idToken, err = token.SignedString(forgedKey) if err != nil { t.Fatal(err) } authenticator := newAuthenticator(idp) if _, err := authenticator.ExchangeCode(context.Background(), idp.provider, "auth-code", idp.redirectURI); err == nil { t.Fatal("exchange should fail with a forged id token") } } func TestExchangeCodeRejectsWrongIssuer(t *testing.T) { idp := newFakeIdP(t) defer idp.server.Close() idp.idToken = idp.signIDToken(t, jwt.MapClaims{ "iss": "https://evil.example.com", "aud": idp.provider.ClientID, "exp": time.Now().Add(5 * time.Minute).Unix(), "email": "attacker@example.com", }) authenticator := newAuthenticator(idp) if _, err := authenticator.ExchangeCode(context.Background(), idp.provider, "auth-code", idp.redirectURI); err == nil { t.Fatal("exchange should fail with a wrong issuer") } }