authentification OpenID Connect (Keycloak) : flux authorization code, state signé, validation du token ID via JWKS, provisioning automatique des utilisateurs

This commit is contained in:
fabien committed 2026-10-06 22:03:20 +02:00
1 parent 51bbb45b32
commit bedb6c98e7
15 files changed
+1057 -23

No files matched your search

@@ -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")
}
}