From bedb6c98e7bcb4b0cae8fbb68fd1a4d4f22be72a Mon Sep 17 00:00:00 2001 From: Fabien Masson Date: Tue, 6 Oct 2026 22:03:20 +0200 Subject: [PATCH] =?UTF-8?q?authentification=20OpenID=20Connect=20(Keycloak?= =?UTF-8?q?)=20:=20flux=20authorization=20code,=20state=20sign=C3=A9,=20va?= =?UTF-8?q?lidation=20du=20token=20ID=20via=20JWKS,=20provisioning=20autom?= =?UTF-8?q?atique=20des=20utilisateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/ioc.go | 5 +- .../internal/adapter/auth/oidc/discovery.go | 88 ++++++ backend/internal/adapter/auth/oidc/jwks.go | 199 +++++++++++++ backend/internal/adapter/auth/oidc/oidc.go | 226 ++++++++++++++ .../internal/adapter/auth/oidc/oidc_test.go | 277 ++++++++++++++++++ backend/internal/adapter/database/factory.go | 1 + backend/internal/adapter/database/turso/db.go | 4 + .../adapter/database/turso/oidc_repository.go | 34 +++ backend/internal/adapter/security/jwt.go | 32 ++ backend/internal/domain/oidc.go | 21 ++ backend/internal/http/handlers/auth.go | 73 ++++- .../internal/service/auth/authenticator.go | 19 +- backend/internal/service/auth/service.go | 47 ++- backend/web/src/lib/api/endpoints/auth.ts | 10 + .../src/routes/(public)/login/+page.svelte | 44 ++- 15 files changed, 1057 insertions(+), 23 deletions(-) create mode 100644 backend/internal/adapter/auth/oidc/discovery.go create mode 100644 backend/internal/adapter/auth/oidc/jwks.go create mode 100644 backend/internal/adapter/auth/oidc/oidc.go create mode 100644 backend/internal/adapter/auth/oidc/oidc_test.go create mode 100644 backend/internal/adapter/database/turso/oidc_repository.go create mode 100644 backend/internal/domain/oidc.go diff --git a/backend/cmd/ioc.go b/backend/cmd/ioc.go index 35e99db..c7a572f 100644 --- a/backend/cmd/ioc.go +++ b/backend/cmd/ioc.go @@ -3,6 +3,7 @@ package cmd import ( "time" + "trankilou.fr/lassistanoque/backend/internal/adapter/auth/oidc" "trankilou.fr/lassistanoque/backend/internal/adapter/auth/password" "trankilou.fr/lassistanoque/backend/internal/adapter/database" "trankilou.fr/lassistanoque/backend/internal/adapter/file" @@ -71,6 +72,8 @@ func getAuthenticators() map[string]auth.Authenticator { authenticators := make(map[string]auth.Authenticator) pwdAuth := password.NewPasswordAuthenticator(GetTokenManager(), db.UserRepository()) authenticators["password"] = pwdAuth + oidcAuth := oidc.NewOIDCAuthenticator(GetTokenManager(), db.OIDCProviderRepository(), db.UserRepository()) + authenticators["oidc"] = oidcAuth return authenticators } @@ -98,7 +101,7 @@ func GetStorageService() *storage.Service { func GetAuthService() *auth.Service { if authService == nil { - authService = auth.NewService(db.SettingsRepository(), db.UserRepository(), getAuthenticators()) + authService = auth.NewService(db.SettingsRepository(), db.UserRepository(), db.OIDCProviderRepository(), GetTokenManager(), getAuthenticators()) } return authService } diff --git a/backend/internal/adapter/auth/oidc/discovery.go b/backend/internal/adapter/auth/oidc/discovery.go new file mode 100644 index 0000000..f9d6224 --- /dev/null +++ b/backend/internal/adapter/auth/oidc/discovery.go @@ -0,0 +1,88 @@ +package oidc + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "trankilou.fr/lassistanoque/backend/internal/domain" +) + +// providerMetadata est le document de découverte OpenID +// (.well-known/openid-configuration). +type providerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri"` +} + +type metadataCacheEntry struct { + metadata *providerMetadata + expiresAt time.Time +} + +// discovery maintient un cache des documents de découverte par fournisseur. +type discovery struct { + httpClient *http.Client + mu sync.Mutex + entries map[string]*metadataCacheEntry +} + +func newDiscovery() *discovery { + return &discovery{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + entries: make(map[string]*metadataCacheEntry), + } +} + +// Metadata retourne les endpoints du fournisseur, avec mise en cache. +func (d *discovery) Metadata(provider *domain.OIDCProvider) (*providerMetadata, error) { + d.mu.Lock() + entry, ok := d.entries[provider.ID] + if ok && time.Now().Before(entry.expiresAt) { + d.mu.Unlock() + return entry.metadata, nil + } + d.mu.Unlock() + + metadata, err := d.fetch(provider.WellknownURL) + if err != nil { + return nil, fmt.Errorf("error fetching provider discovery document: %s", err) + } + if metadata.Issuer == "" || metadata.AuthorizationEndpoint == "" || metadata.TokenEndpoint == "" || metadata.JwksURI == "" { + return nil, fmt.Errorf("incomplete discovery document for provider %s", provider.ID) + } + + d.mu.Lock() + d.entries[provider.ID] = &metadataCacheEntry{ + metadata: metadata, + expiresAt: time.Now().Add(1 * time.Hour), + } + d.mu.Unlock() + + return metadata, nil +} + +func (d *discovery) fetch(wellknownURL string) (*providerMetadata, error) { + res, err := d.httpClient.Get(wellknownURL) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + metadata := &providerMetadata{} + if err := json.Unmarshal(body, metadata); err != nil { + return nil, err + } + return metadata, nil +} diff --git a/backend/internal/adapter/auth/oidc/jwks.go b/backend/internal/adapter/auth/oidc/jwks.go new file mode 100644 index 0000000..7b735b8 --- /dev/null +++ b/backend/internal/adapter/auth/oidc/jwks.go @@ -0,0 +1,199 @@ +package oidc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// jwk est une clé du document JWKS du fournisseur. +type jwk struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +type jwksDocument struct { + Keys []jwk `json:"keys"` +} + +type jwksCacheEntry struct { + keys map[string]interface{} + expiresAt time.Time +} + +// jwksStore maintient un cache des clés publiques de signature par fournisseur. +type jwksStore struct { + httpClient *http.Client + mu sync.Mutex + entries map[string]*jwksCacheEntry +} + +func newJwksStore() *jwksStore { + return &jwksStore{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + entries: make(map[string]*jwksCacheEntry), + } +} + +// KeyFunc retourne une fonction de résolution de clé pour golang-jwt, +// valable pour un fournisseur donné. +func (s *jwksStore) KeyFunc(providerID string, jwksURI string) jwt.Keyfunc { + return func(t *jwt.Token) (interface{}, error) { + kid, ok := t.Header["kid"].(string) + if !ok || kid == "" { + return nil, fmt.Errorf("id token: kid manquant") + } + key, err := s.key(providerID, jwksURI, kid) + if err != nil { + return nil, err + } + return key, nil + } +} + +// key retourne la clé publique correspondant au kid, avec mise en cache. +// Si le kid est inconnu, le cache est invalidé et le JWKS re-téléchargé +// (rotation de clés côté fournisseur). +func (s *jwksStore) key(providerID string, jwksURI string, kid string) (interface{}, error) { + fetch := func() (map[string]interface{}, error) { + keys, err := s.fetch(jwksURI) + if err != nil { + return nil, fmt.Errorf("error fetching provider JWKS: %s", err) + } + s.mu.Lock() + s.entries[providerID] = &jwksCacheEntry{ + keys: keys, + expiresAt: time.Now().Add(1 * time.Hour), + } + s.mu.Unlock() + return keys, nil + } + + s.mu.Lock() + entry, ok := s.entries[providerID] + valid := ok && time.Now().Before(entry.expiresAt) + s.mu.Unlock() + + if valid { + if key, ok := entry.keys[kid]; ok { + return key, nil + } + } + + keys, err := fetch() + if err != nil { + return nil, err + } + key, ok := keys[kid] + if !ok { + return nil, fmt.Errorf("id token: aucune clé JWKS ne correspond au kid %s", kid) + } + return key, nil +} + +func (s *jwksStore) fetch(jwksURI string) (map[string]interface{}, error) { + res, err := s.httpClient.Get(jwksURI) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + document := &jwksDocument{} + if err := json.Unmarshal(body, document); err != nil { + return nil, err + } + + keys := make(map[string]interface{}) + for _, k := range document.Keys { + if k.Use != "" && k.Use != "sig" { + continue + } + switch k.Kty { + case "RSA": + key, err := rsaKey(k) + if err != nil { + return nil, err + } + keys[k.Kid] = key + case "EC": + key, err := ecKey(k) + if err != nil { + return nil, err + } + keys[k.Kid] = key + } + } + return keys, nil +} + +func rsaKey(k jwk) (*rsa.PublicKey, error) { + if k.N == "" || k.E == "" { + return nil, fmt.Errorf("clé RSA JWKS incomplète (kid %s)", k.Kid) + } + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + return nil, fmt.Errorf("module RSA invalide: %s", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + return nil, fmt.Errorf("exposant RSA invalide: %s", err) + } + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + }, nil +} + +func ecKey(k jwk) (*ecdsa.PublicKey, error) { + if k.X == "" || k.Y == "" { + return nil, fmt.Errorf("clé EC JWKS incomplète (kid %s)", k.Kid) + } + var curve elliptic.Curve + switch strings.ToUpper(k.Crv) { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("courbe EC non supportée: %s", k.Crv) + } + xBytes, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + return nil, fmt.Errorf("coordonnée x invalide: %s", err) + } + yBytes, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { + return nil, fmt.Errorf("coordonnée y invalide: %s", err) + } + return &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + }, nil +} diff --git a/backend/internal/adapter/auth/oidc/oidc.go b/backend/internal/adapter/auth/oidc/oidc.go new file mode 100644 index 0000000..9cbdbf9 --- /dev/null +++ b/backend/internal/adapter/auth/oidc/oidc.go @@ -0,0 +1,226 @@ +package oidc + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "trankilou.fr/lassistanoque/backend/internal/domain" + "trankilou.fr/lassistanoque/backend/internal/service/auth" +) + +var ( + ErrRegistrationUnsupported = errors.New("registration via OIDC is not supported: users are provisioned at login") + ErrNoEmail = errors.New("id token does not contain an email claim") + ErrUserDisabled = errors.New("user is disabled") +) + +// tokenResponse est la réponse de l'endpoint token du fournisseur. +type tokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// idTokenClaims sont les claims du token ID émis par le fournisseur. +type idTokenClaims struct { + Email string `json:"email"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` + jwt.RegisteredClaims +} + +type OIDCAuthenticator struct { + tokenManager auth.TokenManager + oidcRepository domain.OIDCProviderRepository + userRepository domain.UserRepository + discovery *discovery + jwks *jwksStore + httpClient *http.Client +} + +func NewOIDCAuthenticator( + tokenManager auth.TokenManager, + oidcRepository domain.OIDCProviderRepository, + userRepository domain.UserRepository, +) *OIDCAuthenticator { + return &OIDCAuthenticator{ + tokenManager: tokenManager, + oidcRepository: oidcRepository, + userRepository: userRepository, + discovery: newDiscovery(), + jwks: newJwksStore(), + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// AuthorizeURL construit l'URL d'autorisation vers laquelle rediriger l'utilisateur. +func (s *OIDCAuthenticator) AuthorizeURL(ctx context.Context, provider *domain.OIDCProvider, redirectURI string, state string) (string, error) { + metadata, err := s.discovery.Metadata(provider) + if err != nil { + return "", err + } + + params := url.Values{} + params.Set("client_id", provider.ClientID) + params.Set("redirect_uri", redirectURI) + params.Set("response_type", "code") + params.Set("scope", "openid profile email") + params.Set("state", state) + + return metadata.AuthorizationEndpoint + "?" + params.Encode(), nil +} + +// ExchangeCode échange le code d'autorisation contre un token ID, le valide, +// puis délivre une session applicative. +func (s *OIDCAuthenticator) ExchangeCode(ctx context.Context, provider *domain.OIDCProvider, code string, redirectURI string) (*auth.Session, error) { + metadata, err := s.discovery.Metadata(provider) + if err != nil { + return nil, err + } + + tokens, err := s.requestTokens(metadata.TokenEndpoint, provider, code, redirectURI) + if err != nil { + return nil, err + } + if tokens.IDToken == "" { + return nil, fmt.Errorf("provider %s did not return an id token", provider.ID) + } + + claims, err := s.validateIDToken(provider, metadata, tokens.IDToken) + if err != nil { + return nil, err + } + if claims.Email == "" { + return nil, ErrNoEmail + } + + user, err := s.provisionUser(claims) + if err != nil { + return nil, err + } + + accessToken, expiresAt, err := s.tokenManager.GenerateAccessToken(user) + if err != nil { + return nil, fmt.Errorf("unable to generate access token: %s", err) + } + refreshToken, _, err := s.tokenManager.GenerateRefreshToken(user.ID) + if err != nil { + return nil, fmt.Errorf("unable to generate refresh token: %s", err) + } + + return &auth.Session{ + User: user, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + }, nil +} + +// Authenticate implémente auth.Authenticator: le flux OIDC passe par le state +// signé qui transporte l'id du fournisseur, puis l'échange du code. +func (s *OIDCAuthenticator) Authenticate(ctx context.Context, creds auth.Credentials) (*auth.Session, error) { + providerID, err := s.tokenManager.ParseStateToken(creds.OIDCState) + if err != nil { + return nil, fmt.Errorf("invalid oidc state: %s", err) + } + provider, err := s.oidcRepository.FindOIDCProvider(providerID) + if err != nil { + return nil, fmt.Errorf("unknown oidc provider: %s", providerID) + } + return s.ExchangeCode(ctx, provider, creds.OIDCCode, creds.RedirectURI) +} + +func (s *OIDCAuthenticator) Register(ctx context.Context, registration auth.Registration) (*auth.Session, error) { + return nil, ErrRegistrationUnsupported +} + +func (s *OIDCAuthenticator) requestTokens(tokenEndpoint string, provider *domain.OIDCProvider, code string, redirectURI string) (*tokenResponse, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + form.Set("client_id", provider.ClientID) + form.Set("client_secret", provider.ClientSecret) + + res, err := s.httpClient.Post(tokenEndpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("error calling token endpoint: %s", err) + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token endpoint returned status %d: %s", res.StatusCode, string(body)) + } + + tokens := &tokenResponse{} + if err := json.Unmarshal(body, tokens); err != nil { + return nil, err + } + return tokens, nil +} + +func (s *OIDCAuthenticator) validateIDToken(provider *domain.OIDCProvider, metadata *providerMetadata, idToken string) (*idTokenClaims, error) { + claims := &idTokenClaims{} + _, err := jwt.ParseWithClaims(idToken, claims, s.jwks.KeyFunc(provider.ID, metadata.JwksURI), + jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES521"}), + jwt.WithIssuer(metadata.Issuer), + jwt.WithAudience(provider.ClientID), + ) + if err != nil { + return nil, fmt.Errorf("invalid id token: %s", err) + } + return claims, nil +} + +func (s *OIDCAuthenticator) provisionUser(claims *idTokenClaims) (*domain.User, error) { + user, err := s.userRepository.FindUserByEmail(claims.Email) + if err == nil { + if !user.Enabled { + return nil, ErrUserDisabled + } + return user, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + firstname := claims.GivenName + if firstname == "" { + firstname = claims.PreferredUsername + } + user, err = s.userRepository.CreateUser(&domain.User{ + Email: claims.Email, + Firstname: firstname, + Lastname: claims.FamilyName, + Enabled: true, + }) + if err != nil { + return nil, fmt.Errorf("error creating oidc user %s: %s", claims.Email, err) + } + + _, err = s.userRepository.CreateTeam(user.ID, &domain.Team{ + Label: "Espace personnel", + DateCreated: time.Now(), + }) + if err != nil { + return nil, fmt.Errorf("error creating personal team for oidc user %s: %s", claims.Email, err) + } + + return user, nil +} diff --git a/backend/internal/adapter/auth/oidc/oidc_test.go b/backend/internal/adapter/auth/oidc/oidc_test.go new file mode 100644 index 0000000..5c7553c --- /dev/null +++ b/backend/internal/adapter/auth/oidc/oidc_test.go @@ -0,0 +1,277 @@ +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") + } +} diff --git a/backend/internal/adapter/database/factory.go b/backend/internal/adapter/database/factory.go index fde0f84..45914a3 100644 --- a/backend/internal/adapter/database/factory.go +++ b/backend/internal/adapter/database/factory.go @@ -11,6 +11,7 @@ import ( type Database interface { SettingsRepository() domain.SettingsRepository UserRepository() domain.UserRepository + OIDCProviderRepository() domain.OIDCProviderRepository FileRepository() domain.FileRepository ProviderRepository() domain.ProviderRepository AgentRepository() domain.AgentRepository diff --git a/backend/internal/adapter/database/turso/db.go b/backend/internal/adapter/database/turso/db.go index d247f27..ee02093 100644 --- a/backend/internal/adapter/database/turso/db.go +++ b/backend/internal/adapter/database/turso/db.go @@ -64,6 +64,10 @@ func (db *TursoDB) SettingsRepository() domain.SettingsRepository { return NewTursoSettingsRepository(db.DB) } +func (db *TursoDB) OIDCProviderRepository() domain.OIDCProviderRepository { + return NewTursoOIDCProviderRepository(db.DB) +} + func (db *TursoDB) FileRepository() domain.FileRepository { return NewTursoFileRepository(db.DB) } diff --git a/backend/internal/adapter/database/turso/oidc_repository.go b/backend/internal/adapter/database/turso/oidc_repository.go new file mode 100644 index 0000000..a2b5192 --- /dev/null +++ b/backend/internal/adapter/database/turso/oidc_repository.go @@ -0,0 +1,34 @@ +package turso + +import ( + "database/sql" + "log" + + "trankilou.fr/lassistanoque/backend/internal/adapter/database/orm" + "trankilou.fr/lassistanoque/backend/internal/domain" +) + +type TursoOIDCProviderRepository struct { + db *sql.DB + OIDCTable orm.Table[domain.OIDCProvider] +} + +func NewTursoOIDCProviderRepository(db *sql.DB) *TursoOIDCProviderRepository { + dialect := orm.NewSqliteDialect() + oidcTable, err := orm.NewTable[domain.OIDCProvider](db, dialect, "oidc") + if err != nil { + log.Fatalf("error creating oidc provider table") + } + return &TursoOIDCProviderRepository{ + db: db, + OIDCTable: oidcTable, + } +} + +func (or *TursoOIDCProviderRepository) ListOIDCProviders() ([]*domain.OIDCProvider, error) { + return or.OIDCTable.Select(orm.WithOrder("label asc")) +} + +func (or *TursoOIDCProviderRepository) FindOIDCProvider(id string) (*domain.OIDCProvider, error) { + return or.OIDCTable.Get(id) +} diff --git a/backend/internal/adapter/security/jwt.go b/backend/internal/adapter/security/jwt.go index 34bd948..d92b059 100644 --- a/backend/internal/adapter/security/jwt.go +++ b/backend/internal/adapter/security/jwt.go @@ -75,6 +75,38 @@ func (tm *JwtTokenManager) GenerateRefreshToken(userID string) (string, time.Tim return signed, expiresAt, err } +// GenerateStateToken crée un JWT court (10 min) utilisé comme paramètre state +// du flux OIDC, porteur d'une donnée arbitraire (ex: l'id du fournisseur). +func (tm *JwtTokenManager) GenerateStateToken(data string) (string, error) { + claims := jwt.RegisteredClaims{ + Subject: data, + Issuer: tm.issuer, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(tm.secret) +} + +// ParseStateToken vérifie un state généré par GenerateStateToken et retourne +// la donnée qu'il transporte. +func (tm *JwtTokenManager) ParseStateToken(state string) (string, error) { + claims := &jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(state, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("méthode de signature inattendue") + } + return tm.secret, nil + }) + if err != nil { + return "", err + } + if !token.Valid { + return "", errors.New("token invalide") + } + return claims.Subject, nil +} + // ParseAndValidate décode et vérifie la signature + l'expiration d'un JWT. func (tm *JwtTokenManager) ParseAndValidate(tokenString string) (*domain.User, error) { claims := &Claims{} diff --git a/backend/internal/domain/oidc.go b/backend/internal/domain/oidc.go new file mode 100644 index 0000000..fcd26c6 --- /dev/null +++ b/backend/internal/domain/oidc.go @@ -0,0 +1,21 @@ +package domain + +import "time" + +// OIDCProvider décrit un fournisseur OpenID Connect enregistré dans la table oidc. +type OIDCProvider struct { + ID string `db:"id" json:"id"` + Label string `db:"label" json:"label"` + Domain string `db:"domain" json:"domain"` + ClientID string `db:"client_id" json:"clientId"` + ClientSecret string `db:"client_secret" json:"-"` + WellknownURL string `db:"wellknown_url" json:"wellknownUrl"` + DateCreated time.Time `db:"_date_created" json:"_date_created"` + DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"` + VersionId string `db:"_version" json:"_version"` +} + +type OIDCProviderRepository interface { + ListOIDCProviders() ([]*OIDCProvider, error) + FindOIDCProvider(id string) (*OIDCProvider, error) +} diff --git a/backend/internal/http/handlers/auth.go b/backend/internal/http/handlers/auth.go index 5c0ee7d..4d3234d 100644 --- a/backend/internal/http/handlers/auth.go +++ b/backend/internal/http/handlers/auth.go @@ -17,6 +17,8 @@ func NewAuthGroup(prefix string, e *echo.Group, service *auth.Service) *echo.Gro } auth := e.Group(prefix) auth.POST("/login", authHandler.Login) + auth.GET("/oidc/providers", authHandler.OIDCProviders) + auth.GET("/oidc/login", authHandler.OIDCLogin) auth.GET("/oidc/callback", authHandler.OIDCCallback) auth.POST("/register", authHandler.Register) return auth @@ -69,8 +71,77 @@ func (h AuthHandler) Login(c *echo.Context) error { return c.JSON(http.StatusOK, resp) } +// oidcRedirectURI reconstruit l'URL de callback du serveur à partir de la +// requête. Elle doit être identique entre /oidc/login et /oidc/callback. +func oidcRedirectURI(c *echo.Context) string { + scheme := "http" + if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } else if c.Request().TLS != nil { + scheme = "https" + } + host := c.Request().Header.Get("X-Forwarded-Host") + if host == "" { + host = c.Request().Host + } + return scheme + "://" + host + "/api/auth/oidc/callback" +} + +func (h AuthHandler) OIDCProviders(c *echo.Context) error { + providers, err := h.authService.ListOIDCProviders() + if err != nil { + c.Logger().Error(fmt.Sprintf("error listing oidc providers: %s", err)) + return echo.NewHTTPError(http.StatusInternalServerError, "error listing oidc providers") + } + return c.JSON(http.StatusOK, providers) +} + +func (h AuthHandler) OIDCLogin(c *echo.Context) error { + providerID := c.QueryParam("provider") + if providerID == "" { + return echo.NewHTTPError(http.StatusBadRequest, "provider query parameter is required") + } + + authorizeURL, err := h.authService.OIDCAuthorizeURL(context.Background(), providerID, oidcRedirectURI(c)) + if err != nil { + c.Logger().Error(fmt.Sprintf("error building oidc authorize url: %s", err)) + return c.Redirect(http.StatusFound, "/login?error=oidc") + } + return c.Redirect(http.StatusFound, authorizeURL) +} + func (h AuthHandler) OIDCCallback(c *echo.Context) error { - return nil + if errParam := c.QueryParam("error"); errParam != "" { + c.Logger().Error(fmt.Sprintf("oidc provider returned an error: %s", errParam)) + return c.Redirect(http.StatusFound, "/login?error=oidc") + } + code := c.QueryParam("code") + state := c.QueryParam("state") + if code == "" || state == "" { + return echo.NewHTTPError(http.StatusBadRequest, "code and state are required") + } + + session, err := h.authService.OIDCCallback(context.Background(), state, code, oidcRedirectURI(c)) + if err != nil { + c.Logger().Error(fmt.Sprintf("error while oidc callback: %s", err)) + return c.Redirect(http.StatusFound, "/login?error=oidc") + } + + // L'application SPA est servie par ce même serveur (même origine) et lit + // ses tokens dans localStorage: on les dépose ici puis on redirige vers + // l'application, sans jamais les faire transiter dans une URL. + page := fmt.Sprintf(` + +Connexion en cours... + + + +`, session.AccessToken, session.RefreshToken) + return c.Blob(http.StatusOK, "text/html; charset=utf-8", []byte(page)) } func (h AuthHandler) Register(c *echo.Context) error { diff --git a/backend/internal/service/auth/authenticator.go b/backend/internal/service/auth/authenticator.go index 9767d8a..dd82b77 100644 --- a/backend/internal/service/auth/authenticator.go +++ b/backend/internal/service/auth/authenticator.go @@ -9,10 +9,12 @@ import ( ) type Credentials struct { - Method string // "password" ou "oidc" - Email string - Password string - OIDCCode string + Method string // "password" ou "oidc" + Email string + Password string + OIDCCode string + OIDCState string + RedirectURI string } type Registration struct { @@ -45,6 +47,15 @@ type Authenticator interface { type TokenManager interface { GenerateAccessToken(user *domain.User) (string, time.Time, error) GenerateRefreshToken(userID string) (string, time.Time, error) + GenerateStateToken(data string) (string, error) + ParseStateToken(state string) (string, error) ParseAndValidate(tokenString string) (*domain.User, error) TokenMiddleware(next echo.HandlerFunc) echo.HandlerFunc } + +// OIDCManager est le port implémenté par l'adaptateur OpenID Connect. +// Il gère la redirection vers le fournisseur et l'échange du code d'autorisation. +type OIDCManager interface { + AuthorizeURL(ctx context.Context, provider *domain.OIDCProvider, redirectURI string, state string) (string, error) + ExchangeCode(ctx context.Context, provider *domain.OIDCProvider, code string, redirectURI string) (*Session, error) +} diff --git a/backend/internal/service/auth/service.go b/backend/internal/service/auth/service.go index 1d449ec..592816d 100644 --- a/backend/internal/service/auth/service.go +++ b/backend/internal/service/auth/service.go @@ -17,20 +17,31 @@ var ( type Service struct { authenticators map[string]Authenticator // "password" -> ..., "oidc" -> ... + oidcManager OIDCManager + tokenManager TokenManager userRepository domain.UserRepository + oidcRepository domain.OIDCProviderRepository settingsRepository domain.SettingsRepository } func NewService( settingsRepository domain.SettingsRepository, userRepository domain.UserRepository, + oidcRepository domain.OIDCProviderRepository, + tokenManager TokenManager, authenticators map[string]Authenticator, ) *Service { - return &Service{ + service := &Service{ authenticators: authenticators, + tokenManager: tokenManager, userRepository: userRepository, + oidcRepository: oidcRepository, settingsRepository: settingsRepository, } + if manager, ok := authenticators["oidc"].(OIDCManager); ok { + service.oidcManager = manager + } + return service } func (s *Service) Login(ctx context.Context, creds Credentials) (*Session, error) { @@ -72,3 +83,37 @@ func (s *Service) Register(registration Registration) (*Session, error) { func (s *Service) Status(session *Session) (*domain.User, error) { return nil, nil } + +// ListOIDCProviders retourne les fournisseurs OpenID Connect enregistrés, +// sans exposer le secret client (champ marqué json:"-"). +func (s *Service) ListOIDCProviders() ([]*domain.OIDCProvider, error) { + return s.oidcRepository.ListOIDCProviders() +} + +// OIDCAuthorizeURL construit l'URL d'autorisation du fournisseur, avec un +// state signé transportant l'id du fournisseur. +func (s *Service) OIDCAuthorizeURL(ctx context.Context, providerID string, redirectURI string) (string, error) { + provider, err := s.oidcRepository.FindOIDCProvider(providerID) + if err != nil { + return "", fmt.Errorf("unknown oidc provider: %s", providerID) + } + state, err := s.tokenManager.GenerateStateToken(providerID) + if err != nil { + return "", err + } + return s.oidcManager.AuthorizeURL(ctx, provider, redirectURI, state) +} + +// OIDCCallback traite le retour du fournisseur: vérifie le state, échange le +// code d'autorisation et délivre une session applicative. +func (s *Service) OIDCCallback(ctx context.Context, state string, code string, redirectURI string) (*Session, error) { + providerID, err := s.tokenManager.ParseStateToken(state) + if err != nil { + return nil, fmt.Errorf("invalid oidc state: %s", err) + } + provider, err := s.oidcRepository.FindOIDCProvider(providerID) + if err != nil { + return nil, fmt.Errorf("unknown oidc provider: %s", providerID) + } + return s.oidcManager.ExchangeCode(ctx, provider, code, redirectURI) +} diff --git a/backend/web/src/lib/api/endpoints/auth.ts b/backend/web/src/lib/api/endpoints/auth.ts index 753ec22..b17e919 100644 --- a/backend/web/src/lib/api/endpoints/auth.ts +++ b/backend/web/src/lib/api/endpoints/auth.ts @@ -21,8 +21,18 @@ export interface RegistrationData { password: string } +export interface OIDCProvider { + id: string + label: string + domain: string + clientId: string + wellknownUrl: string +} + export const authApi = { register: (data: RegistrationData) => api.post('/auth/register', data), status: () => api.get('/auth/status'), login: (data: Credentials) => api.post('/auth/login', data), + oidcProviders: () => api.get('/auth/oidc/providers', { auth: false }), + oidcLoginUrl: (providerId: string) => `${import.meta.env.VITE_API_URL}/auth/oidc/login?provider=${encodeURIComponent(providerId)}`, }; diff --git a/backend/web/src/routes/(public)/login/+page.svelte b/backend/web/src/routes/(public)/login/+page.svelte index b2002d2..f175ab1 100644 --- a/backend/web/src/routes/(public)/login/+page.svelte +++ b/backend/web/src/routes/(public)/login/+page.svelte @@ -2,8 +2,9 @@ import logo from "$lib/assets/logo-txt.png" import { auth, setToken } from '$lib/state/auth.svelte'; import { goto } from '$app/navigation'; - import { authApi } from "$lib/api/endpoints/auth" + import { authApi, type OIDCProvider } from "$lib/api/endpoints/auth" import { validateEmail } from "$lib/utilities/validators" + import { onMount } from "svelte" type Credentials = { email: string @@ -17,6 +18,18 @@ validemail: false }) + let providers: OIDCProvider[] = $state([]) + + onMount(() => { + authApi.oidcProviders() + .then((res) => { providers = res ?? [] }) + .catch(() => { providers = [] }) + }) + + const oidcLogin = (providerId: string) => { + window.location.href = authApi.oidcLoginUrl(providerId) + } + const formSubmitEmail = () => { credentials.validemail = validateEmail(credentials.email) } @@ -85,22 +98,21 @@ {/if} -
+ {#if providers.length > 0} +
-
- - - - -
+
+ {#each providers as provider (provider.id)} + + {/each} +
+ {/if}