Add Google OAuth authentication

This commit is contained in:
Ian Gulliver
2026-02-14 21:11:25 -08:00
parent 45f4332539
commit 23083bf5a8
5 changed files with 337 additions and 4 deletions

31
go.mod
View File

@@ -2,4 +2,33 @@ module rooms
go 1.25
require github.com/lib/pq v1.11.2
require (
github.com/lib/pq v1.11.2
google.golang.org/api v0.266.0
)
require (
cloud.google.com/go/auth v0.18.1 // 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.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

75
go.sum
View File

@@ -1,2 +1,77 @@
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
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.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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk=
google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

111
main.go
View File

@@ -1,19 +1,30 @@
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
_ "github.com/lib/pq"
"google.golang.org/api/idtoken"
)
//go:embed schema.sql
var schema string
var templates *template.Template
func main() {
dsn := os.Getenv("PGCONN")
if dsn == "" {
@@ -35,8 +46,11 @@ func main() {
log.Fatalf("failed to apply schema: %v", err)
}
http.Handle("/", http.FileServer(http.Dir("static")))
templates = template.Must(template.New("").ParseGlob("static/*.html"))
template.Must(templates.ParseGlob("static/*.js"))
http.HandleFunc("/", handleStatic)
http.HandleFunc("POST /auth/google/callback", handleGoogleCallback)
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
http.Error(w, "db unhealthy", http.StatusServiceUnavailable)
@@ -48,3 +62,98 @@ func main() {
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func templateData() map[string]any {
return map[string]any{
"env": envMap(),
}
}
func envMap() map[string]string {
m := map[string]string{}
for _, e := range os.Environ() {
if parts := strings.SplitN(e, "=", 2); len(parts) == 2 {
m[parts[0]] = parts[1]
}
}
return m
}
func handleStatic(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
path := r.URL.Path
if path == "/" {
path = "/index.html"
}
name := strings.TrimPrefix(path, "/")
if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".js") {
t := templates.Lookup(name)
if t == nil {
http.NotFound(w, r)
return
}
if strings.HasSuffix(name, ".html") {
w.Header().Set("Content-Type", "text/html")
} else {
w.Header().Set("Content-Type", "application/javascript")
}
t.Execute(w, templateData())
return
}
http.ServeFile(w, r, filepath.Join("static", name))
}
func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
credential := r.FormValue("credential")
if credential == "" {
http.Error(w, "missing credential", http.StatusBadRequest)
return
}
payload, err := idtoken.Validate(context.Background(), credential, os.Getenv("GOOGLE_CLIENT_ID"))
if err != nil {
log.Println("failed to validate token:", err)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
email := payload.Claims["email"].(string)
profile := map[string]any{
"email": email,
"name": payload.Claims["name"],
"picture": payload.Claims["picture"],
"token": signEmail(email),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(profile)
}
func signEmail(email string) string {
h := hmac.New(sha256.New, []byte(os.Getenv("TOKEN_SECRET")))
h.Write([]byte(email))
sig := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(email)) + "." + sig
}
func authorize(r *http.Request) (string, bool) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return "", false
}
emailBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
email := string(emailBytes)
if signEmail(email) != token {
return "", false
}
return email, true
}

112
static/app.js Normal file
View File

@@ -0,0 +1,112 @@
const CLIENT_ID = '{{.env.GOOGLE_CLIENT_ID}}';
function getProfile() {
const data = localStorage.getItem('profile');
return data ? JSON.parse(data) : null;
}
function setProfile(profile) {
localStorage.setItem('profile', JSON.stringify(profile));
}
export function logout() {
localStorage.removeItem('profile');
location.reload();
}
export async function api(method, path, body) {
const profile = getProfile();
const opts = {
method,
headers: {
'Content-Type': 'application/json'
}
};
if (profile?.token) {
opts.headers['Authorization'] = 'Bearer ' + profile.token;
}
if (body !== undefined) {
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (!res.ok) {
throw new Error(await res.text());
}
return res.json();
}
function bind(data) {
document.querySelectorAll('[data-bind]').forEach(el => {
const key = el.dataset.bind;
const value = key.split('.').reduce((o, k) => o?.[k], data);
if (el.tagName === 'IMG') {
el.src = value;
} else {
el.textContent = value;
}
});
}
const googleReady = new Promise((resolve) => {
const script = document.createElement('script');
script.src = 'https://accounts.google.com/gsi/client';
script.onload = resolve;
document.head.appendChild(script);
});
export async function init() {
let profile = getProfile();
if (profile) {
bind(profile);
return profile;
}
await googleReady;
const signin = document.getElementById('signin');
signin.style.display = 'flex';
profile = await new Promise((resolve) => {
google.accounts.id.initialize({
client_id: CLIENT_ID,
callback: async (response) => {
try {
const res = await fetch('/auth/google/callback', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'credential=' + encodeURIComponent(response.credential)
});
if (!res.ok) {
throw new Error(`server returned ${res.status}: ${await res.text()}`);
}
const profile = await res.json();
setProfile(profile);
signin.style.display = 'none';
resolve(profile);
} catch (err) {
console.error('sign-in callback error:', err);
alert('Sign-in failed: ' + err.message);
}
},
error_callback: (err) => {
console.error('google sign-in error:', err);
alert('Google sign-in error: ' + (err.message || err.type || JSON.stringify(err)));
}
});
const buttonContainer = document.createElement('div');
signin.appendChild(buttonContainer);
google.accounts.id.renderButton(buttonContainer, {
type: 'standard',
theme: 'filled_black',
size: 'large',
text: 'sign_in_with',
shape: 'pill',
logo_alignment: 'left'
});
});
bind(profile);
return profile;
}

View File

@@ -6,7 +6,15 @@
<title>Rooms</title>
</head>
<body>
<h1>Rooms</h1>
<p>Roommate selection for student trips.</p>
<div id="signin" style="display: none; align-items: center; justify-content: center; height: 100vh;"></div>
<div id="main" style="display: none;">
<h1>Rooms</h1>
<p>Signed in as <span data-bind="email"></span></p>
</div>
<script type="module">
import { init } from '/app.js';
const profile = await init();
document.getElementById('main').style.display = 'block';
</script>
</body>
</html>