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