Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/labstack/echo/v4"
)

// main initializes environment configuration, scheduled tasks, database connections, services, and starts the web server.
func main() {
env, err := config.GetEnv()
if err != nil {
Expand All @@ -38,7 +39,10 @@ func main() {
dbgen := dbgen.New(db)

ints := integration.New()
servs := service.New(env, dbgen, cr, ints)
servs, err := service.New(env, dbgen, cr, ints)
if err != nil {
logger.FatalError("error initializing services", logger.KV{"error": err})
}
initSchedule(cr, servs)

app := echo.New()
Expand Down
5 changes: 4 additions & 1 deletion cmd/changepw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
"github.com/google/uuid"
)

// main runs a command-line utility to reset a user's password in the PostgreSQL database.
// It prompts for a user's email, verifies the user exists, generates a new random password,
// updates the password in the database, and displays the new password to the operator.
func main() {
env, err := config.GetEnv()
if err != nil {
Expand Down Expand Up @@ -60,7 +63,7 @@ func main() {
err = dbg.UsersServiceChangePassword(
context.Background(), dbgen.UsersServiceChangePasswordParams{
ID: userID,
Password: hashedPassword,
Password: sql.NullString{String: hashedPassword, Valid: true},
},
)
if err != nil {
Expand Down
19 changes: 18 additions & 1 deletion internal/config/env_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import (
"github.com/eduardolat/pgbackweb/internal/validate"
)

// validateEnv runs additional validations on the environment variables.
// validateEnv checks the validity of environment variables in the Env struct, including listen host, port, and required OIDC parameters if OIDC is enabled.
// It returns an error describing the first validation failure encountered, or nil if all checks pass.
func validateEnv(env Env) error {
if !validate.ListenHost(env.PBW_LISTEN_HOST) {
return fmt.Errorf("invalid listen address %s", env.PBW_LISTEN_HOST)
Expand All @@ -16,5 +17,21 @@ func validateEnv(env Env) error {
return fmt.Errorf("invalid listen port %s, valid values are 1-65535", env.PBW_LISTEN_PORT)
}

// Validate OIDC configuration if enabled
if env.PBW_OIDC_ENABLED {
if env.PBW_OIDC_ISSUER_URL == "" {
return fmt.Errorf("PBW_OIDC_ISSUER_URL is required when OIDC is enabled")
}
if env.PBW_OIDC_CLIENT_ID == "" {
return fmt.Errorf("PBW_OIDC_CLIENT_ID is required when OIDC is enabled")
}
if env.PBW_OIDC_CLIENT_SECRET == "" {
return fmt.Errorf("PBW_OIDC_CLIENT_SECRET is required when OIDC is enabled")
}
if env.PBW_OIDC_REDIRECT_URL == "" {
return fmt.Errorf("PBW_OIDC_REDIRECT_URL is required when OIDC is enabled")
}
}

return nil
}
194 changes: 194 additions & 0 deletions internal/service/oidc/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package oidc

import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"strings"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/eduardolat/pgbackweb/internal/config"
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
"golang.org/x/oauth2"
)

// Custom error types for better error handling
var (
ErrEmailAlreadyExists = errors.New("email already exists with different authentication method")
ErrOIDCNotEnabled = errors.New("OIDC is not enabled")
ErrInvalidToken = errors.New("invalid or expired token")
ErrMissingClaims = errors.New("required user information missing from OIDC claims")
)

type Service struct {
env config.Env
dbgen *dbgen.Queries
provider *oidc.Provider
config oauth2.Config
}

type UserInfo struct {
Email string
Name string
Username string
Subject string
}

// New initializes and returns a new OIDC Service using the provided environment configuration and database queries.
// If OIDC is disabled in the environment, returns a Service with minimal setup.
// Returns an error if the OIDC provider cannot be created.
func New(env config.Env, dbgen *dbgen.Queries) (*Service, error) {
if !env.PBW_OIDC_ENABLED {
return &Service{env: env, dbgen: dbgen}, nil
}

ctx := context.Background()
provider, err := oidc.NewProvider(ctx, env.PBW_OIDC_ISSUER_URL)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC provider: %w", err)
}

scopes := strings.Split(env.PBW_OIDC_SCOPES, " ")
config := oauth2.Config{
ClientID: env.PBW_OIDC_CLIENT_ID,
ClientSecret: env.PBW_OIDC_CLIENT_SECRET,
RedirectURL: env.PBW_OIDC_REDIRECT_URL,
Endpoint: provider.Endpoint(),
Scopes: scopes,
}

return &Service{
env: env,
dbgen: dbgen,
provider: provider,
config: config,
}, nil
}

func (s *Service) IsEnabled() bool {
return s.env.PBW_OIDC_ENABLED
}

func (s *Service) GetAuthURL(state string) string {
if !s.IsEnabled() {
return ""
}
return s.config.AuthCodeURL(state)
}

func (s *Service) GenerateState() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}

func (s *Service) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) {
if !s.IsEnabled() {
return nil, ErrOIDCNotEnabled
}

token, err := s.config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}

rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("no id_token field in oauth2 token")
}

verifier := s.provider.Verifier(&oidc.Config{ClientID: s.env.PBW_OIDC_CLIENT_ID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}

claims := make(map[string]interface{})
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse claims: %w", err)
}

userInfo := &UserInfo{
Subject: idToken.Subject,
}

// Extract email
if email, ok := claims[s.env.PBW_OIDC_EMAIL_CLAIM].(string); ok {
userInfo.Email = strings.ToLower(email)
}

// Extract name
if name, ok := claims[s.env.PBW_OIDC_NAME_CLAIM].(string); ok {
userInfo.Name = name
}

// Extract username
if username, ok := claims[s.env.PBW_OIDC_USERNAME_CLAIM].(string); ok {
userInfo.Username = username
}

// Fallback to email as username if username not provided
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}

// Fallback to username as name if name not provided
if userInfo.Name == "" && userInfo.Username != "" {
userInfo.Name = userInfo.Username
}

if userInfo.Email == "" || userInfo.Name == "" || userInfo.Subject == "" {
return nil, ErrMissingClaims
}

return userInfo, nil
}

