Skip to content

Commit 958f098

Browse files
📝 Add docstrings to feat/add-oidc-auth
Docstrings generation was requested by @eduardolat. * #126 (comment) 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`
1 parent 8054b12 commit 958f098

10 files changed

Lines changed: 587 additions & 65 deletions

File tree

cmd/app/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/labstack/echo/v4"
1313
)
1414

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

4041
ints := integration.New()
41-
servs := service.New(env, dbgen, cr, ints)
42+
servs, err := service.New(env, dbgen, cr, ints)
43+
if err != nil {
44+
logger.FatalError("error initializing services", logger.KV{"error": err})
45+
}
4246
initSchedule(cr, servs)
4347

4448
app := echo.New()

cmd/changepw/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import (
1313
"github.com/google/uuid"
1414
)
1515

16+
// main runs a command-line utility to reset a user's password in the PostgreSQL database.
17+
// It prompts for a user's email, verifies the user exists, generates a new random password,
18+
// updates the password in the database, and displays the new password to the operator.
1619
func main() {
1720
env, err := config.GetEnv()
1821
if err != nil {
@@ -60,7 +63,7 @@ func main() {
6063
err = dbg.UsersServiceChangePassword(
6164
context.Background(), dbgen.UsersServiceChangePasswordParams{
6265
ID: userID,
63-
Password: hashedPassword,
66+
Password: sql.NullString{String: hashedPassword, Valid: true},
6467
},
6568
)
6669
if err != nil {

internal/config/env_validate.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import (
66
"github.com/eduardolat/pgbackweb/internal/validate"
77
)
88

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

20+
// Validate OIDC configuration if enabled
21+
if env.PBW_OIDC_ENABLED {
22+
if env.PBW_OIDC_ISSUER_URL == "" {
23+
return fmt.Errorf("PBW_OIDC_ISSUER_URL is required when OIDC is enabled")
24+
}
25+
if env.PBW_OIDC_CLIENT_ID == "" {
26+
return fmt.Errorf("PBW_OIDC_CLIENT_ID is required when OIDC is enabled")
27+
}
28+
if env.PBW_OIDC_CLIENT_SECRET == "" {
29+
return fmt.Errorf("PBW_OIDC_CLIENT_SECRET is required when OIDC is enabled")
30+
}
31+
if env.PBW_OIDC_REDIRECT_URL == "" {
32+
return fmt.Errorf("PBW_OIDC_REDIRECT_URL is required when OIDC is enabled")
33+
}
34+
}
35+
1936
return nil
2037
}

internal/service/oidc/oidc.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package oidc
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"database/sql"
7+
"encoding/base64"
8+
"errors"
9+
"fmt"
10+
"strings"
11+
12+
"github.com/coreos/go-oidc/v3/oidc"
13+
"github.com/eduardolat/pgbackweb/internal/config"
14+
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
15+
"golang.org/x/oauth2"
16+
)
17+
18+
// Custom error types for better error handling
19+
var (
20+
ErrEmailAlreadyExists = errors.New("email already exists with different authentication method")
21+
ErrOIDCNotEnabled = errors.New("OIDC is not enabled")
22+
ErrInvalidToken = errors.New("invalid or expired token")
23+
ErrMissingClaims = errors.New("required user information missing from OIDC claims")
24+
)
25+
26+
type Service struct {
27+
env config.Env
28+
dbgen *dbgen.Queries
29+
provider *oidc.Provider
30+
config oauth2.Config
31+
}
32+
33+
type UserInfo struct {
34+
Email string
35+
Name string
36+
Username string
37+
Subject string
38+
}
39+
40+
// New initializes and returns a new OIDC Service using the provided environment configuration and database queries.
41+
// If OIDC is disabled in the environment, returns a Service with minimal setup.
42+
// Returns an error if the OIDC provider cannot be created.
43+
func New(env config.Env, dbgen *dbgen.Queries) (*Service, error) {
44+
if !env.PBW_OIDC_ENABLED {
45+
return &Service{env: env, dbgen: dbgen}, nil
46+
}
47+
48+
ctx := context.Background()
49+
provider, err := oidc.NewProvider(ctx, env.PBW_OIDC_ISSUER_URL)
50+
if err != nil {
51+
return nil, fmt.Errorf("failed to create OIDC provider: %w", err)
52+
}
53+
54+
scopes := strings.Split(env.PBW_OIDC_SCOPES, " ")
55+
config := oauth2.Config{
56+
ClientID: env.PBW_OIDC_CLIENT_ID,
57+
ClientSecret: env.PBW_OIDC_CLIENT_SECRET,
58+
RedirectURL: env.PBW_OIDC_REDIRECT_URL,
59+
Endpoint: provider.Endpoint(),
60+
Scopes: scopes,
61+
}
62+
63+
return &Service{
64+
env: env,
65+
dbgen: dbgen,
66+
provider: provider,
67+
config: config,
68+
}, nil
69+
}
70+
71+
func (s *Service) IsEnabled() bool {
72+
return s.env.PBW_OIDC_ENABLED
73+
}
74+
75+
func (s *Service) GetAuthURL(state string) string {
76+
if !s.IsEnabled() {
77+
return ""
78+
}
79+
return s.config.AuthCodeURL(state)
80+
}
81+
82+
func (s *Service) GenerateState() (string, error) {
83+
b := make([]byte, 32)
84+
_, err := rand.Read(b)
85+
if err != nil {
86+
return "", err
87+
}
88+
return base64.URLEncoding.EncodeToString(b), nil
89+
}
90+
91+
func (s *Service) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) {
92+
if !s.IsEnabled() {
93+
return nil, ErrOIDCNotEnabled
94+
}
95+
96+
token, err := s.config.Exchange(ctx, code)
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to exchange code: %w", err)
99+
}
100+
101+
rawIDToken, ok := token.Extra("id_token").(string)
102+
if !ok {
103+
return nil, fmt.Errorf("no id_token field in oauth2 token")
104+
}
105+
106+
verifier := s.provider.Verifier(&oidc.Config{ClientID: s.env.PBW_OIDC_CLIENT_ID})
107+
idToken, err := verifier.Verify(ctx, rawIDToken)
108+
if err != nil {
109+
return nil, fmt.Errorf("failed to verify ID token: %w", err)
110+
}
111+
112+
claims := make(map[string]interface{})
113+
if err := idToken.Claims(&claims); err != nil {
114+
return nil, fmt.Errorf("failed to parse claims: %w", err)
115+
}
116+
117+
userInfo := &UserInfo{
118+
Subject: idToken.Subject,
119+
}
120+
121+
// Extract email
122+
if email, ok := claims[s.env.PBW_OIDC_EMAIL_CLAIM].(string); ok {
123+
userInfo.Email = strings.ToLower(email)
124+
}
125+
126+
// Extract name
127+
if name, ok := claims[s.env.PBW_OIDC_NAME_CLAIM].(string); ok {
128+
userInfo.Name = name
129+
}
130+
131+
// Extract username
132+
if username, ok := claims[s.env.PBW_OIDC_USERNAME_CLAIM].(string); ok {
133+
userInfo.Username = username
134+
}
135+
136+
// Fallback to email as username if username not provided
137+
if userInfo.Username == "" && userInfo.Email != "" {
138+
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
139+
}
140+
141+
// Fallback to username as name if name not provided
142+
if userInfo.Name == "" && userInfo.Username != "" {
143+
userInfo.Name = userInfo.Username
144+
}
145+
146+
if userInfo.Email == "" || userInfo.Name == "" || userInfo.Subject == "" {
147+
return nil, ErrMissingClaims
148+
}
149+
150+
return userInfo, nil
151+
}
152+
153+
func (s *Service) CreateOrUpdateUser(ctx context.Context, userInfo *UserInfo) (*dbgen.User, error) {
154+
// Try to get existing OIDC user
155+
_, err := s.dbgen.OIDCServiceGetUserByOIDC(ctx, dbgen.OIDCServiceGetUserByOIDCParams{
156+
OidcProvider: sql.NullString{String: "oidc", Valid: true},
157+
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
158+
})
159+
160+
if err == nil {
161+
// OIDC user exists, update their information
162+
user, err := s.dbgen.OIDCServiceUpdateUser(ctx, dbgen.OIDCServiceUpdateUserParams{
163+
Name: userInfo.Name,
164+
Email: userInfo.Email,
165+
OidcProvider: sql.NullString{String: "oidc", Valid: true},
166+
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
167+
})
168+
if err != nil {
169+
return nil, fmt.Errorf("failed to update user: %w", err)
170+
}
171+
return &user, nil
172+
}
173+
174+
// OIDC user doesn't exist, check if regular user with same email exists
175+
_, err = s.dbgen.AuthServiceLoginGetUserByEmail(ctx, strings.ToLower(userInfo.Email))
176+
if err == nil {
177+
// Regular user with same email exists - we cannot create OIDC user
178+
// This prevents account takeover and maintains data integrity
179+
return nil, ErrEmailAlreadyExists
180+
}
181+
182+
// No existing user, create new OIDC user
183+
user, err := s.dbgen.OIDCServiceCreateUser(ctx, dbgen.OIDCServiceCreateUserParams{
184+
Name: userInfo.Name,
185+
Email: userInfo.Email,
186+
OidcProvider: sql.NullString{String: "oidc", Valid: true},
187+
OidcSubject: sql.NullString{String: userInfo.Subject, Valid: true},
188+
})
189+
if err != nil {
190+
return nil, fmt.Errorf("failed to create user: %w", err)
191+
}
192+
193+
return &user, nil
194+
}

