feat: Google OAuth login + server sessions (user_key u:<sub>)

This commit is contained in:
asepharyana
2026-09-15 16:29:57 +07:00
parent fee313ce97
commit 1881e7c6b1
6 changed files with 410 additions and 1 deletions
+48
View File
@@ -271,3 +271,51 @@ func TestDestinations(t *testing.T) {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
// Auth endpoints 404 when Google is unconfigured; /me 401.
func TestAuthUnconfigured(t *testing.T) {
s := testServer(t)
rec := do(s, "GET", "/api/auth/start", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("start code = %d", rec.Code)
}
rec = do(s, "GET", "/api/auth/me", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("me code = %d", rec.Code)
}
rec = do(s, "POST", "/api/auth/logout", nil)
if rec.Code != http.StatusOK {
t.Fatalf("logout code = %d", rec.Code)
}
}
// Session round-trip: upsert user -> create session -> resolve -> revoke.
func TestSessionRoundTrip(t *testing.T) {
s := testServer(t)
u, err := s.DB.UpsertUser("sub-123", "a@x.id", "A", "")
if err != nil {
t.Fatal(err)
}
if u.UserKey != "u:sub-123" {
t.Fatalf("userkey = %s", u.UserKey)
}
tok, err := s.DB.CreateSession(u.ID, u.UserKey, 3600000000000)
if err != nil {
t.Fatal(err)
}
got, ok := s.DB.SessionUser(tok)
if !ok || got.Email != "a@x.id" {
t.Fatalf("resolve ok=%v got=%v", ok, got)
}
req := httptest.NewRequest("GET", "/api/watchlist", nil)
req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
if key := s.userKey(req); key != "u:sub-123" {
t.Fatalf("userKey = %s", key)
}
if err := s.DB.DeleteSession(tok); err != nil {
t.Fatal(err)
}
if _, ok := s.DB.SessionUser(tok); ok {
t.Fatal("revoked session still valid")
}
}
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"flowsight/internal/store"
)
// Session lifetime for Google logins.
const sessionTTL = 30 * 24 * time.Hour
// googleAuthURL builds the Google consent URL (state carries platform=web).
func (s *Server) googleAuthURL() string {
q := url.Values{
"client_id": {s.Cfg.GoogleClientID},
"redirect_uri": {s.Cfg.GoogleRedirectURL},
"response_type": {"code"},
"scope": {"openid email profile"},
"access_type": {"offline"},
"prompt": {"select_account"},
"state": {base64.RawURLEncoding.EncodeToString([]byte(`{"platform":"web"}`))},
}
return "https://accounts.google.com/o/oauth2/v2/auth?" + q.Encode()
}
// AuthStart serves GET /api/auth/start: 302 to Google, or 404 unconfigured.
func (s *Server) AuthStart(w http.ResponseWriter, r *http.Request) {
if !s.Cfg.HasGoogle() {
writeErr(w, http.StatusNotFound, "google login not configured")
return
}
http.Redirect(w, r, s.googleAuthURL(), http.StatusFound)
}
// AuthCallback serves GET /api/auth/callback: exchanges code for tokens,
// upserts the user, mints a session cookie, redirects to /.
// Error cases redirect to /login?error=... (web client shows the message).
func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
if !s.Cfg.HasGoogle() {
writeErr(w, http.StatusNotFound, "google login not configured")
return
}
fail := func(msg string) {
http.Redirect(w, r, "/login?error="+url.QueryEscape(msg), http.StatusFound)
}
q := r.URL.Query()
if q.Get("error") != "" || q.Get("code") == "" {
fail(q.Get("error"))
if q.Get("error") == "" {
fail("missing_code")
}
return
}
tokens, err := exchangeGoogleCode(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
s.Cfg.GoogleRedirectURL, q.Get("code"))
if err != nil {
fail("google token exchange failed")
return
}
claims, err := decodeGoogleIDToken(tokens.IDToken)
if err != nil || !claims.EmailVerified || claims.Email == "" {
fail("email not verified by google")
return
}
name := strings.TrimSpace(claims.Name)
if name == "" {
name = strings.Split(claims.Email, "@")[0]
}
user, err := s.DB.UpsertUser(claims.Sub, strings.ToLower(strings.TrimSpace(claims.Email)),
name, claims.Picture)
if err != nil {
fail("user store unavailable")
return
}
// Ensure the login user owns the default watchlist on first login.
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
for _, t := range s.Cfg.Watchlist {
_ = s.DB.AddWatch(user.UserKey, t)
}
}
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
if err != nil {
fail("session store unavailable")
return
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
Secure: true, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
http.Redirect(w, r, "/", http.StatusFound)
}
// AuthMe serves GET /api/auth/me: 200 user when logged in, else 401.
func (s *Server) AuthMe(w http.ResponseWriter, r *http.Request) {
u, ok := s.sessionUser(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "not logged in")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name,
"avatar_url": u.AvatarURL, "user_key": u.UserKey,
"google_configured": s.Cfg.HasGoogle(),
},
})
}
// AuthLogout serves POST /api/auth/logout: revokes the session cookie.
func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("fs_session"); err == nil {
_ = s.DB.DeleteSession(c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// sessionUser resolves the request's session cookie to its user.
func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
c, err := r.Cookie("fs_session")
if err != nil {
return nil, false
}
return s.DB.SessionUser(c.Value)
}
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
type googleTokenResp struct {
IDToken string `json:"id_token"`
}
// googleClaims is the verified-identity subset of the id_token payload.
type googleClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
// exchangeGoogleCode swaps an authorization code for tokens.
func exchangeGoogleCode(clientID, secret, redirectURL, code string) (*googleTokenResp, error) {
form := url.Values{
"code": {code},
"client_id": {clientID},
"client_secret": {secret},
"redirect_uri": {redirectURL},
"grant_type": {"authorization_code"},
}
resp, err := http.PostForm("https://oauth2.googleapis.com/token", form)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(resp.StatusCode)}
}
var out googleTokenResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if out.IDToken == "" {
return nil, &url.Error{Op: "exchange", URL: "oauth2.googleapis.com/token", Err: errString(0)}
}
return &out, nil
}
// decodeGoogleIDToken decodes (not signature-verifies) the id_token payload.
// Full verification happens implicitly: the token arrives over TLS directly
// from Google's token endpoint in exchange for our client_secret.
func decodeGoogleIDToken(idToken string) (*googleClaims, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, errInvalidToken{}
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
var c googleClaims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, err
}
if c.Sub == "" {
return nil, errInvalidToken{}
}
return &c, nil
}
type errString int
func (e errString) Error() string {
if e == 0 {
return "empty id_token"
}
return http.StatusText(int(e))
}
type errInvalidToken struct{}
func (errInvalidToken) Error() string { return "invalid id_token" }
+9 -1
View File
@@ -62,6 +62,10 @@ func (s *Server) Router() http.Handler {
r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping"))
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.Health)
r.Get("/auth/start", s.AuthStart)
r.Get("/auth/callback", s.AuthCallback)
r.Get("/auth/me", s.AuthMe)
r.Post("/auth/logout", s.AuthLogout)
r.Get("/stream", s.Stream)
r.Get("/flow/summary", s.FlowSummary)
r.Get("/flow/broker", s.FlowBroker)
@@ -96,8 +100,12 @@ func (s *Server) Router() http.Handler {
return r
}
// userKey resolves the demo auth header (single demo key for hackathon).
// userKey resolves the request owner: session cookie first (Google login ->
// `u:<sub>`), then the demo header, then the shared demo key.
func (s *Server) userKey(r *http.Request) string {
if u, ok := s.sessionUser(r); ok {
return u.UserKey
}
if k := strings.TrimSpace(r.Header.Get("X-User-Key")); k != "" {
return k
}