feat: add Google Sign-In with JWT auth and Neon DB persistence

This commit is contained in:
user
2026-04-27 13:23:47 +09:00
parent 7f77c2aaf4
commit 3aa1d92c98
11 changed files with 393 additions and 19 deletions

View File

@@ -4,6 +4,8 @@ import (
"log"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"tutor/internal/auth"
"tutor/internal/config"
"tutor/internal/db"
"tutor/internal/httpapi"
@@ -22,12 +24,14 @@ func NewServer(cfg config.Config) *http.Server {
var memoryStore learnermemory.Store
var ontologyStore ontology.Store
var assetsStore teachingassets.Store
var pool *pgxpool.Pool
if cfg.DatabaseURL != "" {
pool, err := db.Open(cfg.DatabaseURL)
p, err := db.Open(cfg.DatabaseURL)
if err != nil {
log.Fatalf("open database: %v", err)
}
pool = p
if err := db.Migrate(pool); err != nil {
log.Fatalf("migrate database: %v", err)
}
@@ -51,8 +55,15 @@ func NewServer(cfg config.Config) *http.Server {
service := interview.NewService(interviewStore, runner, memory)
handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets)
mux := handler.Routes().(*http.ServeMux)
if pool != nil && cfg.GoogleClientID != "" && cfg.JWTSecret != "" {
authService := auth.NewService(pool, cfg.GoogleClientID, cfg.JWTSecret)
authService.RegisterRoutes(mux)
log.Println("auth routes registered")
}
return &http.Server{
Addr: cfg.HTTPAddr,
Handler: handler.Routes(),
Handler: mux,
}
}

35
internal/auth/handler.go Normal file
View File

@@ -0,0 +1,35 @@
package auth
import (
"encoding/json"
"net/http"
)
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/auth/google", s.handleGoogleLogin)
}
func (s *Service) handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req GoogleLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, "invalid request", http.StatusBadRequest)
return
}
resp, err := s.HandleGoogleLogin(r.Context(), req)
if err != nil {
writeError(w, err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func writeError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}

128
internal/auth/service.go Normal file
View File

@@ -0,0 +1,128 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"google.golang.org/api/idtoken"
)
type Service struct {
pool *pgxpool.Pool
googleClientID string
jwtSecret string
}
func NewService(pool *pgxpool.Pool, googleClientID, jwtSecret string) *Service {
return &Service{
pool: pool,
googleClientID: googleClientID,
jwtSecret: jwtSecret,
}
}
func (s *Service) HandleGoogleLogin(ctx context.Context, req GoogleLoginRequest) (SessionResponse, error) {
if strings.TrimSpace(req.IDToken) == "" || s.googleClientID == "" {
return SessionResponse{}, errors.New("google id token is required")
}
payload, err := idtoken.Validate(ctx, req.IDToken, s.googleClientID)
if err != nil {
return SessionResponse{}, err
}
email := ""
if v, ok := payload.Claims["email"].(string); ok {
email = strings.ToLower(strings.TrimSpace(v))
}
name := ""
if v, ok := payload.Claims["name"].(string); ok {
name = strings.TrimSpace(v)
}
subject := payload.Subject
if email == "" {
return SessionResponse{}, errors.New("email not found in token")
}
if name == "" {
name = strings.Split(email, "@")[0]
}
user, err := s.upsertUser(ctx, email, name, subject)
if err != nil {
return SessionResponse{}, err
}
token, err := SignToken(s.jwtSecret, Claims{
UserID: user.ID,
Email: user.Email,
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
})
if err != nil {
return SessionResponse{}, err
}
return SessionResponse{Token: token, User: user}, nil
}
func (s *Service) upsertUser(ctx context.Context, email, displayName, subject string) (User, error) {
var user User
err := s.pool.QueryRow(ctx, `
INSERT INTO users (email, display_name, provider, provider_subject)
VALUES ($1, $2, 'google', $3)
ON CONFLICT (email) DO UPDATE SET
display_name = EXCLUDED.display_name,
provider_subject = EXCLUDED.provider_subject
RETURNING id, email, display_name
`, email, displayName, subject).Scan(&user.ID, &user.Email, &user.DisplayName)
if err != nil {
return User{}, fmt.Errorf("upsert user: %w", err)
}
return user, nil
}
func SignToken(secret string, claims Claims) (string, error) {
body, err := json.Marshal(claims)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(body)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payload + "." + sig, nil
}
func VerifyToken(secret, token string) (Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return Claims{}, errors.New("invalid token")
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(parts[0]))
expected := mac.Sum(nil)
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(expected, actual) {
return Claims{}, errors.New("invalid signature")
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Claims{}, err
}
var claims Claims
if err := json.Unmarshal(body, &claims); err != nil {
return Claims{}, err
}
if claims.Exp < time.Now().Unix() {
return Claims{}, fmt.Errorf("token expired")
}
return claims, nil
}

