• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DigitalTolk / wireguard-ui / 24860158850

23 Apr 2026 09:37PM UTC coverage: 89.866% (-0.2%) from 90.064%
24860158850

Pull #21

github

web-flow
Merge a78b67a93 into 2b36d33b8
Pull Request #21: Remove remember-me

535 of 600 branches covered (89.17%)

Branch coverage included in aggregate %.

4 of 5 new or added lines in 2 files covered. (80.0%)

2 existing lines in 1 file now uncovered.

3216 of 3574 relevant lines covered (89.98%)

44.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.43
/handler/api_v1_oidc.go
1
package handler
2

3
import (
4
        "context"
5
        "fmt"
6
        "net/http"
7
        "time"
8

9
        "github.com/coreos/go-oidc/v3/oidc"
10
        "github.com/gorilla/sessions"
11
        "github.com/labstack/echo-contrib/session"
12
        "github.com/labstack/echo/v4"
13
        "github.com/labstack/gommon/log"
14
        "github.com/rs/xid"
15
        "golang.org/x/oauth2"
16

17
        "github.com/DigitalTolk/wireguard-ui/model"
18
        "github.com/DigitalTolk/wireguard-ui/store"
19
        "github.com/DigitalTolk/wireguard-ui/util"
20
)
21

22
// OIDCProvider holds the OIDC provider and OAuth2 config
23
type OIDCProvider struct {
24
        provider    *oidc.Provider
25
        oauth2Cfg   oauth2.Config
26
        verifier    *oidc.IDTokenVerifier
27
        adminGroups []string
28
}
29

30
// NewOIDCProvider creates a new OIDC provider from configuration
31
func NewOIDCProvider() (*OIDCProvider, error) {
2✔
32
        if util.OIDCIssuerURL == "" || util.OIDCClientID == "" {
3✔
33
                return nil, nil // OIDC not configured
1✔
34
        }
1✔
35

36
        ctx := context.Background()
1✔
37
        provider, err := oidc.NewProvider(ctx, util.OIDCIssuerURL)
1✔
38
        if err != nil {
2✔
39
                return nil, fmt.Errorf("cannot create OIDC provider: %w", err)
1✔
40
        }
1✔
41

42
        scopes := util.OIDCScopes
×
43
        if len(scopes) == 0 {
×
44
                scopes = []string{oidc.ScopeOpenID, "profile", "email"}
×
45
        }
×
46

47
        oauth2Cfg := oauth2.Config{
×
48
                ClientID:     util.OIDCClientID,
×
49
                ClientSecret: util.OIDCClientSecret,
×
50
                RedirectURL:  util.OIDCRedirectURL,
×
51
                Endpoint:     provider.Endpoint(),
×
52
                Scopes:       scopes,
×
53
        }
×
54

×
55
        verifier := provider.Verifier(&oidc.Config{ClientID: util.OIDCClientID})
×
56

×
57
        return &OIDCProvider{
×
58
                provider:    provider,
×
59
                oauth2Cfg:   oauth2Cfg,
×
60
                verifier:    verifier,
×
61
                adminGroups: util.OIDCAdminGroups,
×
62
        }, nil
×
63
}
64

65
// APIStartOIDCLogin initiates the OIDC authorization code flow
66
func APIStartOIDCLogin(oidcProvider *OIDCProvider) echo.HandlerFunc {
2✔
67
        return func(c echo.Context) error {
4✔
68
                if oidcProvider == nil {
3✔
69
                        return apiInternalError(c, "OIDC is not configured")
1✔
70
                }
1✔
71

72
                state := xid.New().String()
1✔
73
                nonce := xid.New().String()
1✔
74

1✔
75
                // store state and nonce in session for validation in callback
1✔
76
                sess, _ := session.Get("session", c)
1✔
77
                sess.Options = &sessions.Options{
1✔
78
                        Path:     util.GetCookiePath(),
1✔
79
                        MaxAge:   300, // 5 minutes for login flow
1✔
80
                        HttpOnly: true,
1✔
81
                        SameSite: http.SameSiteLaxMode,
1✔
82
                }
1✔
83
                sess.Values["oidc_state"] = state
1✔
84
                sess.Values["oidc_nonce"] = nonce
1✔
85
                sess.Save(c.Request(), c.Response())
1✔
86

1✔
87
                authURL := oidcProvider.oauth2Cfg.AuthCodeURL(state, oidc.Nonce(nonce))
1✔
88
                return c.Redirect(http.StatusTemporaryRedirect, authURL)
1✔
89
        }
90
}
91

