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 }