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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user