92
// APIHandleOIDCCallback handles the OIDC callback after user authenticates
93
func APIHandleOIDCCallback(oidcProvider *OIDCProvider, db store.IStore) echo.HandlerFunc {
4✔
94
        return func(c echo.Context) error {
8✔
95
                if oidcProvider == nil {
5✔
96
                        return apiInternalError(c, "OIDC is not configured")
1✔
97
                }
1✔
98

99
                ctx := c.Request().Context()
3✔
100

3✔
101
                // verify state
3✔
102
                sess, _ := session.Get("session", c)
3✔
103
                expectedState, _ := sess.Values["oidc_state"].(string)
3✔
104
                expectedNonce, _ := sess.Values["oidc_nonce"].(string)
3✔
105

3✔
106
                if c.QueryParam("state") != expectedState || expectedState == "" {
4✔
107
                        return apiBadRequest(c, "Invalid state parameter")
1✔
108
                }
1✔
109

110
                // check for error from OIDC provider
111
                if errParam := c.QueryParam("error"); errParam != "" {
3✔
112
                        errDesc := c.QueryParam("error_description")
1✔
113
                        log.Errorf("OIDC error: %s - %s", errParam, errDesc)
1✔
114
                        // return 403 (not 401) to avoid the SPA redirect loop — 401 triggers OIDC login again
1✔
115
                        return apiError(c, http.StatusForbidden, "OIDC_ERROR", fmt.Sprintf("Authentication failed: %s", errDesc))
1✔
116
                }
1✔
117

118
                // exchange code for token
119
                code := c.QueryParam("code")
1✔
120
                token, err := oidcProvider.oauth2Cfg.Exchange(ctx, code)
1✔
121
                if err != nil {
2✔
122
                        log.Errorf("OIDC token exchange failed: %v", err)
1✔
123
                        return apiInternalError(c, "Token exchange failed")
1✔
124
                }
1✔
125

126
                // extract and verify ID token
127
                rawIDToken, ok := token.Extra("id_token").(string)
×
128
                if !ok {
×
129
                        return apiInternalError(c, "No id_token in response")
×
130
                }
×
131

132
                idToken, err := oidcProvider.verifier.Verify(ctx, rawIDToken)
×
133
                if err != nil {
×
134
                        log.Errorf("OIDC token verification failed: %v", err)
×
135
                        return apiInternalError(c, "Token verification failed")
×
136
                }
×
137

138
                // verify nonce
139
                if idToken.Nonce != expectedNonce {
×
140
                        return apiBadRequest(c, "Invalid nonce")
×
141
                }
×
142

143
                // extract claims
144
                var claims struct {
×
145
                        Sub               string   `json:"sub"`
×
146
                        Email             string   `json:"email"`
×
147
                        Name              string   `json:"name"`
×
148
                        PreferredUsername string   `json:"preferred_username"`
×
149
                        Groups            []string `json:"groups"`
×
150
                }
×
151
                if err := idToken.Claims(&claims); err != nil {
×
152
                        return apiInternalError(c, "Cannot read token claims")
×
153
                }
×
154

155
                // determine username (prefer preferred_username, fallback to email, then sub)
156
                username := claims.PreferredUsername
×
157
                if username == "" {
×
158
                        username = claims.Email
×
159
                }
×
160
                if username == "" {
×
161
                        username = claims.Sub
×
162
                }
×
163

164
                // look up or create user
165
                user, err := findOrCreateOIDCUser(db, claims.Sub, username, claims.Email, claims.Name, claims.Groups, oidcProvider.adminGroups)
×
166
                if err != nil {
×
167
                        log.Errorf("OIDC user provisioning failed: %v", err)
×
168
                        return apiInternalError(c, "User provisioning failed")
×
169
                }
×
170

171
                // create session using shared helper (respects SessionMaxDuration config)
NEW
172
                createSession(c, user.Username, user.Admin, util.GetDBUserCRC32(user))
×
173

×
174
                auditLogEvent(c, "user.login", "user", user.Username, map[string]string{"email": user.Email})
×
175
                log.Infof("OIDC login successful for user: %s", user.Username)
×
176

×
177
                // redirect to SPA root
×
178
                return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/")
×
179
        }
180
}
181

