authentification OpenID Connect (Keycloak) : flux authorization code, state signé, validation du token ID via JWKS, provisioning automatique des utilisateurs
This commit is contained in:
1 parent
51bbb45b32
commit
bedb6c98e7
15 files changed
+1057
-23
No files matched your search
+4
-1
@@ -3,6 +3,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"trankilou.fr/lassistanoque/backend/internal/adapter/auth/oidc"
|
||||||
"trankilou.fr/lassistanoque/backend/internal/adapter/auth/password"
|
"trankilou.fr/lassistanoque/backend/internal/adapter/auth/password"
|
||||||
"trankilou.fr/lassistanoque/backend/internal/adapter/database"
|
"trankilou.fr/lassistanoque/backend/internal/adapter/database"
|
||||||
"trankilou.fr/lassistanoque/backend/internal/adapter/file"
|
"trankilou.fr/lassistanoque/backend/internal/adapter/file"
|
||||||
@@ -71,6 +72,8 @@ func getAuthenticators() map[string]auth.Authenticator {
|
|||||||
authenticators := make(map[string]auth.Authenticator)
|
authenticators := make(map[string]auth.Authenticator)
|
||||||
pwdAuth := password.NewPasswordAuthenticator(GetTokenManager(), db.UserRepository())
|
pwdAuth := password.NewPasswordAuthenticator(GetTokenManager(), db.UserRepository())
|
||||||
authenticators["password"] = pwdAuth
|
authenticators["password"] = pwdAuth
|
||||||
|
oidcAuth := oidc.NewOIDCAuthenticator(GetTokenManager(), db.OIDCProviderRepository(), db.UserRepository())
|
||||||
|
authenticators["oidc"] = oidcAuth
|
||||||
return authenticators
|
return authenticators
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ func GetStorageService() *storage.Service {
|
|||||||
|
|
||||||
func GetAuthService() *auth.Service {
|
func GetAuthService() *auth.Service {
|
||||||
if authService == nil {
|
if authService == nil {
|
||||||
authService = auth.NewService(db.SettingsRepository(), db.UserRepository(), getAuthenticators())
|
authService = auth.NewService(db.SettingsRepository(), db.UserRepository(), db.OIDCProviderRepository(), GetTokenManager(), getAuthenticators())
|
||||||
}
|
}
|
||||||
return authService
|
return authService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
type Database interface {
|
type Database interface {
|
||||||
SettingsRepository() domain.SettingsRepository
|
SettingsRepository() domain.SettingsRepository
|
||||||
UserRepository() domain.UserRepository
|
UserRepository() domain.UserRepository
|
||||||
|
OIDCProviderRepository() domain.OIDCProviderRepository
|
||||||
FileRepository() domain.FileRepository
|
FileRepository() domain.FileRepository
|
||||||
ProviderRepository() domain.ProviderRepository
|
ProviderRepository() domain.ProviderRepository
|
||||||
AgentRepository() domain.AgentRepository
|
AgentRepository() domain.AgentRepository
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ func (db *TursoDB) SettingsRepository() domain.SettingsRepository {
|
|||||||
return NewTursoSettingsRepository(db.DB)
|
return NewTursoSettingsRepository(db.DB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *TursoDB) OIDCProviderRepository() domain.OIDCProviderRepository {
|
||||||
|
return NewTursoOIDCProviderRepository(db.DB)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *TursoDB) FileRepository() domain.FileRepository {
|
func (db *TursoDB) FileRepository() domain.FileRepository {
|
||||||
return NewTursoFileRepository(db.DB)
|
return NewTursoFileRepository(db.DB)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -75,6 +75,38 @@ func (tm *JwtTokenManager) GenerateRefreshToken(userID string) (string, time.Tim
|
|||||||
return signed, expiresAt, err
|
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.
|
// ParseAndValidate décode et vérifie la signature + l'expiration d'un JWT.
|
||||||
func (tm *JwtTokenManager) ParseAndValidate(tokenString string) (*domain.User, error) {
|
func (tm *JwtTokenManager) ParseAndValidate(tokenString string) (*domain.User, error) {
|
||||||
claims := &Claims{}
|
claims := &Claims{}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ func NewAuthGroup(prefix string, e *echo.Group, service *auth.Service) *echo.Gro
|
|||||||
}
|
}
|
||||||
auth := e.Group(prefix)
|
auth := e.Group(prefix)
|
||||||
auth.POST("/login", authHandler.Login)
|
auth.POST("/login", authHandler.Login)
|
||||||
|
auth.GET("/oidc/providers", authHandler.OIDCProviders)
|
||||||
|
auth.GET("/oidc/login", authHandler.OIDCLogin)
|
||||||
auth.GET("/oidc/callback", authHandler.OIDCCallback)
|
auth.GET("/oidc/callback", authHandler.OIDCCallback)
|
||||||
auth.POST("/register", authHandler.Register)
|
auth.POST("/register", authHandler.Register)
|
||||||
return auth
|
return auth
|
||||||
@@ -69,8 +71,77 @@ func (h AuthHandler) Login(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, resp)
|
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 {
|
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(`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>Connexion en cours...</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
localStorage.setItem("accessToken", %q);
|
||||||
|
localStorage.setItem("refreshToken", %q);
|
||||||
|
location.replace("/");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`, session.AccessToken, session.RefreshToken)
|
||||||
|
return c.Blob(http.StatusOK, "text/html; charset=utf-8", []byte(page))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h AuthHandler) Register(c *echo.Context) error {
|
func (h AuthHandler) Register(c *echo.Context) error {
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Credentials struct {
|
type Credentials struct {
|
||||||
Method string // "password" ou "oidc"
|
Method string // "password" ou "oidc"
|
||||||
Email string
|
Email string
|
||||||
Password string
|
Password string
|
||||||
OIDCCode string
|
OIDCCode string
|
||||||
|
OIDCState string
|
||||||
|
RedirectURI string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Registration struct {
|
type Registration struct {
|
||||||
@@ -45,6 +47,15 @@ type Authenticator interface {
|
|||||||
type TokenManager interface {
|
type TokenManager interface {
|
||||||
GenerateAccessToken(user *domain.User) (string, time.Time, error)
|
GenerateAccessToken(user *domain.User) (string, time.Time, error)
|
||||||
GenerateRefreshToken(userID string) (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)
|
ParseAndValidate(tokenString string) (*domain.User, error)
|
||||||
TokenMiddleware(next echo.HandlerFunc) echo.HandlerFunc
|
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)
|
||||||
|
}
|
||||||
@@ -17,20 +17,31 @@ var (
|
|||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
authenticators map[string]Authenticator // "password" -> ..., "oidc" -> ...
|
authenticators map[string]Authenticator // "password" -> ..., "oidc" -> ...
|
||||||
|
oidcManager OIDCManager
|
||||||
|
tokenManager TokenManager
|
||||||
userRepository domain.UserRepository
|
userRepository domain.UserRepository
|
||||||
|
oidcRepository domain.OIDCProviderRepository
|
||||||
settingsRepository domain.SettingsRepository
|
settingsRepository domain.SettingsRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
settingsRepository domain.SettingsRepository,
|
settingsRepository domain.SettingsRepository,
|
||||||
userRepository domain.UserRepository,
|
userRepository domain.UserRepository,
|
||||||
|
oidcRepository domain.OIDCProviderRepository,
|
||||||
|
tokenManager TokenManager,
|
||||||
authenticators map[string]Authenticator,
|
authenticators map[string]Authenticator,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
service := &Service{
|
||||||
authenticators: authenticators,
|
authenticators: authenticators,
|
||||||
|
tokenManager: tokenManager,
|
||||||
userRepository: userRepository,
|
userRepository: userRepository,
|
||||||
|
oidcRepository: oidcRepository,
|
||||||
settingsRepository: settingsRepository,
|
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) {
|
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) {
|
func (s *Service) Status(session *Session) (*domain.User, error) {
|
||||||
return nil, nil
|
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)
|
||||||
|
}
|
||||||
@@ -21,8 +21,18 @@ export interface RegistrationData {
|
|||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OIDCProvider {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
domain: string
|
||||||
|
clientId: string
|
||||||
|
wellknownUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: (data: RegistrationData) => api.post<User>('/auth/register', data),
|
register: (data: RegistrationData) => api.post<User>('/auth/register', data),
|
||||||
status: () => api.get<User>('/auth/status'),
|
status: () => api.get<User>('/auth/status'),
|
||||||
login: (data: Credentials) => api.post<UserReponse>('/auth/login', data),
|
login: (data: Credentials) => api.post<UserReponse>('/auth/login', data),
|
||||||
|
oidcProviders: () => api.get<OIDCProvider[]>('/auth/oidc/providers', { auth: false }),
|
||||||
|
oidcLoginUrl: (providerId: string) => `${import.meta.env.VITE_API_URL}/auth/oidc/login?provider=${encodeURIComponent(providerId)}`,
|
||||||
};
|
};
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
import logo from "$lib/assets/logo-txt.png"
|
import logo from "$lib/assets/logo-txt.png"
|
||||||
import { auth, setToken } from '$lib/state/auth.svelte';
|
import { auth, setToken } from '$lib/state/auth.svelte';
|
||||||
import { goto } from '$app/navigation';
|
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 { validateEmail } from "$lib/utilities/validators"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
|
||||||
type Credentials = {
|
type Credentials = {
|
||||||
email: string
|
email: string
|
||||||
@@ -17,6 +18,18 @@
|
|||||||
validemail: false
|
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 = () => {
|
const formSubmitEmail = () => {
|
||||||
credentials.validemail = validateEmail(credentials.email)
|
credentials.validemail = validateEmail(credentials.email)
|
||||||
}
|
}
|
||||||
@@ -85,22 +98,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
<hr class="my-4">
|
{#if providers.length > 0}
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-4">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-4">
|
||||||
<button class="bt p-5 text-center" title="Connexion avec Google">
|
{#each providers as provider (provider.id)}
|
||||||
<i class="icon-google"></i>
|
<button
|
||||||
</button>
|
class="bt p-5 text-center"
|
||||||
<button class="bt p-5 text-center" title="Connexion avec Facebook">
|
title="Connexion avec {provider.label}"
|
||||||
<i class="icon-facebook"></i>
|
onclick={() => oidcLogin(provider.id)}
|
||||||
</button>
|
>
|
||||||
<button class="bt p-5 text-center" title="Connexion avec Github">
|
{provider.label}
|
||||||
<i class="icon-github"></i>
|
</button>
|
||||||
</button>
|
{/each}
|
||||||
<button class="bt p-5 text-center" title="Connexion avec Apple">
|
</div>
|
||||||
<i class="icon-apple"></i>
|
{/if}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Reference in new issue
Block a user