auth_handler.go

  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}