📝 Add docstrings to feat/add-oidc-auth

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`
This commit is contained in:
coderabbitai[bot]
2025-07-19 15:21:29 +00:00
committed by GitHub
parent 8054b127e7
commit 958f098def
10 changed files with 588 additions and 66 deletions
+5 -1
View File
@@ -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()
+4 -1
View File
@@ -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 {
+18 -1
View File
@@ -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
}
+194
View File
@@ -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
}
+11 -2
View File
@@ -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
}
+6
View File
@@ -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
}
+58 -3
View File
@@ -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",
+5
View File
@@ -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)
}
@@ -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...)...,
),
},
})
+177
View File
@@ -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))
}