Phase 16: Petal authenticates for itself

Petal is now an OIDC client in its own right rather than trusting a header
from the proxy. The Phase-0 Resolver seam was the only integration point:
main.go picks the session store when Authentik is configured and the static
local user otherwise, and no handler or query moved for either.

internal/auth gains three pieces. session.go issues an opaque cookie token
and stores only its SHA-256, so a database copy yields nothing usable; the
30-day expiry slides on every request, throttled to one write an hour, and
logout deletes the row rather than just the cookie. oidc.go runs the
authorization-code flow with state, nonce and PKCE, and discovers the
provider lazily and on retry — an Authentik outage should block new logins
without stopping Petal booting or invalidating live sessions. users.go
provisions accounts from the token's claims and gates them on an allowlist
that matches emails as well as subject ids, since a subject is an opaque
uuid that doesn't exist until someone has already logged in once.

Migration 0010 lands sessions, images and users.pair_lang together. The
images table closes the capability-URL hole the Phase-0 audit flagged: a
hash was previously enough to fetch anyone's picture. Rows are keyed
(name, user_id) so one file can have several owners and deduplication
survives; a stranger gets 404 rather than 403, the cache header drops to
private, and files already on disk are claimed at startup or every image
already pasted into a document would 404.

On the frontend a single 401 interceptor feeds a warm bilingual sign-in
overlay, drawn over a still-visible editor because nothing has been taken
away. Behind it is the part that matters: a save that comes back 401
stashes its body to localStorage before anything else and stops the
auto-save loop, and reopening that document after signing in merges the
draft back and saves it. An expired session must not cost writing.

Writing the round-trip test against a stub identity provider turned up a
real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer,
which runs after the redirect has written the response header, so the
clearing Set-Cookie was silently dropped and they lingered for their full
ten minutes.

Also swaps the emoji favicon for a drawn sakura, which renders as Petal's
own rose palette everywhere instead of whatever each platform's font
decides, and doubles as the app tile in Authentik.