internal/service/service.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/eduardolat/pgbackweb/internal/service/databases"
1111
"github.com/eduardolat/pgbackweb/internal/service/destinations"
1212
"github.com/eduardolat/pgbackweb/internal/service/executions"
13+
"github.com/eduardolat/pgbackweb/internal/service/oidc"
1314
"github.com/eduardolat/pgbackweb/internal/service/restorations"
1415
"github.com/eduardolat/pgbackweb/internal/service/users"
1516
"github.com/eduardolat/pgbackweb/internal/service/webhooks"
@@ -21,17 +22,24 @@ type Service struct {
2122
DatabasesService *databases.Service
2223
DestinationsService *destinations.Service
2324
ExecutionsService *executions.Service
25+
OIDCService *oidc.Service
2426
UsersService *users.Service
2527
RestorationsService *restorations.Service
2628
WebhooksService *webhooks.Service
2729
}
2830

31+
// New constructs and initializes a Service instance with all component services.
32+
// Returns the assembled Service or an error if OIDC service initialization fails.
2933
func New(
3034
env config.Env, dbgen *dbgen.Queries,
3135
cr *cron.Cron, ints *integration.Integration,
32-
) *Service {
36+
) (*Service, error) {
3337
webhooksService := webhooks.New(dbgen)
3438
authService := auth.New(env, dbgen)
39+
oidcService, err := oidc.New(env, dbgen)
40+
if err != nil {
41+
return nil, err
42+
}
3543
databasesService := databases.New(env, dbgen, ints, webhooksService)
3644
destinationsService := destinations.New(env, dbgen, ints, webhooksService)
3745
executionsService := executions.New(env, dbgen, ints, webhooksService)
@@ -47,8 +55,9 @@ func New(
4755
DatabasesService: databasesService,
4856
DestinationsService: destinationsService,
4957
ExecutionsService: executionsService,
58+
OIDCService: oidcService,
5059
UsersService: usersService,
5160
RestorationsService: restorationsService,
5261
WebhooksService: webhooksService,
53-
}
62+
}, nil
5463
}

internal/service/users/users.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ type Service struct {
66
dbgen *dbgen.Queries
77
}
88

9+
// New creates and returns a new Service instance using the provided database queries handler.
910
func New(dbgen *dbgen.Queries) *Service {
1011
return &Service{
1112
dbgen: dbgen,
1213
}
1314
}
15+
16+
// IsOIDCUser checks if a user is authenticated via OIDC
17+
func (s *Service) IsOIDCUser(user dbgen.User) bool {
18+
return user.OidcProvider.Valid && user.OidcSubject.Valid
19+
}

0 commit comments

Comments
 (0)