-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoauth2_app_github.go
More file actions
379 lines (336 loc) · 14.5 KB
/
oauth2_app_github.go
File metadata and controls
379 lines (336 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
package api
import (
authpkg "api-server/auth"
"api-server/db"
"api-server/models"
"api-server/utils"
"context"
"crypto/subtle"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.uber.org/zap"
)
type GitHubAppHandler struct {
collProfiles *mongo.Collection
auth *authpkg.Auth
logger *zap.SugaredLogger
sessionStateName string
sessionAppCodeChallengeName string
sessionAppStateName string
sessionGitHubVerifierName string
httpClient *http.Client
}
type AppExchangeCodeReq struct {
Code string `json:"code"`
CodeVerifier string `json:"codeVerifier"`
}
type AppExchangeCodeResp struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
}
func NewGitHubAppHandler(auth *authpkg.Auth, logger *zap.SugaredLogger, client *mongo.Client, sessionStateName, sessionAppCodeChallengeName string) *GitHubAppHandler {
return &GitHubAppHandler{
collProfiles: db.GetCollections(client).Profiles,
auth: auth,
logger: logger,
sessionStateName: sessionStateName,
sessionAppCodeChallengeName: sessionAppCodeChallengeName,
sessionAppStateName: sessionAppCodeChallengeName + "_app_state",
sessionGitHubVerifierName: sessionAppCodeChallengeName + "_github_verifier",
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (gh *GitHubAppHandler) GitHubAppLogin(c *gin.Context) {
gh.logger.Info("REST - GET - GitHubAppLogin called")
// ---------------------- MOBILE APP SPECIFIC ----------------------
// This PKCE challenge is generated by the mobile app, not by GitHub.
// It protects the later /app/exchange-code step: if the app_code leaks
// through the OS/browser redirect boundary, it cannot be redeemed without
// the app-held verifier.
appCodeChallenge := strings.TrimSpace(c.Query("code_challenge"))
appCodeChallengeMethod := strings.TrimSpace(c.Query("code_challenge_method"))
if !utils.IsValidPKCECodeChallenge(appCodeChallenge) || appCodeChallengeMethod != utils.PKCEChallengeMethodS256 {
gh.logger.Error("REST - GET - GitHubAppLogin - invalid app-code PKCE challenge")
c.JSON(http.StatusBadRequest, gin.H{"error": "missing or invalid PKCE parameters"})
return
}
// -----------------------------------------------------------------
appState := strings.TrimSpace(c.Query("app_state"))
if !utils.IsValidPKCEVerifier(appState) {
gh.logger.Error("REST - GET - GitHubAppLogin - invalid app state")
c.JSON(http.StatusBadRequest, gin.H{"error": "missing or invalid app state"})
return
}
session := sessions.Default(c)
// build state for CSRF protection. Use the RFC 7636 PKCE verifier
// maximum length because the value has the same unguessable bearer-secret
// property and GitHub echoes it through the OAuth redirect.
state, err := utils.NewPKCEVerifier()
if err != nil {
gh.logger.Error("REST - GET - GitHubAppLogin - cannot create random state token")
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize oauth flow"})
return
}
// This second PKCE verifier is server-generated and protects only the
// server <-> GitHub authorization-code exchange, exactly like the web flow.
// build PKCE plain secret verifier
// (it will be used only on our server-side as a verification step)
githubVerifier, err := utils.NewPKCEVerifier()
if err != nil {
gh.logger.Errorw("REST - GET - GitHubAppLogin - cannot create GitHub PKCE verifier", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize oauth flow"})
return
}
// build PKCE codeChallenge from verifier with sha256 and base64 function
// This will be used later to send it to GitHub (so we send the hashed version and not the plain verifier code)
githubCodeChallenge, err := utils.BuildPKCECodeChallenge(githubVerifier)
if err != nil {
gh.logger.Errorw("REST - GET - GitHubAppLogin - cannot create GitHub PKCE challenge", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize oauth flow"})
return
}
// store both state and GitHub PKCE verifier in session
session.Set(gh.sessionStateName, state)
session.Set(gh.sessionGitHubVerifierName, githubVerifier)
// ---------------------- MOBILE APP SPECIFIC ----------------------
// save also PKCE challenge and state in session, we will need these later
session.Set(gh.sessionAppCodeChallengeName, appCodeChallenge)
session.Set(gh.sessionAppStateName, appState)
// -----------------------------------------------------------------
if err = session.Save(); err != nil {
gh.logger.Error("REST - GET - GitHubAppLogin - cannot save session")
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize oauth flow"})
return
}
// authURL must expose only the S256 challenge. Keep the raw verifier server-side.
authURL, err := authpkg.BuildGitHubAuthorizationURL(authpkg.GitHubOAuthClientApp, state, githubCodeChallenge)
if err != nil {
gh.logger.Errorw("REST - GET - GitHubAppLogin - cannot build authorization URL", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not build authURL during oauth flow initialization"})
return
}
gh.logger.Debug("REST - GET - GitHubAppLogin - authURL: ", authURL)
c.Redirect(http.StatusTemporaryRedirect, authURL)
}
func (gh *GitHubAppHandler) GitHubAppCallback(c *gin.Context) {
gh.auth.Logger.Info("REST - GET - GitHubAppCallback called")
session := sessions.Default(c)
defer func() {
session.Delete(gh.sessionStateName)
session.Delete(gh.sessionGitHubVerifierName)
session.Delete(gh.sessionAppCodeChallengeName)
session.Delete(gh.sessionAppStateName)
if err := session.Save(); err != nil {
gh.logger.Warnw("GitHubAppCallback - cannot clear oauth session", "error", err)
}
}()
// extract state (for CSRF protection)
queryState := strings.TrimSpace(c.Query("state"))
// extract code: a one-time authorization code from GitHub, used to get a GitHub access token.
queryCode := strings.TrimSpace(c.Query("code"))
if queryState == "" || queryCode == "" {
gh.logger.Error("REST - GET - GitHubAppCallback - missing either state or code callback parameters")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth callback"})
return
}
// check if state, GitHub PKCE verifier, and app-code PKCE challenge are in session.
sessionState, _ := session.Get(gh.sessionStateName).(string)
githubVerifier, _ := session.Get(gh.sessionGitHubVerifierName).(string)
appCodeChallenge, _ := session.Get(gh.sessionAppCodeChallengeName).(string)
appState, _ := session.Get(gh.sessionAppStateName).(string)
if sessionState == "" || !utils.IsValidPKCEVerifier(githubVerifier) ||
!utils.IsValidPKCECodeChallenge(appCodeChallenge) || !utils.IsValidPKCEVerifier(appState) {
gh.logger.Error("REST - GET - GitHubAppCallback - oauth session is missing or expired")
c.JSON(http.StatusBadRequest, gin.H{"error": "oauth session is missing or expired"})
return
}
// state must be = to the one in session
if subtle.ConstantTimeCompare([]byte(queryState), []byte(sessionState)) != 1 {
gh.logger.Error("REST - GET - GitHubAppCallback - oauth state verification failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth callback"})
return
}
// 10s timeout, so the GitHub token exchange and profile request cannot pin the request indefinitely.
// This is not really required because the timeout is already 10s, but in this way it's more explicit.
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
// get GitHub access token passing both:
// - one-time code
// - PKCE plain verifier (the plain string, because GitHub will hash and compare it)
githubAccessToken, err := authpkg.ExchangeGitHubCodeForAccessToken(
ctx,
gh.httpClient,
authpkg.GitHubOAuthClientApp,
queryCode,
githubVerifier,
)
if err != nil {
gh.logger.Errorw("REST - GET - GitHubAppCallback - github token exchange failed", "error", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "github token exchange failed"})
return
}
// get GitHub profile using the githubAccessToken
githubProfile, err := authpkg.FetchGitHubUser(ctx, gh.httpClient, githubAccessToken)
if err != nil {
gh.logger.Errorw("REST - GET - GitHubAppCallback - could not load github profile", "error", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "could not load github profile"})
return
}
// find existing local profile or create a new one
profile, err := authpkg.FindOrCreateGitHubProfile(ctx, gh.logger, gh.collProfiles, githubProfile)
if err != nil {
gh.logger.Errorw("REST - GET - GitHubAppCallback - could not persist user", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not persist user"})
return
}
// App login cannot safely issue JWTs in the browser callback because the
// callback crosses the OS/browser/app-link boundary. Instead, issue a
// short-lived one-time app_code bound to the app PKCE challenge. The app
// must redeem it later with the original verifier before JWTs are issued.
appLoginCode, expiry, err := gh.issueAppLoginResult(ctx, profile, appCodeChallenge)
if err != nil {
gh.auth.Logger.Errorw("REST - GET - GitHubAppCallback - could not issue app login result", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not complete login"})
return
}
gh.auth.Logger.Infow("AUDIT - app login code issued",
"profileID", profile.ID.Hex(),
"expiry", expiry,
)
queryParams := url.Values{}
queryParams.Set("code", appLoginCode)
queryParams.Set("state", appState)
location, err := gh.buildMobileAppRedirectURL(queryParams)
if err != nil {
gh.auth.Logger.Errorw("REST - GET - GitHubAppCallback - invalid app callback URL configuration", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot build app redirect"})
return
}
c.Redirect(http.StatusFound, location)
}
func (gh *GitHubAppHandler) ExchangeAppCode(c *gin.Context) {
gh.auth.Logger.Info("REST - POST - ExchangeAppCode called")
var req AppExchangeCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
gh.auth.Logger.Error("REST - POST - ExchangeAppCode - invalid request payload")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
code := strings.TrimSpace(req.Code)
codeVerifier := strings.TrimSpace(req.CodeVerifier)
if !utils.IsValidAppLoginCode(code) {
gh.auth.Logger.Error("REST - POST - ExchangeAppCode - app login code is invalid")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
// The app proves possession of the verifier that matches the challenge
// stored when login started. This binds JWT issuance to the original app
// instance, not just to whoever can present the redirected app_code.
appCodeChallenge, err := utils.BuildPKCECodeChallenge(codeVerifier)
if err != nil {
gh.auth.Logger.Error("REST - POST - ExchangeAppCode - invalid request payload")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
now := time.Now().UTC()
filter := bson.M{
"code": code,
"pkceCodeChallenge": appCodeChallenge,
"pkceChallengeMethod": utils.PKCEChallengeMethodS256,
"usedAt": bson.M{"$exists": false},
"expiresAt": bson.M{"$gt": now},
}
update := bson.M{"$set": bson.M{"usedAt": now}}
var appLoginCode models.AppLoginCode
err = gh.auth.CollAppLoginCodes.FindOneAndUpdate(c.Request.Context(), filter, update, options.FindOneAndUpdate()).Decode(&appLoginCode)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
gh.auth.Logger.Error("REST - POST - ExchangeAppCode - invalid or expired code")
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired code"})
return
}
gh.auth.Logger.Errorw("REST - POST - ExchangeAppCode - cannot consume code", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot exchange code"})
return
}
var profile models.Profile
err = gh.auth.CollProfiles.FindOne(c.Request.Context(), bson.M{"_id": appLoginCode.ProfileID}).Decode(&profile)
if err != nil {
gh.auth.Logger.Errorw("REST - POST - ExchangeAppCode - profile not found", "profileID", appLoginCode.ProfileID.Hex(), "error", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "profile not found"})
return
}
// issue the local access JWT and store a hashed refresh token server-side.
accessToken, refreshToken, expirationTime, err := authpkg.IssueGitHubLoginResult(
c.Request.Context(),
gh.auth.CollRefreshTokens,
profile,
gh.auth.JwtKey,
authpkg.MobileTokenTTL,
authpkg.MobileRefreshTokenTTL,
authpkg.RefreshTokenClientMobile,
)
if err != nil {
gh.auth.Logger.Errorw("REST - POST - ExchangeAppCode - cannot create tokens", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot create tokens"})
return
}
gh.auth.Logger.Infow("AUDIT - mobile tokens issued via app code exchange",
"profileID", profile.ID.Hex(),
"expiry", expirationTime,
)
c.JSON(http.StatusOK, AppExchangeCodeResp{
Token: accessToken,
RefreshToken: refreshToken,
})
}
func (gh *GitHubAppHandler) issueAppLoginResult(ctx context.Context, profile models.Profile, appCodeChallenge string) (string, time.Time, error) {
code, err := utils.RandomString(96)
if err != nil {
return "", time.Time{}, fmt.Errorf("create app login code: %w", err)
}
now := time.Now().UTC()
expiry := now.Add(authpkg.MobileAppLoginCodeTTL)
appLoginCode := models.AppLoginCode{
ID: bson.NewObjectID(),
Code: code,
ProfileID: profile.ID,
PKCECodeChallenge: appCodeChallenge,
PKCEChallengeMethod: utils.PKCEChallengeMethodS256,
ExpiresAt: expiry,
CreatedAt: now,
}
if _, err = gh.auth.CollAppLoginCodes.InsertOne(ctx, appLoginCode); err != nil {
return "", time.Time{}, fmt.Errorf("store app login code: %w", err)
}
return code, expiry, nil
}
func (gh *GitHubAppHandler) buildMobileAppRedirectURL(queryParams url.Values) (string, error) {
callbackURL := strings.TrimSpace(os.Getenv("OAUTH2_APP_CALLBACK"))
parsed, err := url.Parse(callbackURL)
if err != nil {
return "", err
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("OAUTH2_APP_CALLBACK must include scheme and host")
}
location := url.URL{
Scheme: parsed.Scheme,
Host: parsed.Host,
Path: "/app/postlogin",
RawQuery: queryParams.Encode(),
}
return location.String(), nil
}