From 958f098def3536f15e9a4ec46520bae248d1bad5 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:21:29 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`feat/ad?= =?UTF-8?q?d-oidc-auth`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @eduardolat. * https://github.com/eduardolat/pgbackweb/pull/126#issuecomment-3089272934 The following files were modified: * `cmd/app/main.go` * `cmd/changepw/main.go` * `internal/config/env_validate.go` * `internal/service/oidc/oidc.go` * `internal/service/service.go` * `internal/service/users/users.go` * `internal/view/web/auth/login.go` * `internal/view/web/auth/router.go` * `internal/view/web/dashboard/profile/update_user.go` * `internal/view/web/oidc/router.go` --- cmd/app/main.go | 6 +- cmd/changepw/main.go | 5 +- internal/config/env_validate.go | 19 +- internal/service/oidc/oidc.go | 194 ++++++++++++++++++ internal/service/service.go | 13 +- internal/service/users/users.go | 6 + internal/view/web/auth/login.go | 61 +++++- internal/view/web/auth/router.go | 5 + .../view/web/dashboard/profile/update_user.go | 168 +++++++++------ internal/view/web/oidc/router.go | 177 ++++++++++++++++ 10 files changed, 588 insertions(+), 66 deletions(-) create mode 100644 internal/service/oidc/oidc.go create mode 100644 internal/view/web/oidc/router.go diff --git a/cmd/app/main.go b/cmd/app/main.go index 632deec..7b6b012 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -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 { @@ -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() diff --git a/cmd/changepw/main.go b/cmd/changepw/main.go index b6c1d8c..713136c 100644 --- a/cmd/changepw/main.go +++ b/cmd/changepw/main.go @@ -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 { @@ -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 { diff --git a/internal/config/env_validate.go b/internal/config/env_validate.go index b538a8b..aa39671 100644 --- a/internal/config/env_validate.go +++ b/internal/config/env_validate.go @@ -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) @@ -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 } diff --git a/internal/service/oidc/oidc.go b/internal/service/oidc/oidc.go new file mode 100644 index 0000000..4e1b9bc --- /dev/null +++ b/internal/service/oidc/oidc.go @@ -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 +} diff --git a/internal/service/service.go b/internal/service/service.go index 0500c44..b09bdd5 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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" @@ -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) @@ -47,8 +55,9 @@ func New( DatabasesService: databasesService, DestinationsService: destinationsService, ExecutionsService: executionsService, + OIDCService: oidcService, UsersService: usersService, RestorationsService: restorationsService, WebhooksService: webhooksService, - } + }, nil } diff --git a/internal/service/users/users.go b/internal/service/users/users.go index 7835b36..fc9e405 100644 --- a/internal/service/users/users.go +++ b/internal/service/users/users.go @@ -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 +} diff --git a/internal/view/web/auth/login.go b/internal/view/web/auth/login.go index bde5aab..e9ab098 100644 --- a/internal/view/web/auth/login.go +++ b/internal/view/web/auth/login.go @@ -2,6 +2,7 @@ package auth import ( "net/http" + "net/url" "github.com/eduardolat/pgbackweb/internal/logger" "github.com/eduardolat/pgbackweb/internal/util/echoutil" @@ -31,13 +32,67 @@ func (h *handlers) loginPageHandler(c echo.Context) error { return c.Redirect(http.StatusFound, "/auth/create-first-user") } - return echoutil.RenderNodx(c, http.StatusOK, loginPage()) + // Check for error message in URL parameters + errorMsg := c.QueryParam("error") + if errorMsg != "" { + // URL decode the error message to handle encoded characters + if decodedMsg, err := url.QueryUnescape(errorMsg); err == nil { + errorMsg = decodedMsg + } + } + + return echoutil.RenderNodx(c, http.StatusOK, loginPage(h.servs.OIDCService.IsEnabled(), errorMsg)) } -func loginPage() nodx.Node { +// loginPage constructs the login page UI as a NodX node tree, optionally displaying an error message and an OIDC login option. +// +// If an error message is provided, a toast notification is triggered on page load. If OIDC login is enabled, a button for SSO login and a divider are included before the traditional email/password login form. +// +// Returns the complete login page node wrapped in the authentication layout. +func loginPage(oidcEnabled bool, errorMsg string) nodx.Node { content := []nodx.Node{ component.H1Text("Login"), + } + // Add JavaScript to show toast notification if error message is present + if errorMsg != "" { + // Use a data attribute to safely pass the error message to JavaScript + content = append(content, + nodx.Script( + nodx.Attr("data-error-message", errorMsg), + nodx.Text(` + (function() { + const errorMsg = document.currentScript.dataset.errorMessage; + if (errorMsg) { + window.toaster.error(errorMsg); + } + })(); + `), + ), + ) + } + + // Add OIDC login option if enabled + if oidcEnabled { + content = append(content, + nodx.Div( + nodx.Class("mt-4"), + nodx.A( + nodx.Href("/auth/oidc/login"), + nodx.Class("btn btn-outline btn-block"), + component.SpanText("Login with SSO"), + lucide.ExternalLink(), + ), + ), + nodx.Div( + nodx.Class("divider"), + nodx.Text("OR"), + ), + ) + } + + // Traditional login form + content = append(content, nodx.FormEl( htmx.HxPost("/auth/login"), htmx.HxDisabledELT("find button"), @@ -72,7 +127,7 @@ func loginPage() nodx.Node { ), ), ), - } + ) return layout.Auth(layout.AuthParams{ Title: "Login", diff --git a/internal/view/web/auth/router.go b/internal/view/web/auth/router.go index f04fb79..a823a86 100644 --- a/internal/view/web/auth/router.go +++ b/internal/view/web/auth/router.go @@ -5,6 +5,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service" "github.com/eduardolat/pgbackweb/internal/view/middleware" + "github.com/eduardolat/pgbackweb/internal/view/web/oidc" "github.com/labstack/echo/v4" ) @@ -12,6 +13,7 @@ type handlers struct { servs *service.Service } +// MountRouter registers authentication-related HTTP routes on the provided Echo group, applying appropriate middleware for authenticated and unauthenticated access. It also mounts OpenID Connect (OIDC) routes on the same group. func MountRouter( parent *echo.Group, mids *middleware.Middleware, servs *service.Service, ) { @@ -31,4 +33,7 @@ func MountRouter( requireAuth.POST("/logout", h.logoutHandler) requireAuth.POST("/logout-all", h.logoutAllSessionsHandler) + + // Mount OIDC routes + oidc.MountRouter(parent, mids, servs) } diff --git a/internal/view/web/dashboard/profile/update_user.go b/internal/view/web/dashboard/profile/update_user.go index 2e4f84b..d934d49 100644 --- a/internal/view/web/dashboard/profile/update_user.go +++ b/internal/view/web/dashboard/profile/update_user.go @@ -18,6 +18,14 @@ func (h *handlers) updateUserHandler(c echo.Context) error { reqCtx := reqctx.GetCtx(c) ctx := c.Request().Context() + // Check if user is OIDC user + isOIDCUser := reqCtx.User.OidcProvider.Valid && reqCtx.User.OidcSubject.Valid + + // Block profile updates for OIDC users + if isOIDCUser { + return respondhtmx.ToastError(c, "Profile updates are not allowed for SSO users. Your profile is managed by your identity provider.") + } + var formData struct { Name string `form:"name" validate:"required"` Email string `form:"email" validate:"required,email"` @@ -27,15 +35,18 @@ func (h *handlers) updateUserHandler(c echo.Context) error { if err := c.Bind(&formData); err != nil { return respondhtmx.ToastError(c, err.Error()) } + if err := validate.Struct(&formData); err != nil { return respondhtmx.ToastError(c, err.Error()) } + passwordUpdate := sql.NullString{String: formData.Password, Valid: formData.Password != ""} + _, err := h.servs.UsersService.UpdateUser(ctx, dbgen.UsersServiceUpdateUserParams{ ID: reqCtx.User.ID, Name: sql.NullString{String: formData.Name, Valid: true}, Email: sql.NullString{String: formData.Email, Valid: true}, - Password: sql.NullString{String: formData.Password, Valid: formData.Password != ""}, + Password: passwordUpdate, }) if err != nil { return respondhtmx.ToastError(c, err.Error()) @@ -44,67 +55,108 @@ func (h *handlers) updateUserHandler(c echo.Context) error { return respondhtmx.ToastSuccess(c, "Profile updated") } +// updateUserForm returns a profile update form UI node tailored to the user's authentication type. +// For OIDC (SSO) users, the form disables editing and displays an informational alert; for regular users, it provides editable fields for name, email, and password updates. func updateUserForm(user dbgen.User) nodx.Node { + // Check if user is OIDC user + isOIDCUser := user.OidcProvider.Valid && user.OidcSubject.Valid + + // Build form fields + formFields := []nodx.Node{ + component.H2Text("Update profile"), + + // Show different message for OIDC users + nodx.If(isOIDCUser, + nodx.Div( + nodx.Class("alert alert-info mb-4"), + nodx.Div( + nodx.Class("flex items-center space-x-2"), + lucide.Info(), + nodx.Div( + nodx.Class("text-sm"), + nodx.Text("You are logged in via SSO. Your profile information is managed by your identity provider and cannot be changed here."), + ), + ), + ), + ), + + component.InputControl(component.InputControlParams{ + Name: "name", + Label: "Full name", + Placeholder: "Your full name", + Required: !isOIDCUser, // Don't require if disabled + Type: component.InputTypeText, + AutoComplete: "name", + Children: []nodx.Node{ + nodx.Value(user.Name), + nodx.If(isOIDCUser, nodx.Disabled("")), + nodx.If(isOIDCUser, nodx.Readonly("")), + }, + }), + + component.InputControl(component.InputControlParams{ + Name: "email", + Label: "Email", + Placeholder: "Your email", + Required: !isOIDCUser, // Don't require if disabled + AutoComplete: "email", + Type: component.InputTypeEmail, + Children: []nodx.Node{ + nodx.Value(user.Email), + nodx.If(isOIDCUser, nodx.Disabled("")), + nodx.If(isOIDCUser, nodx.Readonly("")), + }, + }), + } + + // Add password fields only for non-OIDC users + if !isOIDCUser { + formFields = append(formFields, + component.InputControl(component.InputControlParams{ + Name: "password", + Label: "Change password", + Placeholder: "New password", + AutoComplete: "new-password", + Type: component.InputTypePassword, + HelpText: "Leave empty to keep your current password", + }), + + component.InputControl(component.InputControlParams{ + Name: "password_confirmation", + Label: "Confirm password", + Placeholder: "Confirm new password", + AutoComplete: "new-password", + Type: component.InputTypePassword, + }), + ) + } + + // Add submit button (disabled for OIDC users) + formFields = append(formFields, + nodx.Div( + nodx.Class("flex justify-end items-center space-x-2 pt-2"), + component.HxLoadingMd(), + nodx.Button( + nodx.ClassMap{ + "btn btn-primary": !isOIDCUser, + "btn btn-disabled": isOIDCUser, + }, + nodx.Type("submit"), + nodx.If(isOIDCUser, nodx.Disabled("")), + component.SpanText("Save changes"), + lucide.Save(), + ), + ), + ) + return component.CardBox(component.CardBoxParams{ Children: []nodx.Node{ nodx.FormEl( - htmx.HxPost("/dashboard/profile"), - htmx.HxDisabledELT("find button"), - nodx.Class("space-y-2"), - - component.H2Text("Update profile"), - - component.InputControl(component.InputControlParams{ - Name: "name", - Label: "Full name", - Placeholder: "Your full name", - Required: true, - Type: component.InputTypeText, - AutoComplete: "name", - Children: []nodx.Node{ - nodx.Value(user.Name), - }, - }), - - component.InputControl(component.InputControlParams{ - Name: "email", - Label: "Email", - Placeholder: "Your email", - Required: true, - AutoComplete: "email", - Type: component.InputTypeEmail, - Children: []nodx.Node{ - nodx.Value(user.Email), - }, - }), - - component.InputControl(component.InputControlParams{ - Name: "password", - Label: "Change password", - Placeholder: "New password", - AutoComplete: "new-password", - Type: component.InputTypePassword, - HelpText: "Leave empty to keep your current password", - }), - - component.InputControl(component.InputControlParams{ - Name: "password_confirmation", - Label: "Confirm password", - Placeholder: "Confirm new password", - AutoComplete: "new-password", - Type: component.InputTypePassword, - }), - - nodx.Div( - nodx.Class("flex justify-end items-center space-x-2 pt-2"), - component.HxLoadingMd(), - nodx.Button( - nodx.Class("btn btn-primary"), - nodx.Type("submit"), - component.SpanText("Save changes"), - lucide.Save(), - ), - ), + append([]nodx.Node{ + nodx.If(!isOIDCUser, htmx.HxPost("/dashboard/profile")), + nodx.If(!isOIDCUser, htmx.HxDisabledELT("find button")), + nodx.Class("space-y-2"), + }, formFields...)..., ), }, }) diff --git a/internal/view/web/oidc/router.go b/internal/view/web/oidc/router.go new file mode 100644 index 0000000..271df4f --- /dev/null +++ b/internal/view/web/oidc/router.go @@ -0,0 +1,177 @@ +package oidc + +import ( + "errors" + "net/http" + "net/url" + + "github.com/eduardolat/pgbackweb/internal/logger" + "github.com/eduardolat/pgbackweb/internal/service" + "github.com/eduardolat/pgbackweb/internal/service/oidc" + "github.com/eduardolat/pgbackweb/internal/view/middleware" + "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" + "github.com/labstack/echo/v4" +) + +type handlers struct { + servs *service.Service +} + +// MountRouter registers OIDC authentication routes on the provided Echo group if OIDC is enabled. +// It sets up login and callback endpoints under a middleware group that requires no prior authentication. +func MountRouter( + parent *echo.Group, mids *middleware.Middleware, servs *service.Service, +) { + if !servs.OIDCService.IsEnabled() { + return + } + + h := handlers{servs: servs} + + requireNoAuth := parent.Group("", mids.RequireNoAuth) + + requireNoAuth.GET("/oidc/login", h.oidcLoginHandler) + requireNoAuth.GET("/oidc/callback", h.oidcCallbackHandler) +} + +func (h *handlers) oidcLoginHandler(c echo.Context) error { + state, err := h.servs.OIDCService.GenerateState() + if err != nil { + logger.Error("OIDC: failed to generate state", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + "error": err, + }) + return handleOIDCError(c, "OIDC: Unable to initiate login") + } + + // Store state in session/cookie for verification + c.SetCookie(&http.Cookie{ + Name: "oidc_state", + Value: state, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 300, // 5 minutes + Path: "/", + }) + + authURL := h.servs.OIDCService.GetAuthURL(state) + return c.Redirect(http.StatusFound, authURL) +} + +func (h *handlers) oidcCallbackHandler(c echo.Context) error { + ctx := c.Request().Context() + + // Verify state parameter + state := c.QueryParam("state") + stateCookie, err := c.Cookie("oidc_state") + if err != nil || stateCookie == nil || stateCookie.Value != state { + expectedValue := "" + if stateCookie != nil { + expectedValue = stateCookie.Value + } + logger.Error("OIDC: state mismatch", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + "state": state, + "expected": expectedValue, + }) + return handleOIDCError(c, "OIDC: Invalid state parameter") + } + + // Clear the state cookie + c.SetCookie(&http.Cookie{ + Name: "oidc_state", + Value: "", + HttpOnly: true, + MaxAge: -1, + Path: "/", + }) + + // Check for error from OIDC provider + if errorParam := c.QueryParam("error"); errorParam != "" { + errorDesc := c.QueryParam("error_description") + errorMsg := "OIDC: Login failed" + if errorDesc != "" { + errorMsg = "OIDC: " + errorDesc + } else { + errorMsg = "OIDC: " + errorParam + } + logger.Error("OIDC provider returned error", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + "error": errorParam, + "error_description": errorDesc, + }) + return handleOIDCError(c, errorMsg) + } + + code := c.QueryParam("code") + if code == "" { + return handleOIDCError(c, "OIDC: Missing authorization code") + } + + // Exchange code for user info + userInfo, err := h.servs.OIDCService.ExchangeCode(ctx, code) + if err != nil { + logger.Error("failed to exchange OIDC code", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + "error": err, + }) + return handleOIDCError(c, "OIDC: Unable to authenticate with provider") + } + + // Create or update user + user, err := h.servs.OIDCService.CreateOrUpdateUser(ctx, userInfo) + if err != nil { + errorMsg := "OIDC: Unable to create user account" + if errors.Is(err, oidc.ErrEmailAlreadyExists) { + errorMsg = "OIDC: Email already exists. Use regular login." + } + logger.Error("failed to create/update OIDC user", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + "email": userInfo.Email, + "error": err, + }) + return handleOIDCError(c, errorMsg) + } + + logger.Info("OIDC: authentication successful", logger.KV{ + "email": userInfo.Email, + "name": userInfo.Name, + "subject": userInfo.Subject, + "user_id": user.ID, + }) + + // Create session for the user + session, err := h.servs.AuthService.LoginOIDC( + ctx, user.ID, c.RealIP(), c.Request().UserAgent(), + ) + if err != nil { + logger.Error("OIDC: failed to create session for user", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + "user_id": user.ID, + "error": err, + }) + return handleOIDCError(c, "OIDC: Unable to create session") + } + + // Set session cookie and redirect to dashboard + h.servs.AuthService.SetSessionCookie(c, session.DecryptedToken) + return c.Redirect(http.StatusSeeOther, "/dashboard") +} + +// handleOIDCError sends an OIDC error response as an HTMX toast if the request is from HTMX, or redirects to the login page with an error message for regular browser requests. +func handleOIDCError(c echo.Context, message string) error { + // Check if it's an HTMX request + if c.Request().Header.Get("HX-Request") != "" { + return respondhtmx.ToastError(c, message) + } + + // For regular browser requests, redirect to login with error parameter + return c.Redirect(http.StatusSeeOther, "/auth/login?error="+url.QueryEscape(message)) +}