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,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