24
internal/auth/types.go Normal file
View File

@@ -0,0 +1,24 @@
package auth
import "github.com/google/uuid"
type GoogleLoginRequest struct {
IDToken string `json:"id_token"`
}
type SessionResponse struct {
Token string `json:"token"`
User User `json:"user"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
type Claims struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Exp int64 `json:"exp"`
}

View File

@@ -19,6 +19,8 @@ type Config struct {
ModelKey string
ImageModelKey string
ThirdOneBin string
GoogleClientID string
JWTSecret string
}
func LoadFromEnv() Config {
@@ -30,6 +32,8 @@ func LoadFromEnv() Config {
ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey),
ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey),
ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin),
GoogleClientID: envOrDefault("GOOGLE_CLIENT_ID", ""),
JWTSecret: envOrDefault("JWT_SECRET", ""),
}
}

View File

@@ -2,19 +2,38 @@ package db
import (
"context"
_ "embed"
"embed"
"fmt"
"path"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/001_init.sql
var initSQL string
//go:embed migrations/*.sql
var migrationsFS embed.FS
func Migrate(pool *pgxpool.Pool) error {
_, err := pool.Exec(context.Background(), initSQL)
files, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("run migration: %w", err)
return fmt.Errorf("read migrations dir: %w", err)
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name() < files[j].Name()
})
for _, f := range files {
if path.Ext(f.Name()) != ".sql" {
continue
}
data, err := migrationsFS.ReadFile("migrations/" + f.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", f.Name(), err)
}
if _, err := pool.Exec(context.Background(), string(data)); err != nil {
return fmt.Errorf("run migration %s: %w", f.Name(), err)
}
}
return nil
}

View File

@@ -0,0 +1,10 @@
-- Auth: users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'google',
provider_subject TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -25,6 +25,9 @@ const els = {
status: document.querySelector("#status-line"),
error: document.querySelector("#error-line"),
title: document.querySelector("#session-title"),
gSignIn: document.querySelector(".g_id_signin"),
userInfo: document.querySelector("#user-info"),
logoutButton: document.querySelector("#logout-button"),
};
const readinessClassMap = {
@@ -359,11 +362,48 @@ function evidenceBlock(evidence = []) {
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
}
window.handleCredentialResponse = async (response) => {
try {
const res = await request("/api/v1/auth/google", {
method: "POST",
body: JSON.stringify({ id_token: response.credential }),
});
localStorage.setItem("tutor_token", res.token);
localStorage.setItem("tutor_user", JSON.stringify(res.user));
setStatus(`Signed in as ${res.user.email}`);
renderAuth();
} catch (err) {
showError(err.message);
}
};
function renderAuth() {
const user = JSON.parse(localStorage.getItem("tutor_user") || "null");
const token = localStorage.getItem("tutor_token");
if (user && token) {
els.gSignIn.style.display = "none";
els.userInfo.style.display = "block";
els.userInfo.textContent = user.email;
els.logoutButton.style.display = "inline-block";
} else {
els.gSignIn.style.display = "block";
els.userInfo.style.display = "none";
els.logoutButton.style.display = "none";
}
}
els.logoutButton.addEventListener("click", () => {
localStorage.removeItem("tutor_token");
localStorage.removeItem("tutor_user");
renderAuth();
setStatus("Signed out");
});
async function request(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
const token = localStorage.getItem("tutor_token");
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const response = await fetch(url, { headers, ...options });
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || `Request failed: ${response.status}`);
@@ -398,3 +438,5 @@ function escapeHTML(value) {
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
renderAuth();

View File

@@ -7,12 +7,20 @@
<link rel="stylesheet" href="/assets/styles.css" />
</head>
<body>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<main class="workspace">
<aside class="setup-pane" aria-label="Diagnostic setup">
<p class="eyebrow">Tutor Platform</p>
<h1>Interview practice</h1>
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
<div id="auth-area" class="auth-area">
<div id="g_id_onload" data-client_id="13671390758-bp1ed6psn43bl86r8a9kv81o40nkea90.apps.googleusercontent.com" data-callback="handleCredentialResponse"></div>
<div class="g_id_signin" data-type="standard"></div>
<div id="user-info" class="user-info" style="display:none;"></div>
<button id="logout-button" class="small-button" type="button" style="display:none;">Sign out</button>
</div>
<form id="session-form" class="stacked-form">
<label>
User ID