278 lines
7.6 KiB
Go
278 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|