func (s *Service) CreateOrUpdateUser(ctx context.Context, userInfo *UserInfo) (*dbgen.User, error) {
// Try to get existing OIDC user
_, err := s.dbgen.OIDCServiceGetUserByOIDC(ctx, dbgen.OIDCServiceGetUserByOIDCParams{
OidcProvider: sql.NullString{String: "oidc", Valid: true},
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
})

if err == nil {
// OIDC user exists, update their information
user, err := s.dbgen.OIDCServiceUpdateUser(ctx, dbgen.OIDCServiceUpdateUserParams{
Name: userInfo.Name,
Email: userInfo.Email,
OidcProvider: sql.NullString{String: "oidc", Valid: true},
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
})
if err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
return &user, nil
}

// OIDC user doesn't exist, check if regular user with same email exists
_, err = s.dbgen.AuthServiceLoginGetUserByEmail(ctx, strings.ToLower(userInfo.Email))
if err == nil {
// Regular user with same email exists - we cannot create OIDC user
// This prevents account takeover and maintains data integrity
return nil, ErrEmailAlreadyExists
}

// No existing user, create new OIDC user
user, err := s.dbgen.OIDCServiceCreateUser(ctx, dbgen.OIDCServiceCreateUserParams{
Name: userInfo.Name,
Email: userInfo.Email,
OidcProvider: sql.NullString{String: "oidc", Valid: true},
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
})
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}

return &user, nil
}
13 changes: 11 additions & 2 deletions internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/eduardolat/pgbackweb/internal/service/databases"
"github.com/eduardolat/pgbackweb/internal/service/destinations"
"github.com/eduardolat/pgbackweb/internal/service/executions"
"github.com/eduardolat/pgbackweb/internal/service/oidc"
"github.com/eduardolat/pgbackweb/internal/service/restorations"
"github.com/eduardolat/pgbackweb/internal/service/users"
"github.com/eduardolat/pgbackweb/internal/service/webhooks"
Expand All @@ -21,17 +22,24 @@ type Service struct {
DatabasesService *databases.Service
DestinationsService *destinations.Service
ExecutionsService *executions.Service
OIDCService *oidc.Service
UsersService *users.Service
RestorationsService *restorations.Service
WebhooksService *webhooks.Service
}

// New constructs and initializes a Service instance with all component services.
// Returns the assembled Service or an error if OIDC service initialization fails.
func New(
env config.Env, dbgen *dbgen.Queries,
cr *cron.Cron, ints *integration.Integration,
) *Service {
) (*Service, error) {
webhooksService := webhooks.New(dbgen)
authService := auth.New(env, dbgen)
oidcService, err := oidc.New(env, dbgen)
if err != nil {
return nil, err
}
databasesService := databases.New(env, dbgen, ints, webhooksService)
destinationsService := destinations.New(env, dbgen, ints, webhooksService)
executionsService := executions.New(env, dbgen, ints, webhooksService)
Expand All @@ -47,8 +55,9 @@ func New(
DatabasesService: databasesService,
DestinationsService: destinationsService,
ExecutionsService: executionsService,
OIDCService: oidcService,
UsersService: usersService,
RestorationsService: restorationsService,
WebhooksService: webhooksService,
}
}, nil
}
6 changes: 6 additions & 0 deletions internal/service/users/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ type Service struct {
dbgen *dbgen.Queries
}

// New creates and returns a new Service instance using the provided database queries handler.
func New(dbgen *dbgen.Queries) *Service {
return &Service{
dbgen: dbgen,
}
}

// IsOIDCUser checks if a user is authenticated via OIDC
func (s *Service) IsOIDCUser(user dbgen.User) bool {
return user.OidcProvider.Valid && user.OidcSubject.Valid
}
Loading
Loading