Migration 0010 verified against a VACUUM INTO copy of the live millenia
database: counts intact, FTS still matching, the one existing image
claimed.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 07:21:32 -07:00
parent 42d857a878
commit 1cf207d73f
30 changed files with 2407 additions and 97 deletions
+386
View File
@@ -0,0 +1,386 @@
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
)
// Temporary cookies that carry one login attempt from /auth/login to
// /auth/callback. They live for ten minutes and are cleared the moment the
// callback runs.
const (
stateCookie = "petal_oidc_state"
nonceCookie = "petal_oidc_nonce"
pkceCookie = "petal_oidc_pkce"
loginAttemptTTL = 600 // seconds
)
// Options configures the OIDC client.
type Options struct {
IssuerURL string // Authentik's issuer, e.g. https://auth.example.com/application/o/petal/
ClientID string
ClientSecret string
BaseURL string // Petal's public base URL; the redirect URI is derived from it
Allowed Allowlist
}
// OIDC implements Petal's half of an authorization-code login against
// Authentik: /auth/login starts it, /auth/callback finishes it by provisioning
// the account and issuing a session, /auth/logout ends it.
//
// Petal is the OIDC client itself rather than trusting a proxy-injected header.
// The header approach is far less code, but it is only safe while the container
// is unreachable except through that proxy — an invariant enforced by network
// configuration, not by anything in the repository, on a public host that also
// runs half a dozen other services. Petal holds someone's private journals; it
// should be safe to expose directly.
type OIDC struct {
opts Options
sessions *SessionStore
users *UserStore
secure bool
// The provider is discovered over the network, which means it can fail at
// startup for reasons that have nothing to do with Petal. Discovery is
// therefore lazy and retried: an Authentik outage blocks new logins but
// leaves every existing session working, since those only need the database.
mu sync.Mutex
provider *oidc.Provider
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
}
// NewOIDC builds the login flow. It attempts discovery once so a misconfigured
// issuer shows up in the startup log rather than on the writer's first login,
// but a failure here is not fatal.
func NewOIDC(ctx context.Context, opts Options, sessions *SessionStore, users *UserStore) *OIDC {
o := &OIDC{
opts: opts,
sessions: sessions,
users: users,
secure: strings.HasPrefix(strings.ToLower(opts.BaseURL), "https://"),
}
if err := o.discover(ctx); err != nil {
log.Printf("auth: OIDC discovery failed (%v) — login will retry on demand", err)
}
return o
}
// RedirectURI is the callback Authentik must have registered for this client.
func (o *OIDC) RedirectURI() string {
return strings.TrimSuffix(o.opts.BaseURL, "/") + "/auth/callback"
}
// discover resolves the provider metadata and builds the oauth2 config.
func (o *OIDC) discover(ctx context.Context) error {
o.mu.Lock()
defer o.mu.Unlock()
if o.provider != nil {
return nil
}
provider, err := oidc.NewProvider(ctx, strings.TrimSuffix(o.opts.IssuerURL, "/"))
if err != nil {
return err
}
o.provider = provider
o.verifier = provider.Verifier(&oidc.Config{ClientID: o.opts.ClientID})
o.oauth = &oauth2.Config{
ClientID: o.opts.ClientID,
ClientSecret: o.opts.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: o.RedirectURI(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return nil
}
// ready returns the discovered client, discovering it first if an earlier
// attempt failed.
func (o *OIDC) ready(ctx context.Context) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
if err := o.discover(ctx); err != nil {
return nil, nil, err
}
o.mu.Lock()
defer o.mu.Unlock()
return o.oauth, o.verifier, nil
}
// Routes mounts the login endpoints. Mount at "/auth", outside /api: these are
// browser navigations, not API calls, and they must be reachable without a
// session — that is their whole purpose.
func (o *OIDC) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/login", o.login)
r.Get("/callback", o.callback)
r.Get("/logout", o.logout)
r.Post("/logout", o.logout)
return r
}
// login starts an authorization-code flow with PKCE.
func (o *OIDC) login(w http.ResponseWriter, r *http.Request) {
// Already signed in? Don't bounce a valid session through the IdP.
if _, err := o.sessions.Resolve(r); err == nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
conf, _, err := o.ready(r.Context())
if err != nil {
o.page(w, http.StatusServiceUnavailable,
"登录暂时不可用", "Sign-in is unavailable right now",
"Petal 联系不上登录服务。请稍后再试。",
"Petal can't reach the sign-in service. Please try again in a moment.")
return
}
state, err := randomToken()
if err != nil {
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong", "请再试一次。", "Please try again.")
return
}
nonce, err := randomToken()
if err != nil {
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong", "请再试一次。", "Please try again.")
return
}
pkce := oauth2.GenerateVerifier()
// state defends the callback against CSRF (a forged callback can't know the
// cookie); nonce ties the returned ID token to this attempt; PKCE binds the
// code to this client even if it leaks in transit.
o.setTemp(w, stateCookie, state)
o.setTemp(w, nonceCookie, nonce)
o.setTemp(w, pkceCookie, pkce)
http.Redirect(w, r, conf.AuthCodeURL(state,
oidc.Nonce(nonce),
oauth2.S256ChallengeOption(pkce),
), http.StatusFound)
}
// callback completes the flow: verify, allowlist, provision, issue a session.
func (o *OIDC) callback(w http.ResponseWriter, r *http.Request) {
// Expire the one-shot login cookies up front, not on the way out: every exit
// from here writes a response, and a Set-Cookie added after the header is
// written is silently dropped. They're read from the request below, so
// clearing them on the response now costs nothing.
o.clearTemp(w)
if errParam := r.URL.Query().Get("error"); errParam != "" {
o.page(w, http.StatusForbidden,
"登录未完成", "Sign-in didn't finish",
"登录服务拒绝了这次请求。你可以再试一次。",
"The sign-in service turned that request down. You can try again.")
return
}
state, err := r.Cookie(stateCookie)
if err != nil || state.Value == "" ||
subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
o.page(w, http.StatusBadRequest,
"这个登录链接过期了", "That sign-in link expired",
"请回到 Petal 重新登录。",
"Head back to Petal and sign in again.")
return
}
conf, verifier, err := o.ready(r.Context())
if err != nil {
o.page(w, http.StatusServiceUnavailable,
"登录暂时不可用", "Sign-in is unavailable right now",
"Petal 联系不上登录服务。请稍后再试。",
"Petal can't reach the sign-in service. Please try again in a moment.")
return
}
pkce, err := r.Cookie(pkceCookie)
if err != nil {
o.page(w, http.StatusBadRequest, "这个登录链接过期了", "That sign-in link expired",
"请回到 Petal 重新登录。", "Head back to Petal and sign in again.")
return
}
token, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(pkce.Value))
if err != nil {
log.Printf("auth: code exchange failed: %v", err)
o.page(w, http.StatusBadGateway, "登录没有成功", "Sign-in didn't go through",
"请再试一次。", "Please try again.")
return
}
claims, err := o.claims(r.Context(), verifier, token)
if err != nil {
log.Printf("auth: id token rejected: %v", err)
o.page(w, http.StatusBadGateway, "登录没有成功", "Sign-in didn't go through",
"请再试一次。", "Please try again.")
return
}
// Nonce check: this ID token must belong to the attempt that started here.
nonce, err := r.Cookie(nonceCookie)
if err != nil || subtle.ConstantTimeCompare([]byte(nonce.Value), []byte(claims.nonce)) != 1 {
o.page(w, http.StatusBadRequest, "这个登录链接过期了", "That sign-in link expired",
"请回到 Petal 重新登录。", "Head back to Petal and sign in again.")
return
}
if !o.opts.Allowed.Permits(claims.Subject, claims.Email) {
log.Printf("auth: rejected sign-in for sub=%s email=%s (not on the allowlist)", claims.Subject, claims.Email)
o.page(w, http.StatusForbidden,
"这个 Petal 不是给你写的", "This Petal isn't yours to write in",
"你的账号是有效的,但还没有被邀请到这个 Petal。如果这是个误会,找管理员说一声就好。",
"Your account is valid, but it hasn't been invited to this Petal. If that's a mistake, a word with whoever runs it will sort it out.")
return
}
if err := o.users.Upsert(claims.Subject, claims.Email, claims.displayName()); err != nil {
log.Printf("auth: provisioning failed: %v", err)
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong",
"请再试一次。", "Please try again.")
return
}
session, err := o.sessions.Create(claims.Subject, r.UserAgent())
if err != nil {
log.Printf("auth: session creation failed: %v", err)
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong",
"请再试一次。", "Please try again.")
return
}
SetSessionCookie(w, session, o.secure)
log.Printf("auth: signed in %s (%s)", claims.Email, claims.Subject)
http.Redirect(w, r, "/", http.StatusFound)
}
// logout revokes the session server-side and clears the cookie. Doing both
// matters: clearing only the cookie leaves a token that still works if it was
// ever captured.
func (o *OIDC) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
if err := o.sessions.Revoke(c.Value); err != nil {
log.Printf("auth: revoke failed: %v", err)
}
}
ClearSessionCookie(w, o.secure)
http.Redirect(w, r, "/", http.StatusFound)
}
// idClaims is the subset of the ID token Petal cares about.
type idClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
nonce string
}
func (c idClaims) displayName() string {
if c.Name != "" {
return c.Name
}
if c.PreferredUsername != "" {
return c.PreferredUsername
}
return c.Email
}
// claims verifies the ID token in a token response and extracts its claims.
func (o *OIDC) claims(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token) (idClaims, error) {
raw, ok := token.Extra("id_token").(string)
if !ok || raw == "" {
return idClaims{}, errors.New("no id_token in the token response")
}
idToken, err := verifier.Verify(ctx, raw)
if err != nil {
return idClaims{}, err
}
var claims idClaims
if err := idToken.Claims(&claims); err != nil {
return idClaims{}, err
}
if claims.Subject == "" {
claims.Subject = idToken.Subject
}
claims.nonce = idToken.Nonce
return claims, nil
}
func (o *OIDC) setTemp(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/auth",
HttpOnly: true,
Secure: o.secure,
SameSite: http.SameSiteLaxMode,
MaxAge: loginAttemptTTL,
})
}
func (o *OIDC) clearTemp(w http.ResponseWriter) {
for _, name := range []string{stateCookie, nonceCookie, pkceCookie} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/auth",
HttpOnly: true, Secure: o.secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
}
func randomToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// page renders one of the flow's dead ends. Every one of them is a full stop in
// front of someone who was just trying to write, so they read as warm bilingual
// sentences rather than as a status code — the same standard as the rest of the
// app, and the reason these aren't plain http.Error calls.
func (o *OIDC) page(w http.ResponseWriter, status int, titleZH, titleEN, bodyZH, bodyEN string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, `<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s · Petal</title>
<style>
:root { color-scheme: light dark; }
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:#fdf8f5; color:#5b4b52;
font-family:'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',system-ui,sans-serif; }
main { max-width:30rem; padding:2.5rem; text-align:center; }
.mark { font-size:2.5rem; }
h1 { font-size:1.5rem; margin:.75rem 0 .25rem; font-weight:700; }
h2 { font-size:1rem; margin:0 0 1.25rem; font-weight:600; opacity:.65; }
p { line-height:1.7; margin:.4rem 0; }
p.en { opacity:.7; font-size:.95rem; }
a { display:inline-block; margin-top:1.75rem; padding:.6rem 1.4rem; border-radius:999px;
background:#f3c7d3; color:#5b4b52; text-decoration:none; font-weight:700; }
@media (prefers-color-scheme: dark) { body { background:#231b28; color:#e9dfe6; } a { background:#7c5f78; color:#fdf8f5; } }
</style></head>
<body><main>
<div class="mark">🌸</div>
<h1>%s</h1><h2>%s</h2>
<p>%s</p><p class="en">%s</p>
<a href="/">回到 Petal · Back to Petal</a>
</main></body></html>
`, titleEN, titleZH, titleEN, bodyZH, bodyEN)
}