diff --git a/go.mod b/go.mod
index 6c1e51b..a16b040 100644
--- a/go.mod
+++ b/go.mod
@@ -2,13 +2,39 @@ module tutor
go 1.25.0
-require github.com/jackc/pgx/v5 v5.9.2
+require (
+ github.com/google/uuid v1.6.0
+ github.com/jackc/pgx/v5 v5.9.2
+ github.com/joho/godotenv v1.5.1
+ google.golang.org/api v0.276.0
+)
require (
+ cloud.google.com/go/auth v0.20.0 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
+ github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
- github.com/joho/godotenv v1.5.1 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/text v0.29.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ golang.org/x/crypto v0.49.0 // indirect
+ golang.org/x/net v0.52.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/grpc v1.80.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/go.sum b/go.sum
index 9ea2463..1f5f94b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,33 @@
+cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
+cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
+github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
+github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -18,10 +45,50 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
+go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=
+google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
+google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
+google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/internal/app/server.go b/internal/app/server.go
index 71c931c..5320d72 100644
--- a/internal/app/server.go
+++ b/internal/app/server.go
@@ -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,
}
}
diff --git a/internal/auth/handler.go b/internal/auth/handler.go
new file mode 100644
index 0000000..1daad54
--- /dev/null
+++ b/internal/auth/handler.go
@@ -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})
+}
diff --git a/internal/auth/service.go b/internal/auth/service.go
new file mode 100644
index 0000000..3a930a6
--- /dev/null
+++ b/internal/auth/service.go
@@ -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
+}
diff --git a/internal/auth/types.go b/internal/auth/types.go
new file mode 100644
index 0000000..27c0948
--- /dev/null
+++ b/internal/auth/types.go
@@ -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"`
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 4647722..b9f8f2d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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", ""),
}
}
diff --git a/internal/db/migrate.go b/internal/db/migrate.go
index 28ec5d5..5332650 100644
--- a/internal/db/migrate.go
+++ b/internal/db/migrate.go
@@ -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
}
diff --git a/internal/db/migrations/002_auth.sql b/internal/db/migrations/002_auth.sql
new file mode 100644
index 0000000..52d67c2
--- /dev/null
+++ b/internal/db/migrations/002_auth.sql
@@ -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()
+);
diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js
index 589e545..9137ed4 100644
--- a/internal/webapp/static/app.js
+++ b/internal/webapp/static/app.js
@@ -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 `Evidence
${evidence.map((item) => `