182
// findOrCreateOIDCUser looks up a user by OIDC subject, or creates one if auto-provisioning is enabled
183
func findOrCreateOIDCUser(db store.IStore, sub, username, email, displayName string, userGroups, adminGroups []string) (model.User, error) {
8✔
184
        // indexed lookup by oidc_sub
8✔
185
        u, err := db.GetUserByOIDCSub(sub)
8✔
186
        if err == nil {
11✔
187
                // existing user - update claims
3✔
188
                u.Email = email
3✔
189
                if displayName != "" {
4✔
190
                        u.DisplayName = displayName
1✔
191
                }
1✔
192
                if len(adminGroups) > 0 {
5✔
193
                        u.Admin = hasGroupOverlap(userGroups, adminGroups)
2✔
194
                }
2✔
195
                u.UpdatedAt = time.Now().UTC()
3✔
196
                if err := db.SaveUser(u); err != nil {
3✔
197
                        return model.User{}, err
×
198
                }
×
199
                return u, nil
3✔
200
        }
201

202
        // user not found - check if auto-provisioning is enabled
203
        if !util.OIDCAutoProvision {
6✔
204
                return model.User{}, fmt.Errorf("user %s not found and auto-provisioning is disabled", username)
1✔
205
        }
1✔
206

207
        // determine admin status
208
        isAdmin := false
4✔
209
        if len(adminGroups) > 0 {
6✔
210
                isAdmin = hasGroupOverlap(userGroups, adminGroups)
2✔
211
        }
2✔
212
        // first user gets admin
213
        users, _ := db.GetUsers()
4✔
214
        if len(users) == 0 {
7✔
215
                isAdmin = true
3✔
216
        }
3✔
217

218
        now := time.Now().UTC()
4✔
219
        newUser := model.User{
4✔
220
                Username:    username,
4✔
221
                Email:       email,
4✔
222
                DisplayName: displayName,
4✔
223
                OIDCSub:     sub,
4✔
224
                Admin:       isAdmin,
4✔
225
                CreatedAt:   now,
4✔
226
                UpdatedAt:   now,
4✔
227
        }
4✔
228

4✔
229
        if err := db.SaveUser(newUser); err != nil {
4✔
230
                return model.User{}, fmt.Errorf("cannot create user: %w", err)
×
231
        }
×
232

233
        log.Infof("Auto-provisioned new OIDC user: %s (admin=%v)", username, isAdmin)
4✔
234
        return newUser, nil
4✔
235
}
236

237
// hasGroupOverlap checks if any user group matches any admin group
238
func hasGroupOverlap(userGroups, adminGroups []string) bool {
10✔
239
        groupSet := make(map[string]bool)
10✔
240
        for _, g := range adminGroups {
20✔
241
                groupSet[g] = true
10✔
242
        }
10✔
243
        for _, g := range userGroups {
21✔
244
                if groupSet[g] {
15✔
245
                        return true
4✔
246
                }
4✔
247
        }
248
        return false
6✔
249
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc