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

DigitalTolk / wireguard-ui / 24847022216

23 Apr 2026 04:37PM UTC coverage: 81.636% (-0.7%) from 82.32%
24847022216

Pull #16

github

web-flow
Merge ddd9d4228 into 0c50253d1
Pull Request #16: Fix auth

462 of 590 branches covered (78.31%)

Branch coverage included in aggregate %.

38 of 74 new or added lines in 5 files covered. (51.35%)

201 existing lines in 8 files now uncovered.

2832 of 3445 relevant lines covered (82.21%)

14.17 hits per line

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

62.57
/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 apiError(c, http.StatusUnauthorized, "OIDC_ERROR", fmt.Sprintf("Authentication failed: %s", errDesc))
1✔
115
                }
1✔
116

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

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

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

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

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

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

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

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

×
173
                log.Infof("OIDC login successful for user: %s", user.Username)
×
174

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

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

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

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

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

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

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

235
// hasGroupOverlap checks if any user group matches any admin group
236
func hasGroupOverlap(userGroups, adminGroups []string) bool {
10✔
237
        groupSet := make(map[string]bool)
10✔
238
        for _, g := range adminGroups {
20✔
239
                groupSet[g] = true
10✔
240
        }
10✔
241
        for _, g := range userGroups {
21✔
242
                if groupSet[g] {
15✔
243
                        return true
4✔
244
                }
4✔
245
        }
246
        return false
6✔
247
}
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