1// auth_handler.go implements the HTTP endpoints for the OAuth2 login flow:
2//
3// GET /auth/login?provider=<name> — redirect browser to provider
4// GET /auth/callback — receive code, match identity, set session
5// GET /auth/user — return current user as JSON
6// POST /auth/logout — clear session cookie
7// GET /auth/identities — list identities available for adoption
8// POST /auth/adopt — link/create identity and start session
9//
10// The flow for a returning user (identity already has provider metadata):
11//
12// browser → /auth/login → provider → /auth/callback → set cookie → /
13//
14// The flow for a first-time user:
15//
16// browser → /auth/login → provider → /auth/callback
17// → store pending → /auth/select-identity
18// → POST /auth/adopt → set cookie → /
19package http
20
21import (
22 "crypto/rand"
23 "encoding/base64"
24 "encoding/json"
25 "fmt"
26 "net/http"
27 "sync"
28 "time"
29
30 apiauth "github.com/git-bug/git-bug/api/auth"
31 "github.com/git-bug/git-bug/api/auth/oauth"
32 "github.com/git-bug/git-bug/cache"
33 "github.com/git-bug/git-bug/entity"
34)
35
36const (
37 oauthStateCookie = "git-bug-oauth-state"
38 oauthPendingCookie = "git-bug-pending"
39)
40
41// providerMetaKey returns the immutable-metadata key used to link a git-bug
42// identity to an external OAuth provider account. This follows the same
43// convention as the GitHub bridge (metaKeyGithubLogin = "github-login") so
44// that identities imported via the bridge are automatically recognised on
45// first webui login.
46func providerMetaKey(providerName string) string {
47 return providerName + "-login"
48}
49
50// oauthState is JSON-encoded as the OAuth2 state parameter.
51// It carries both a CSRF nonce and the provider name, so the callback can
52// verify the request and dispatch to the right provider without extra cookies.
53type oauthState struct {
54 Nonce string `json:"nonce"`
55 Provider string `json:"provider"`
56}
57
58// pendingAuth holds the OAuth profile for a user who has authenticated with
59// the provider but has not yet been linked to a git-bug identity.
60// It expires after 10 minutes to limit the window for token reuse.
61type pendingAuth struct {
62 UserInfo *oauth.UserInfo
63 Provider string
64 ExpiresAt time.Time
65}
66
67// AuthHandler handles the full OAuth2 login flow. It is intentionally
68// provider-agnostic: concrete providers implement oauth.Provider and are
69// passed in at construction time.
70type AuthHandler struct {
71 mrc *cache.MultiRepoCache
72 sessions *apiauth.SessionStore
73 providers map[string]oauth.Provider // provider name → implementation
74 baseURL string // e.g. "http://localhost:3000"
75
76 // pending maps a short-lived random token (stored in a cookie) to an
77 // OAuth profile that needs identity selection before a real session is
78 // created.
79 pendingMu sync.Mutex
80 pending map[string]*pendingAuth
81}
82
83func NewAuthHandler(mrc *cache.MultiRepoCache, sessions *apiauth.SessionStore, providers []oauth.Provider, baseURL string) *AuthHandler {
84 pm := make(map[string]oauth.Provider, len(providers))
85 for _, p := range providers {
86 pm[p.Name()] = p
87 }
88 return &AuthHandler{
89 mrc: mrc,
90 sessions: sessions,
91 providers: pm,
92 baseURL: baseURL,
93 pending: make(map[string]*pendingAuth),
94 }
95}
96
97// callbackURL builds the absolute URL the provider should redirect to.
98// It must match the URL registered in the provider's OAuth app settings.
99func (h *AuthHandler) callbackURL() string {
100 return h.baseURL + "/auth/callback"
101}
102
103// randToken generates a URL-safe random token of n bytes.
104func randToken(n int) (string, error) {
105 b := make([]byte, n)
106 if _, err := rand.Read(b); err != nil {
107 return "", err
108 }
109 return base64.RawURLEncoding.EncodeToString(b), nil
110}
111
112// HandleLogin initiates the OAuth2 authorization-code flow.
113// GET /auth/login?provider=<name>
114func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
115 providerName := r.URL.Query().Get("provider")
116 p, ok := h.providers[providerName]
117 if !ok {
118 http.Error(w, fmt.Sprintf("unknown provider %q", providerName), http.StatusBadRequest)
119 return
120 }
121
122 nonce, err := randToken(16)
123 if err != nil {
124 http.Error(w, "internal error", http.StatusInternalServerError)
125 return
126 }
127
128 stateData, _ := json.Marshal(oauthState{Nonce: nonce, Provider: providerName})
129 stateEncoded := base64.RawURLEncoding.EncodeToString(stateData)
130
131 // Store the state in a short-lived cookie for CSRF verification on callback.
132 http.SetCookie(w, &http.Cookie{
133 Name: oauthStateCookie,
134 Value: stateEncoded,
135 MaxAge: 300, // 5 minutes — enough time to complete the OAuth redirect
136 HttpOnly: true,
137 SameSite: http.SameSiteLaxMode,
138 Path: "/",
139 })
140
141 http.Redirect(w, r, p.AuthURL(stateEncoded, h.callbackURL()), http.StatusFound)
142}
143
144// HandleCallback receives the authorization code from the provider.
145// GET /auth/callback?code=...&state=...
146func (h *AuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
147 // CSRF: verify that the state parameter matches the cookie we set.
148 stateCookie, err := r.Cookie(oauthStateCookie)
149 if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
150 http.Error(w, "invalid OAuth state", http.StatusBadRequest)
151 return
152 }
153 http.SetCookie(w, &http.Cookie{Name: oauthStateCookie, MaxAge: -1, Path: "/"})
154
155 stateBytes, err := base64.RawURLEncoding.DecodeString(stateCookie.Value)
156 if err != nil {
157 http.Error(w, "malformed state", http.StatusBadRequest)
158 return
159 }
160 var state oauthState
161 if err := json.Unmarshal(stateBytes, &state); err != nil {
162 http.Error(w, "malformed state", http.StatusBadRequest)
163 return
164 }
165
166 p, ok := h.providers[state.Provider]
167 if !ok {
168 http.Error(w, fmt.Sprintf("unknown provider %q", state.Provider), http.StatusBadRequest)
169 return
170 }
171
172 info, err := p.Exchange(r.Context(), r.URL.Query().Get("code"), h.callbackURL())
173 if err != nil {
174 http.Error(w, "OAuth exchange failed: "+err.Error(), http.StatusBadGateway)
175 return
176 }
177
178 // Try to match to an existing git-bug identity via provider metadata.
179 // This reuses the same metadata key as the GitHub bridge
180 // ("github-login"), so bridge-imported identities are recognised
181 // automatically on first login.
182 metaKey := providerMetaKey(state.Provider)
183 for _, repo := range h.mrc.AllRepos() {
184 id, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKey, info.Login)
185 if err == nil {
186 h.startSession(w, id.Id())
187 http.Redirect(w, r, "/", http.StatusFound)
188 return
189 }
190 }
191
192 // No matching identity — store the OAuth profile temporarily and send
193 // the user to the identity selection page.
194 pendingToken, err := randToken(16)
195 if err != nil {
196 http.Error(w, "internal error", http.StatusInternalServerError)
197 return
198 }
199 h.pendingMu.Lock()
200 h.pending[pendingToken] = &pendingAuth{
201 UserInfo: info,
202 Provider: state.Provider,
203 ExpiresAt: time.Now().Add(10 * time.Minute),
204 }
205 h.pendingMu.Unlock()
206
207 http.SetCookie(w, &http.Cookie{
208 Name: oauthPendingCookie,
209 Value: pendingToken,
210 MaxAge: 600,
211 HttpOnly: true,
212 SameSite: http.SameSiteLaxMode,
213 Path: "/",
214 })
215 http.Redirect(w, r, "/auth/select-identity", http.StatusFound)
216}
217
218// HandleUser returns the current authenticated user as JSON.
219// GET /auth/user — used by the frontend in oauth mode to poll auth state.
220func (h *AuthHandler) HandleUser(w http.ResponseWriter, r *http.Request) {
221 cookie, err := r.Cookie(apiauth.SessionCookie)
222 if err != nil {
223 w.WriteHeader(http.StatusUnauthorized)
224 return
225 }
226 userId, ok := h.sessions.Get(cookie.Value)
227 if !ok {
228 w.WriteHeader(http.StatusUnauthorized)
229 return
230 }
231
232 for _, repo := range h.mrc.AllRepos() {
233 id, err := repo.Identities().Resolve(userId)
234 if err != nil {
235 continue
236 }
237 w.Header().Set("Content-Type", "application/json")
238 json.NewEncoder(w).Encode(map[string]any{
239 "id": id.Id().String(),
240 "humanId": id.Id().Human(),
241 "name": id.Name(),
242 "displayName": id.DisplayName(),
243 "login": id.Login(),
244 "email": id.Email(),
245 "avatarUrl": id.AvatarUrl(),
246 })
247 return
248 }
249 w.WriteHeader(http.StatusUnauthorized)
250}
251
252// HandleLogout clears the session and redirects to the root.
253// POST /auth/logout
254func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
255 if cookie, err := r.Cookie(apiauth.SessionCookie); err == nil {
256 h.sessions.Delete(cookie.Value)
257 }
258 http.SetCookie(w, &http.Cookie{Name: apiauth.SessionCookie, MaxAge: -1, Path: "/"})
259 http.Redirect(w, r, "/", http.StatusFound)
260}
261
262// HandleIdentities returns all identities across all repos for the adoption UI.
263// GET /auth/identities — only valid while a pending auth cookie is present.
264func (h *AuthHandler) HandleIdentities(w http.ResponseWriter, r *http.Request) {
265 if _, ok := h.getPending(r); !ok {
266 http.Error(w, "no pending authentication", http.StatusForbidden)
267 return
268 }
269
270 type identityJSON struct {
271 RepoSlug string `json:"repoSlug"`
272 Id string `json:"id"`
273 HumanId string `json:"humanId"`
274 DisplayName string `json:"displayName"`
275 Login string `json:"login,omitempty"`
276 AvatarUrl string `json:"avatarUrl,omitempty"`
277 }
278
279 var identities []identityJSON
280 for _, repo := range h.mrc.AllRepos() {
281 for _, id := range repo.Identities().AllIds() {
282 i, err := repo.Identities().Resolve(id)
283 if err != nil {
284 continue
285 }
286 identities = append(identities, identityJSON{
287 RepoSlug: repo.Name(),
288 Id: i.Id().String(),
289 HumanId: i.Id().Human(),
290 DisplayName: i.DisplayName(),
291 Login: i.Login(),
292 AvatarUrl: i.AvatarUrl(),
293 })
294 }
295 }
296
297 w.Header().Set("Content-Type", "application/json")
298 json.NewEncoder(w).Encode(identities)
299}
300
301// HandleAdopt links the pending OAuth profile to a git-bug identity (existing
302// or newly created) and starts a real session.
303// POST /auth/adopt body: {"identityId": "<id>"} or {"create": true}
304func (h *AuthHandler) HandleAdopt(w http.ResponseWriter, r *http.Request) {
305 pa, ok := h.getPending(r)
306 if !ok {
307 http.Error(w, "no pending authentication", http.StatusForbidden)
308 return
309 }
310
311 var body struct {
312 IdentityId string `json:"identityId"` // empty string → create new
313 }
314 json.NewDecoder(r.Body).Decode(&body)
315
316 metaKey := providerMetaKey(pa.Provider)
317 var userId entity.Id
318
319 if body.IdentityId == "" {
320 // Create a new git-bug identity from the OAuth profile, tagging it
321 // with the provider metadata so future logins match automatically.
322 repos := h.mrc.AllRepos()
323 if len(repos) == 0 {
324 http.Error(w, "no repositories available", http.StatusInternalServerError)
325 return
326 }
327 created, err := repos[0].Identities().NewRaw(
328 pa.UserInfo.Name,
329 pa.UserInfo.Email,
330 pa.UserInfo.Login,
331 pa.UserInfo.AvatarURL,
332 nil,
333 map[string]string{metaKey: pa.UserInfo.Login},
334 )
335 if err != nil {
336 http.Error(w, "failed to create identity: "+err.Error(), http.StatusInternalServerError)
337 return
338 }
339 userId = created.Id()
340 } else {
341 // Adopt an existing identity by adding the provider metadata to it.
342 // This links the identity to the OAuth account for future logins.
343 id := entity.Id(body.IdentityId)
344 for _, repo := range h.mrc.AllRepos() {
345 cached, err := repo.Identities().Resolve(id)
346 if err != nil {
347 continue
348 }
349 cached.SetMetadata(metaKey, pa.UserInfo.Login)
350 if err := cached.Commit(); err != nil {
351 http.Error(w, "failed to update identity: "+err.Error(), http.StatusInternalServerError)
352 return
353 }
354 userId = cached.Id()
355 break
356 }
357 if userId == "" {
358 http.Error(w, "identity not found", http.StatusNotFound)
359 return
360 }
361 }
362
363 h.clearPending(r, w)
364 h.startSession(w, userId)
365 w.WriteHeader(http.StatusOK)
366}
367
368func (h *AuthHandler) startSession(w http.ResponseWriter, userId entity.Id) {
369 token, err := h.sessions.Create(userId)
370 if err != nil {
371 http.Error(w, "internal error", http.StatusInternalServerError)
372 return
373 }
374 http.SetCookie(w, &http.Cookie{
375 Name: apiauth.SessionCookie,
376 Value: token,
377 HttpOnly: true,
378 SameSite: http.SameSiteLaxMode,
379 Path: "/",
380 })
381}
382
383func (h *AuthHandler) getPending(r *http.Request) (*pendingAuth, bool) {
384 cookie, err := r.Cookie(oauthPendingCookie)
385 if err != nil {
386 return nil, false
387 }
388 h.pendingMu.Lock()
389 pa, ok := h.pending[cookie.Value]
390 h.pendingMu.Unlock()
391 if !ok || time.Now().After(pa.ExpiresAt) {
392 return nil, false
393 }
394 return pa, true
395}
396
397func (h *AuthHandler) clearPending(r *http.Request, w http.ResponseWriter) {
398 if cookie, err := r.Cookie(oauthPendingCookie); err == nil {
399 h.pendingMu.Lock()
400 delete(h.pending, cookie.Value)
401 h.pendingMu.Unlock()
402 }
403 http.SetCookie(w, &http.Cookie{Name: oauthPendingCookie, MaxAge: -1, Path: "/"})
404}