Gate everything behind domain-locked google sign-in

This commit is contained in:
Ian Gulliver
2026-08-15 17:07:41 -07:00
parent 97af37ba40
commit f2f784fd63
7 changed files with 268 additions and 30 deletions
+7 -1
View File
@@ -8,6 +8,12 @@ Toolchain prerequisites: see [setup.md](setup.md).
The server listens on http://localhost:8080 (override with `PORT`). Templates, static assets, and sample data are read from disk on every request — edit a file and refresh the browser; no restart needed.
## Auth
Everything — pages, static assets, and the API — sits behind Google sign-in restricted to the school's Google Workspace domain. Unauthenticated requests get the login page (API paths get a 401). The OAuth 2.0 Web application client (authorized JavaScript origins must include `http://localhost:8080` for local development) is read from `creds/oauth-client.json` — the JSON downloaded from the Cloud console — or from `GOOGLE_CLIENT_ID` when set; the server refuses to start with neither. After Google sign-in the server issues its own HMAC-signed session cookie; set `SESSION_KEY` to keep sessions valid across restarts and instances (without it each start generates a random key).
To capture authenticated pages with the screenshot tooling, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile. Plain `tools/screenshot` runs a fresh headless browser with no session and captures the login page.
## Local data
The server reads local data from `sampledata/`, mirroring the production Sheets layout: one directory per app, one CSV file per table, first row is the schema. It goes through the same data-source interface production backends implement, so app code never knows which backend it is talking to.
@@ -16,7 +22,7 @@ The server reads local data from `sampledata/`, mirroring the production Sheets
DIRECTORY_SHEET=<spreadsheet id> go run .
switches the directory app to the Google Sheets source. At startup the directory tables are read from the spreadsheet and normalized into the in-memory data model (see `docs/data.md`); the server refuses to start if that load fails, and the model reloads every five minutes. Requires a service account key in `creds/` (any `*.json`; the directory is gitignored) with the Sheets API enabled and the spreadsheet shared read-only with the service account. Real data never leaves the process: nothing is written to disk.
switches the directory app to the Google Sheets source. At startup the directory tables are read from the spreadsheet and normalized into the in-memory data model (see `docs/data.md`); the server refuses to start if that load fails, and the model reloads every five minutes. Requires the service account key at `creds/service-account.json` (the directory is gitignored) with the Sheets API enabled and the spreadsheet shared read-only with the service account. Real data never leaves the process: nothing is written to disk.
## Layout
+4
View File
@@ -51,6 +51,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
@@ -76,6 +78,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w=
+163
View File
@@ -0,0 +1,163 @@
// Package auth gates the server behind google sign-in restricted to the school domain.
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"strings"
"time"
"google.golang.org/api/idtoken"
)
const (
Domain = "heliosschool.org"
cookieName = "session"
sessionLength = 30 * 24 * time.Hour
)
type contextKey struct{}
type Auth struct {
clientID string
key []byte
}
func New(clientID string, key []byte) *Auth {
return &Auth{clientID: clientID, key: key}
}
func Email(r *http.Request) string {
email, _ := r.Context().Value(contextKey{}).(string)
return email
}
func (a *Auth) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/login", a.login)
mux.HandleFunc("POST /auth/logout", a.logout)
}
func (a *Auth) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/login" {
next.ServeHTTP(w, r)
return
}
email := a.sessionEmail(r)
if email == "" {
if strings.Contains(r.URL.Path, "/api/") {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
a.loginPage(w, r)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, email)))
})
}
func (a *Auth) loginPage(w http.ResponseWriter, r *http.Request) {
t, err := template.ParseFiles("web/login.html")
if err != nil {
log.Printf("[ERROR] parse login page: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
err = t.Execute(w, map[string]string{
"ClientID": a.clientID,
"LoginURI": scheme + "://" + r.Host + "/auth/login",
})
if err != nil {
log.Printf("[ERROR] render login page: %v", err)
}
}
func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
csrf, err := r.Cookie("g_csrf_token")
if err != nil || csrf.Value == "" || csrf.Value != r.FormValue("g_csrf_token") {
http.Error(w, "csrf check failed", http.StatusBadRequest)
return
}
payload, err := idtoken.Validate(r.Context(), r.FormValue("credential"), a.clientID)
if err != nil {
log.Printf("[ERROR] validate id token: %v", err)
http.Error(w, "invalid credential", http.StatusUnauthorized)
return
}
email, _ := payload.Claims["email"].(string)
verified, _ := payload.Claims["email_verified"].(bool)
hd, _ := payload.Claims["hd"].(string)
if !verified || hd != Domain || !strings.HasSuffix(email, "@"+Domain) {
http.Error(w, "account is not in the school domain", http.StatusForbidden)
return
}
expiry := time.Now().Add(sessionLength).Unix()
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: a.token(email, expiry),
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionLength.Seconds()),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *Auth) token(email string, expiry int64) string {
payload := fmt.Sprintf("%s|%d", email, expiry)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.sign(payload)
}
func (a *Auth) sign(payload string) string {
mac := hmac.New(sha256.New, a.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *Auth) sessionEmail(r *http.Request) string {
cookie, err := r.Cookie(cookieName)
if err != nil {
return ""
}
parts := strings.SplitN(cookie.Value, ".", 2)
if len(parts) != 2 {
return ""
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return ""
}
payload := string(decoded)
if !hmac.Equal([]byte(a.sign(payload)), []byte(parts[1])) {
return ""
}
fields := strings.Split(payload, "|")
if len(fields) != 2 {
return ""
}
expiry, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil || time.Now().Unix() > expiry {
return ""
}
if !strings.HasSuffix(fields[0], "@"+Domain) {
return ""
}
return fields[0]
}
+4 -14
View File
@@ -3,32 +3,22 @@ package data
import (
"context"
"fmt"
"path/filepath"
"strings"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
const KeyFile = "creds/service-account.json"
type Sheet struct {
service *sheets.Service
spreadsheets map[string]string
}
func KeyFile() (string, error) {
matches, err := filepath.Glob("creds/*.json")
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", fmt.Errorf("no service account key found in creds/")
}
return matches[0], nil
}
func NewSheet(keyFile string, spreadsheets map[string]string) (*Sheet, error) {
func NewSheet(spreadsheets map[string]string) (*Sheet, error) {
service, err := sheets.NewService(context.Background(),
option.WithCredentialsFile(keyFile),
option.WithCredentialsFile(KeyFile),
option.WithScopes(sheets.SpreadsheetsReadonlyScope))
if err != nil {
return nil, err
+38 -6
View File
@@ -2,10 +2,13 @@
package main
import (
"crypto/rand"
"encoding/json"
"log"
"net/http"
"os"
"heliosian/internal/auth"
"heliosian/internal/data"
"heliosian/internal/directory"
)
@@ -15,23 +18,52 @@ func directorySource() data.Source {
if sheetID == "" {
return data.Dir{Root: "sampledata"}
}
keyFile, err := data.KeyFile()
if err != nil {
log.Fatalf("[ERROR] %v", err)
}
source, err := data.NewSheet(keyFile, map[string]string{"directory": sheetID})
source, err := data.NewSheet(map[string]string{"directory": sheetID})
if err != nil {
log.Fatalf("[ERROR] load directory sheet: %v", err)
}
return source
}
func sessionKey() []byte {
if key := os.Getenv("SESSION_KEY"); key != "" {
return []byte(key)
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
log.Fatalf("[ERROR] generate session key: %v", err)
}
log.Printf("SESSION_KEY not set, using a random key; sessions reset on restart")
return key
}
func clientID() string {
if id := os.Getenv("GOOGLE_CLIENT_ID"); id != "" {
return id
}
raw, err := os.ReadFile("creds/oauth-client.json")
if err != nil {
log.Fatalf("[ERROR] read creds/oauth-client.json (or set GOOGLE_CLIENT_ID): %v", err)
}
var parsed struct {
Web struct {
ClientID string `json:"client_id"`
} `json:"web"`
}
if err := json.Unmarshal(raw, &parsed); err != nil || parsed.Web.ClientID == "" {
log.Fatal("[ERROR] creds/oauth-client.json is not an oauth web client file")
}
return parsed.Web.ClientID
}
func main() {
authn := auth.New(clientID(), sessionKey())
cache, err := directory.NewCache(directorySource())
if err != nil {
log.Fatalf("[ERROR] load directory data: %v", err)
}
mux := http.NewServeMux()
authn.Register(mux)
directory.Register(mux, cache)
mux.Handle("GET /{$}", http.RedirectHandler("/directory/", http.StatusFound))
port := os.Getenv("PORT")
@@ -39,5 +71,5 @@ func main() {
port = "8080"
}
log.Printf("listening on http://localhost:%s", port)
log.Fatal(http.ListenAndServe(":"+port, mux))
log.Fatal(http.ListenAndServe(":"+port, authn.Wrap(mux)))
}
+2 -9
View File
@@ -6,20 +6,13 @@ import (
"flag"
"fmt"
"log"
"path/filepath"
"strings"
"heliosian/internal/data"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
func keyFile() string {
matches, err := filepath.Glob("creds/*.json")
if err != nil || len(matches) == 0 {
log.Fatal("[ERROR] no service account key found in creds/")
}
return matches[0]
}
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
@@ -32,7 +25,7 @@ func main() {
log.Fatal("[ERROR] -sheet <spreadsheet id> is required")
}
svc, err := sheets.NewService(context.Background(),
option.WithCredentialsFile(keyFile()),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(sheets.SpreadsheetsReadonlyScope))
if err != nil {
log.Fatalf("[ERROR] create sheets client: %v", err)
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#014E54">
<title>Helios</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #014E54;
font-family: Inter, system-ui, sans-serif;
}
.panel {
background: rgba(255, 255, 255, 0.07);
border-radius: 1rem;
padding: 3rem 3.5rem;
text-align: center;
color: white;
}
.panel h1 {
margin: 0 0 0.5rem;
font-size: 2rem;
}
.panel p {
color: #cfe0e2;
margin: 0 0 2rem;
}
.button-holder {
display: flex;
justify-content: center;
}
</style>
</head>
<body>
<div class="panel">
<h1>Helios</h1>
<p>Sign in with your school Google account.</p>
<div id="g_id_onload" data-client_id="{{.ClientID}}" data-login_uri="{{.LoginURI}}" data-auto_prompt="false"></div>
<div class="button-holder">
<div class="g_id_signin" data-type="standard" data-theme="filled_black" data-size="large" data-text="continue_with"></div>
</div>
</div>
<script src="https://accounts.google.com/gsi/client" async></script>
</body>
</html>