feat(auth): rip federation, give projax its own /login
mgmt.msbls.de is being retired; depending on it for auth was the wrong
direction. Match the mBrian / flexsiebels pattern instead — same
Supabase backend, but every tool runs its own login page and scopes
cookies to its own host.
Routes
- GET /login render a sign-in form (mBrian dark visual). If the
request already has a valid session, jump to a safe
redirectTo (or /).
- POST /login exchange email+password at /auth/v1/token?grant_type=
password, set cookies, 302 → redirectTo or /. On
Supabase 4xx, re-render the form with the error.
- POST /logout clear both cookies (Max-Age=-1) + 302 → /login.
Cookies
- access_token + refresh_token only. No Domain attribute → scope is
projax.msbls.de exclusively. HttpOnly, Secure, SameSite=Lax, Path=/,
Max-Age=1y. Matches mBrian + flexsiebels per-host pattern.
Middleware
- /healthz, /login, /logout always pass through (otherwise infinite
redirect on the probe / login page).
- On invalid/expired session → 302 /login?redirectTo=<safe-path>,
RELATIVE to projax. No more cross-host bounce.
- Cookie refresh on expiry still rotates both cookies in place.
- Bearer header path kept for scripted clients.
safeRedirect
- Path-only. Rejects "", "//*", "https://*", "\*", control-char
injection. Cross-host or scheme bounces fall back to "/". Tested
against the obvious bypasses.
Cleanup
- Drop PROJAX_LOGIN_URL + PROJAX_COOKIE_DOMAIN env vars (unused now).
- main.go: log "auth: own-login enabled" with the supabase URL on
startup; warn loudly when SUPABASE_URL is unset.
- README trust-model section rewritten: own login, per-host cookies,
same backend.
- layout.tmpl gains a "sign out" form-button in the nav so the tree /
detail / classify pages can log out without curl.
Tests (14, no DB needed): stub Supabase via httptest covers
healthz/login/logout exemption, anonymous→/login redirect, valid
cookie + Bearer pass-through, stale-refresh rotation with NO Domain
attribute, hard-fail redirect, GET form render with redirectTo carry,
already-signed-in short-circuit, POST success with correct cookies,
POST bad-creds error surface, redirectTo safety (path-only, no //,
no absolute URLs), logout cookie clearance.
Full suite (incl. DB-backed): 27/27 green with PROJAX_SKIP_MIGRATE=1.
This commit is contained in:
21
README.md
21
README.md
@@ -14,9 +14,7 @@ go run ./cmd/projax
|
||||
Defaults:
|
||||
- `PROJAX_LISTEN_ADDR=:8080`
|
||||
- `PROJAX_AUTO_MIGRATE=on` (set to `off` to skip on-start migration apply)
|
||||
- `SUPABASE_URL` + `SUPABASE_ANON_KEY` enable cookie-based auth federated with `mgmt.msbls.de`. Leave unset for local dev — every request is anonymous.
|
||||
- `PROJAX_LOGIN_URL` overrides the redirect target (default `https://mgmt.msbls.de/login`).
|
||||
- `PROJAX_COOKIE_DOMAIN` overrides the refresh-cookie Domain attribute (default `msbls.de`, matching mgmt's `auth.ts`).
|
||||
- `SUPABASE_URL` + `SUPABASE_ANON_KEY` enable projax's own `/login`. Same Supabase backend as the rest of the m/* fleet, but every tool runs its own login page and scopes cookies per-host. Leave both unset for local dev — every request is anonymous.
|
||||
|
||||
Visit `http://localhost:8080/`. Routes:
|
||||
|
||||
@@ -29,7 +27,10 @@ Visit `http://localhost:8080/`. Routes:
|
||||
| `GET /new?parent={path}` | Create a new item (area at root, project under parent) |
|
||||
| `POST /new` | Submit |
|
||||
| `GET /admin/classify` | Orphan list with inline HTMX promote |
|
||||
| `GET /healthz` | DB ping |
|
||||
| `GET /login` | Sign-in form (open) |
|
||||
| `POST /login` | Sign-in submit (open) |
|
||||
| `POST /logout` | Clear cookies, redirect to `/login` |
|
||||
| `GET /healthz` | DB ping (open) |
|
||||
| `GET /static/style.css` | Embedded CSS |
|
||||
|
||||
## Test
|
||||
@@ -85,12 +86,14 @@ The image is a distroless static container running as `nonroot`. Total image siz
|
||||
|
||||
## Trust model (v1)
|
||||
|
||||
Single-user. **Public over HTTPS, gated by Supabase JWT cookie federated with `mgmt.msbls.de`.** No anonymous routes except `/healthz` (Dokploy/Traefik probe).
|
||||
Single-user. **Public over HTTPS, gated by projax's own Supabase login.** No anonymous routes except `/healthz` (Dokploy/Traefik probe), `/login` and `/logout`.
|
||||
|
||||
- Browser arrives without a session → `302 https://mgmt.msbls.de/login?redirectTo=<original-url>`.
|
||||
- mgmt's login (Supabase email+password) sets `access_token` + `refresh_token` cookies on the parent `msbls.de` domain (no leading dot — matches mgmt/auth.ts verbatim) so every subdomain shares the session.
|
||||
- Each projax request validates the cookie against `<SUPABASE_URL>/auth/v1/user`. On expiry, projax silently refreshes via `/auth/v1/token?grant_type=refresh_token` and rotates both cookies, preserving SSO across the fleet.
|
||||
- The middleware also accepts `Authorization: Bearer <token>` for scripted clients — same surface mgmt exposes.
|
||||
- Browser arrives without a session → `302 /login?redirectTo=<safe-path>`.
|
||||
- `/login` posts to `<SUPABASE_URL>/auth/v1/token?grant_type=password` with the m/* user account. On success projax sets `access_token` and `refresh_token` cookies (HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=1y, **no Domain attribute** so they are scoped to `projax.msbls.de` only).
|
||||
- Every request after that validates the cookie against `/auth/v1/user`. On expiry, projax silently refreshes via `/auth/v1/token?grant_type=refresh_token` and rotates both cookies. The middleware also accepts `Authorization: Bearer <token>` for scripted clients.
|
||||
- `/logout` clears both cookies and bounces to `/login`.
|
||||
- `redirectTo` is path-only (`/`-prefixed, no `//`, no escape sequences). Cross-host bounces are rejected and fall back to `/`.
|
||||
- Same Supabase backend as the rest of the m/* fleet (mBrian, flexsiebels, …); each tool keeps its own login + cookie scope.
|
||||
- DB role is `projax_admin` — full rights on `projax.*`, read-only on `mai.projects` via an explicit RLS policy, blocked on every other schema (see deploy step 0).
|
||||
- `PROJAX_DB_URL` + `SUPABASE_ANON_KEY` live in Dokploy secrets, never the repo.
|
||||
|
||||
|
||||
@@ -67,23 +67,13 @@ func main() {
|
||||
logger.Error("SUPABASE_URL set but SUPABASE_ANON_KEY missing — refusing to start")
|
||||
os.Exit(1)
|
||||
}
|
||||
loginURL := os.Getenv("PROJAX_LOGIN_URL")
|
||||
if loginURL == "" {
|
||||
loginURL = "https://mgmt.msbls.de/login"
|
||||
}
|
||||
cookieDomain := os.Getenv("PROJAX_COOKIE_DOMAIN")
|
||||
if cookieDomain == "" {
|
||||
cookieDomain = "msbls.de"
|
||||
}
|
||||
srv.Auth = &web.AuthConfig{
|
||||
SupabaseURL: supaURL,
|
||||
AnonKey: anon,
|
||||
LoginURL: loginURL,
|
||||
CookieDomain: cookieDomain,
|
||||
SupabaseURL: supaURL,
|
||||
AnonKey: anon,
|
||||
}
|
||||
logger.Info("auth: federation enabled", "supabase", supaURL, "login", loginURL, "cookie_domain", cookieDomain)
|
||||
logger.Info("auth: own-login enabled", "supabase", supaURL)
|
||||
} else {
|
||||
logger.Warn("auth: federation disabled — SUPABASE_URL not set, every request is anonymous")
|
||||
logger.Warn("auth: disabled — SUPABASE_URL not set, every request is anonymous")
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
|
||||
190
web/auth.go
190
web/auth.go
@@ -14,21 +14,31 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthConfig federates projax with mgmt.msbls.de. Cookies set on the parent
|
||||
// `msbls.de` domain (no leading dot — matches mgmt/auth.ts default verbatim)
|
||||
// roundtrip across every msbls.de subdomain.
|
||||
// AuthConfig backs projax's own /login. Same Supabase backend the other m/*
|
||||
// tools use, but cookies are per-host (no Domain attribute) so projax does not
|
||||
// share its session with any other subdomain. Matches the mBrian / flexsiebels
|
||||
// pattern.
|
||||
type AuthConfig struct {
|
||||
SupabaseURL string // e.g. https://supa.flexsiebels.de
|
||||
AnonKey string
|
||||
LoginURL string // e.g. https://mgmt.msbls.de/login
|
||||
CookieDomain string // e.g. msbls.de (no leading dot; mgmt parity)
|
||||
HTTPClient *http.Client
|
||||
SupabaseURL string // e.g. https://supa.flexsiebels.de
|
||||
AnonKey string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// client returns a usable *http.Client even when HTTPClient is unset, so
|
||||
// helpers can be called directly (e.g. from /login GET) without explicit init.
|
||||
func (cfg AuthConfig) client() *http.Client {
|
||||
if cfg.HTTPClient != nil {
|
||||
return cfg.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
|
||||
const (
|
||||
accessTokenCookie = "access_token"
|
||||
refreshTokenCookie = "refresh_token"
|
||||
cookieMaxAge = 365 * 24 * 60 * 60
|
||||
loginPath = "/login"
|
||||
logoutPath = "/logout"
|
||||
)
|
||||
|
||||
// supabaseUser is the minimum slice of GET /auth/v1/user we read.
|
||||
@@ -37,7 +47,8 @@ type supabaseUser struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// supabaseSession is what /auth/v1/token?grant_type=refresh_token returns.
|
||||
// supabaseSession is what /auth/v1/token returns for either grant type
|
||||
// (password or refresh_token).
|
||||
type supabaseSession struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -46,14 +57,34 @@ type supabaseSession struct {
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
// authMiddleware gates every request except /healthz. The /healthz exemption
|
||||
// is required because Dokploy/Traefik probes must not be redirected to login.
|
||||
func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http.Handler {
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||
// supabaseAuthError is the {error, error_description, msg} shape Supabase
|
||||
// returns on a 4xx from /auth/v1/*.
|
||||
type supabaseAuthError struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
func (e supabaseAuthError) Message() string {
|
||||
switch {
|
||||
case e.ErrorDescription != "":
|
||||
return e.ErrorDescription
|
||||
case e.Msg != "":
|
||||
return e.Msg
|
||||
case e.Error != "":
|
||||
return e.Error
|
||||
default:
|
||||
return "Login failed"
|
||||
}
|
||||
}
|
||||
|
||||
// authMiddleware gates every request except /healthz, /login and /logout.
|
||||
// On invalid session it 302s to /login?redirectTo=<safe-path>.
|
||||
func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
// Always-open routes: probe and auth endpoints themselves.
|
||||
switch r.URL.Path {
|
||||
case "/healthz", loginPath, logoutPath:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -66,7 +97,6 @@ func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
// Try the access token first.
|
||||
if access != "" {
|
||||
if _, err := cfg.validateAccessToken(ctx, access); err == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -74,7 +104,6 @@ func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to refresh-on-expiry.
|
||||
if c, err := r.Cookie(refreshTokenCookie); err == nil && c.Value != "" {
|
||||
sess, err := cfg.refreshSession(ctx, c.Value)
|
||||
if err == nil {
|
||||
@@ -85,13 +114,11 @@ func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http
|
||||
logger.Debug("auth: refresh failed", "err", err)
|
||||
}
|
||||
|
||||
// No valid session — redirect to mgmt login with the original URL.
|
||||
http.Redirect(w, r, cfg.loginRedirectURL(r), http.StatusFound)
|
||||
http.Redirect(w, r, loginRedirectURL(r), http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
// tokenFromBearer extracts a Bearer token from the Authorization header.
|
||||
// Empty string means no Bearer credential present.
|
||||
func tokenFromBearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
@@ -102,7 +129,6 @@ func tokenFromBearer(r *http.Request) string {
|
||||
}
|
||||
|
||||
// validateAccessToken calls GET <SUPABASE_URL>/auth/v1/user with the bearer.
|
||||
// Returns the user on success or an error on any non-2xx response.
|
||||
func (cfg AuthConfig) validateAccessToken(ctx context.Context, token string) (*supabaseUser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.SupabaseURL+"/auth/v1/user", nil)
|
||||
if err != nil {
|
||||
@@ -110,7 +136,7 @@ func (cfg AuthConfig) validateAccessToken(ctx context.Context, token string) (*s
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("apikey", cfg.AnonKey)
|
||||
resp, err := cfg.HTTPClient.Do(req)
|
||||
resp, err := cfg.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -132,80 +158,108 @@ func (cfg AuthConfig) validateAccessToken(ctx context.Context, token string) (*s
|
||||
// refreshSession swaps a refresh token for a fresh access/refresh pair.
|
||||
func (cfg AuthConfig) refreshSession(ctx context.Context, refresh string) (*supabaseSession, error) {
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": refresh})
|
||||
return cfg.tokenRequest(ctx, "refresh_token", body)
|
||||
}
|
||||
|
||||
// passwordSignIn exchanges email+password for a session via /auth/v1/token.
|
||||
func (cfg AuthConfig) passwordSignIn(ctx context.Context, email, password string) (*supabaseSession, error) {
|
||||
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
return cfg.tokenRequest(ctx, "password", body)
|
||||
}
|
||||
|
||||
func (cfg AuthConfig) tokenRequest(ctx context.Context, grant string, body []byte) (*supabaseSession, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
cfg.SupabaseURL+"/auth/v1/token?grant_type=refresh_token",
|
||||
cfg.SupabaseURL+"/auth/v1/token?grant_type="+grant,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("apikey", cfg.AnonKey)
|
||||
resp, err := cfg.HTTPClient.Do(req)
|
||||
resp, err := cfg.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("supabase refresh: %d %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var ae supabaseAuthError
|
||||
_ = json.Unmarshal(raw, &ae)
|
||||
return nil, fmt.Errorf("supabase /auth/v1/token (%s): %s", grant, ae.Message())
|
||||
}
|
||||
var s supabaseSession
|
||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.AccessToken == "" || s.RefreshToken == "" {
|
||||
return nil, errors.New("supabase refresh: empty token in response")
|
||||
return nil, errors.New("supabase /auth/v1/token: empty token in response")
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// setSessionCookies writes refreshed access/refresh cookies. Domain, Secure,
|
||||
// HttpOnly and SameSite match mgmt.msbls.de exactly so the same browser session
|
||||
// continues to work after the round-trip.
|
||||
// setSessionCookies writes per-host access/refresh cookies. No Domain attribute
|
||||
// — scope is projax.msbls.de only, matching mbrian + flexsiebels.
|
||||
func (cfg AuthConfig) setSessionCookies(w http.ResponseWriter, s *supabaseSession) {
|
||||
for _, c := range []*http.Cookie{
|
||||
{
|
||||
Name: accessTokenCookie,
|
||||
Value: s.AccessToken,
|
||||
Domain: cfg.CookieDomain,
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
},
|
||||
{
|
||||
Name: refreshTokenCookie,
|
||||
Value: s.RefreshToken,
|
||||
Domain: cfg.CookieDomain,
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
},
|
||||
sessionCookie(accessTokenCookie, s.AccessToken),
|
||||
sessionCookie(refreshTokenCookie, s.RefreshToken),
|
||||
} {
|
||||
http.SetCookie(w, c)
|
||||
}
|
||||
}
|
||||
|
||||
// loginRedirectURL builds the mgmt.msbls.de/login?redirectTo=... target.
|
||||
// redirectTo carries the original public URL so the user lands back where they
|
||||
// started after login.
|
||||
func (cfg AuthConfig) loginRedirectURL(r *http.Request) string {
|
||||
scheme := "https"
|
||||
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
|
||||
// Behind Traefik we always run on https in production; this branch is
|
||||
// reached only in local dev. Preserve original scheme so the round-trip
|
||||
// works there too.
|
||||
scheme = "http"
|
||||
func sessionCookie(name, value string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
host := r.Host
|
||||
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
|
||||
host = fwd
|
||||
}
|
||||
original := scheme + "://" + host + r.URL.RequestURI()
|
||||
q := url.Values{}
|
||||
q.Set("redirectTo", original)
|
||||
return cfg.LoginURL + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// clearCookie returns a Set-Cookie that erases `name` on the browser (Max-Age=0).
|
||||
func clearCookie(name string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// loginRedirectURL builds /login?redirectTo=<safe-path>.
|
||||
func loginRedirectURL(r *http.Request) string {
|
||||
target := safeRedirect(r.URL.RequestURI())
|
||||
if target == "" || target == loginPath {
|
||||
return loginPath
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("redirectTo", target)
|
||||
return loginPath + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// safeRedirect rejects anything that is not a same-origin path. Mirrors the
|
||||
// mgmt safeRedirect: must start with "/", must not start with "//", must not
|
||||
// contain "\" (Windows-style URL trick).
|
||||
func safeRedirect(value string) string {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(v, "/") {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(v, "//") {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(v, "\\\r\n\t") {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
395
web/auth_test.go
395
web/auth_test.go
@@ -6,19 +6,22 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newFakeSupabase spins up a tiny stub that mimics /auth/v1/user and
|
||||
// /auth/v1/token?grant_type=refresh_token. The stub honours simple in-memory
|
||||
// token validity rules so middleware paths can be exercised without a real DB.
|
||||
// fakeSupabase stubs the three /auth/v1 endpoints we touch.
|
||||
type fakeSupabase struct {
|
||||
*httptest.Server
|
||||
ValidAccess string
|
||||
ValidRefresh string
|
||||
NewAccess string
|
||||
NewRefresh string
|
||||
ValidEmail string
|
||||
ValidPass string
|
||||
IssuedAccess string
|
||||
IssuedRefr string
|
||||
}
|
||||
|
||||
func newFakeSupabase(t *testing.T) *fakeSupabase {
|
||||
@@ -28,41 +31,57 @@ func newFakeSupabase(t *testing.T) *fakeSupabase {
|
||||
ValidRefresh: "good-refresh",
|
||||
NewAccess: "rotated-access",
|
||||
NewRefresh: "rotated-refresh",
|
||||
ValidEmail: "m@example",
|
||||
ValidPass: "correct-horse-battery-staple",
|
||||
IssuedAccess: "issued-access",
|
||||
IssuedRefr: "issued-refresh",
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/v1/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer "+f.ValidAccess {
|
||||
if r.Header.Get("Authorization") != "Bearer "+f.ValidAccess {
|
||||
http.Error(w, `{"msg":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "user-1", "email": "m@example"})
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "u-1", "email": f.ValidEmail})
|
||||
})
|
||||
mux.HandleFunc("/auth/v1/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("grant_type") != "refresh_token" {
|
||||
http.Error(w, "bad grant", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
grant := r.URL.Query().Get("grant_type")
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.RefreshToken != f.ValidRefresh {
|
||||
http.Error(w, `{"msg":"bad refresh"}`, http.StatusBadRequest)
|
||||
return
|
||||
switch grant {
|
||||
case "password":
|
||||
if body["email"] != f.ValidEmail || body["password"] != f.ValidPass {
|
||||
http.Error(w, `{"error_description":"Invalid login credentials"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": f.IssuedAccess,
|
||||
"refresh_token": f.IssuedRefr,
|
||||
"user": map[string]string{"id": "u-1"},
|
||||
})
|
||||
case "refresh_token":
|
||||
if body["refresh_token"] != f.ValidRefresh {
|
||||
http.Error(w, `{"msg":"bad refresh"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": f.NewAccess,
|
||||
"refresh_token": f.NewRefresh,
|
||||
"user": map[string]string{"id": "u-1"},
|
||||
})
|
||||
default:
|
||||
http.Error(w, "bad grant", http.StatusBadRequest)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": f.NewAccess,
|
||||
"refresh_token": f.NewRefresh,
|
||||
"user": map[string]string{"id": "user-1"},
|
||||
})
|
||||
})
|
||||
f.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(f.Server.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func newGatedHandler(t *testing.T, supaURL, anonKey string) http.Handler {
|
||||
// gatedMux wires a tiny app behind authMiddleware. It exposes /, /healthz,
|
||||
// /login (always-open), /logout (always-open) so the middleware tests can
|
||||
// exercise the gate without spinning up the real Server.
|
||||
func gatedMux(t *testing.T, supaURL, anon string) http.Handler {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -71,137 +90,305 @@ func newGatedHandler(t *testing.T, supaURL, anonKey string) http.Handler {
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
})
|
||||
cfg := AuthConfig{
|
||||
SupabaseURL: supaURL,
|
||||
AnonKey: anonKey,
|
||||
LoginURL: "https://mgmt.msbls.de/login",
|
||||
CookieDomain: "msbls.de",
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "login-form")
|
||||
})
|
||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, clearCookie(accessTokenCookie))
|
||||
http.SetCookie(w, clearCookie(refreshTokenCookie))
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
})
|
||||
cfg := AuthConfig{SupabaseURL: supaURL, AnonKey: anon}
|
||||
return authMiddleware(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), mux)
|
||||
}
|
||||
|
||||
func TestSafeRedirect(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/i/dev": "/i/dev",
|
||||
"/": "/",
|
||||
"": "",
|
||||
"//evil.com": "",
|
||||
"https://evil.com": "",
|
||||
"javascript:alert": "",
|
||||
`/path\nset-cookie`: "",
|
||||
`\evil`: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := safeRedirect(in); got != want {
|
||||
t.Errorf("safeRedirect(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return authMiddleware(cfg, logger, mux)
|
||||
}
|
||||
|
||||
func TestHealthzAlwaysOpen(t *testing.T) {
|
||||
supa := newFakeSupabase(t)
|
||||
h := newGatedHandler(t, supa.URL, "anon")
|
||||
f := newFakeSupabase(t)
|
||||
h := gatedMux(t, f.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("healthz status %d", w.Result().StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(w.Body.String()) != "ok" {
|
||||
t.Fatalf("healthz body %q", w.Body.String())
|
||||
if w.Result().StatusCode != 200 {
|
||||
t.Fatalf("healthz: %d", w.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousRequestRedirectsToMgmt(t *testing.T) {
|
||||
supa := newFakeSupabase(t)
|
||||
h := newGatedHandler(t, supa.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "https://projax.msbls.de/i/home", nil)
|
||||
r.TLS = nil // httptest doesn't set TLS; loginRedirectURL falls back to http
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
r.Header.Set("X-Forwarded-Host", "projax.msbls.de")
|
||||
func TestLoginPathBypassesAuth(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
h := gatedMux(t, f.URL, "anon")
|
||||
for _, path := range []string{"/login", "/logout"} {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
// /login → 200 (the test mux serves a form). /logout → 302 (clears cookies).
|
||||
if w.Result().StatusCode == http.StatusFound && path == "/logout" {
|
||||
continue
|
||||
}
|
||||
if w.Result().StatusCode != 200 {
|
||||
t.Fatalf("%s: status %d", path, w.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthedRedirectsToLocalLogin(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
h := gatedMux(t, f.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "/i/dev", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Result().StatusCode != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302", w.Result().StatusCode)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "https://mgmt.msbls.de/login?") {
|
||||
t.Fatalf("Location = %q, want mgmt.msbls.de/login prefix", loc)
|
||||
if !strings.HasPrefix(loc, "/login?") {
|
||||
t.Fatalf("Location = %q, want /login? prefix", loc)
|
||||
}
|
||||
if !strings.Contains(loc, "redirectTo=") {
|
||||
t.Fatalf("Location missing redirectTo: %q", loc)
|
||||
}
|
||||
if !strings.Contains(loc, "projax.msbls.de") {
|
||||
t.Fatalf("Location missing host in redirectTo: %q", loc)
|
||||
// Must NOT bounce to another host.
|
||||
if strings.Contains(loc, "msbls.de") || strings.HasPrefix(loc, "http") {
|
||||
t.Fatalf("Location should be relative to projax: %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidAccessCookieRedirects(t *testing.T) {
|
||||
supa := newFakeSupabase(t)
|
||||
h := newGatedHandler(t, supa.URL, "anon")
|
||||
func TestValidCookieAuthorizes(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
h := gatedMux(t, f.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "access_token", Value: "stale"})
|
||||
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: f.ValidAccess})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Result().StatusCode != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302", w.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidAccessCookiePassesThrough(t *testing.T) {
|
||||
supa := newFakeSupabase(t)
|
||||
h := newGatedHandler(t, supa.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "access_token", Value: supa.ValidAccess})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Result().StatusCode)
|
||||
if w.Result().StatusCode != 200 {
|
||||
t.Fatalf("status %d", w.Result().StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(w.Body.String()) != "tree-page" {
|
||||
t.Fatalf("body = %q", w.Body.String())
|
||||
t.Fatalf("body %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerHeaderPassesThrough(t *testing.T) {
|
||||
supa := newFakeSupabase(t)
|
||||
h := newGatedHandler(t, supa.URL, "anon")
|
||||
func TestBearerHeaderAuthorizes(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
h := gatedMux(t, f.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+supa.ValidAccess)
|
||||
r.Header.Set("Authorization", "Bearer "+f.ValidAccess)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200 via Bearer", w.Result().StatusCode)
|
||||
if w.Result().StatusCode != 200 {
|
||||
t.Fatalf("status %d", w.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleAccessRefreshesAndPassesThrough(t *testing.T) {
|
||||
supa := newFakeSupabase(t)
|
||||
h := newGatedHandler(t, supa.URL, "anon")
|
||||
func TestStaleAccessRefreshesAndIssuesCookies(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
h := gatedMux(t, f.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "access_token", Value: "stale"})
|
||||
r.AddCookie(&http.Cookie{Name: "refresh_token", Value: supa.ValidRefresh})
|
||||
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: "stale"})
|
||||
r.AddCookie(&http.Cookie{Name: refreshTokenCookie, Value: f.ValidRefresh})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200 after refresh", w.Result().StatusCode)
|
||||
if w.Result().StatusCode != 200 {
|
||||
t.Fatalf("status %d", w.Result().StatusCode)
|
||||
}
|
||||
cookies := w.Result().Cookies()
|
||||
gotAccess, gotRefresh := "", ""
|
||||
for _, c := range cookies {
|
||||
switch c.Name {
|
||||
case "access_token":
|
||||
gotAccess = c.Value
|
||||
if c.Domain != "msbls.de" {
|
||||
t.Errorf("access cookie Domain = %q, want msbls.de", c.Domain)
|
||||
}
|
||||
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("access cookie flags wrong: httponly=%v secure=%v samesite=%v", c.HttpOnly, c.Secure, c.SameSite)
|
||||
}
|
||||
case "refresh_token":
|
||||
gotRefresh = c.Value
|
||||
got := map[string]*http.Cookie{}
|
||||
for _, c := range w.Result().Cookies() {
|
||||
got[c.Name] = c
|
||||
}
|
||||
if c := got[accessTokenCookie]; c == nil || c.Value != f.NewAccess {
|
||||
t.Fatalf("access cookie missing or wrong value")
|
||||
}
|
||||
if c := got[refreshTokenCookie]; c == nil || c.Value != f.NewRefresh {
|
||||
t.Fatalf("refresh cookie missing or wrong value")
|
||||
}
|
||||
// Per-host scope: NO Domain attribute.
|
||||
if got[accessTokenCookie].Domain != "" {
|
||||
t.Fatalf("access cookie has Domain=%q, want empty (per-host)", got[accessTokenCookie].Domain)
|
||||
}
|
||||
if got[refreshTokenCookie].Domain != "" {
|
||||
t.Fatalf("refresh cookie has Domain=%q, want empty", got[refreshTokenCookie].Domain)
|
||||
}
|
||||
for _, c := range got {
|
||||
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("flags wrong for %q: httponly=%v secure=%v samesite=%v", c.Name, c.HttpOnly, c.Secure, c.SameSite)
|
||||
}
|
||||
}
|
||||
if gotAccess != supa.NewAccess {
|
||||
t.Errorf("rotated access cookie = %q, want %q", gotAccess, supa.NewAccess)
|
||||
}
|
||||
if gotRefresh != supa.NewRefresh {
|
||||
t.Errorf("rotated refresh cookie = %q, want %q", gotRefresh, supa.NewRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidRefreshFinallyRedirects(t *testing.T) {
|
||||
supa := newFakeSupabase(t)
|
||||
h := newGatedHandler(t, supa.URL, "anon")
|
||||
func TestBadRefreshFinallyRedirects(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
h := gatedMux(t, f.URL, "anon")
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "access_token", Value: "stale"})
|
||||
r.AddCookie(&http.Cookie{Name: "refresh_token", Value: "stale-too"})
|
||||
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: "stale"})
|
||||
r.AddCookie(&http.Cookie{Name: refreshTokenCookie, Value: "no-good"})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Result().StatusCode != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302", w.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- /login + /logout handler tests (use the real Server) ---
|
||||
|
||||
func makeServerWithStub(t *testing.T, f *fakeSupabase) *Server {
|
||||
t.Helper()
|
||||
srv, err := New(nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
srv.Auth = &AuthConfig{SupabaseURL: f.URL, AnonKey: "anon"}
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestLoginGETRendersForm(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
srv := makeServerWithStub(t, f)
|
||||
r := httptest.NewRequest(http.MethodGet, "/login?redirectTo=/i/dev", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleLoginForm(w, r)
|
||||
if w.Result().StatusCode != 200 {
|
||||
t.Fatalf("status %d", w.Result().StatusCode)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `name="email"`) || !strings.Contains(body, `name="password"`) {
|
||||
t.Errorf("body missing email/password fields")
|
||||
}
|
||||
if !strings.Contains(body, `name="redirectTo" value="/i/dev"`) {
|
||||
t.Errorf("body missing redirectTo hidden input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginGETShortCircuitsWhenAlreadySignedIn(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
srv := makeServerWithStub(t, f)
|
||||
r := httptest.NewRequest(http.MethodGet, "/login?redirectTo=/i/dev", nil)
|
||||
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: f.ValidAccess})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleLoginForm(w, r)
|
||||
if w.Result().StatusCode != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302", w.Result().StatusCode)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/i/dev" {
|
||||
t.Errorf("Location = %q, want /i/dev", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPOSTSuccessSetsCookiesAndRedirects(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
srv := makeServerWithStub(t, f)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", f.ValidEmail)
|
||||
form.Set("password", f.ValidPass)
|
||||
form.Set("redirectTo", "/i/dev")
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleLoginSubmit(w, r)
|
||||
|
||||
if w.Result().StatusCode != http.StatusFound {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
t.Fatalf("status %d body=%s", w.Result().StatusCode, body)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/i/dev" {
|
||||
t.Errorf("Location = %q, want /i/dev", loc)
|
||||
}
|
||||
var sawAccess, sawRefresh bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == accessTokenCookie {
|
||||
sawAccess = true
|
||||
if c.Domain != "" {
|
||||
t.Errorf("access cookie has Domain=%q, want empty", c.Domain)
|
||||
}
|
||||
if c.Value != f.IssuedAccess {
|
||||
t.Errorf("access cookie value %q, want %q", c.Value, f.IssuedAccess)
|
||||
}
|
||||
}
|
||||
if c.Name == refreshTokenCookie {
|
||||
sawRefresh = true
|
||||
}
|
||||
}
|
||||
if !sawAccess || !sawRefresh {
|
||||
t.Errorf("missing session cookies after login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPOSTBadCredsRerendersWithError(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
srv := makeServerWithStub(t, f)
|
||||
form := url.Values{}
|
||||
form.Set("email", f.ValidEmail)
|
||||
form.Set("password", "wrong")
|
||||
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleLoginSubmit(w, r)
|
||||
if w.Result().StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status %d, want 401", w.Result().StatusCode)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Invalid login credentials") {
|
||||
t.Errorf("form did not surface error message: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRedirectToRejectedWhenUnsafe(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
srv := makeServerWithStub(t, f)
|
||||
for _, hostile := range []string{"//evil.com", "https://evil.com", `\evil`} {
|
||||
form := url.Values{}
|
||||
form.Set("email", f.ValidEmail)
|
||||
form.Set("password", f.ValidPass)
|
||||
form.Set("redirectTo", hostile)
|
||||
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleLoginSubmit(w, r)
|
||||
if loc := w.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("hostile redirectTo %q -> Location %q, want /", hostile, loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsCookies(t *testing.T) {
|
||||
f := newFakeSupabase(t)
|
||||
srv := makeServerWithStub(t, f)
|
||||
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleLogout(w, r)
|
||||
if w.Result().StatusCode != http.StatusFound {
|
||||
t.Fatalf("status %d, want 302", w.Result().StatusCode)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/login" {
|
||||
t.Errorf("Location = %q, want /login", loc)
|
||||
}
|
||||
cleared := 0
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == accessTokenCookie || c.Name == refreshTokenCookie {
|
||||
if c.MaxAge >= 0 || c.Value != "" {
|
||||
t.Errorf("cookie %q not cleared: maxAge=%d value=%q", c.Name, c.MaxAge, c.Value)
|
||||
}
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
if cleared != 2 {
|
||||
t.Errorf("cleared %d cookies, want 2", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
109
web/login.go
Normal file
109
web/login.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleLoginForm renders the login page. If the request already carries a
|
||||
// valid session, jump straight to the safe redirectTo.
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth != nil && s.hasValidSession(r) {
|
||||
http.Redirect(w, r, postLoginTarget(r.URL.Query().Get("redirectTo")), http.StatusFound)
|
||||
return
|
||||
}
|
||||
redirectTo := safeRedirect(r.URL.Query().Get("redirectTo"))
|
||||
s.render(w, "login", map[string]any{
|
||||
"Title": "sign in",
|
||||
"RedirectTo": redirectTo,
|
||||
"Error": "",
|
||||
})
|
||||
}
|
||||
|
||||
// handleLoginSubmit exchanges email+password for a Supabase session and writes
|
||||
// the session cookies. On failure we re-render the login form with the
|
||||
// Supabase error message.
|
||||
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth == nil {
|
||||
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
redirectTo := safeRedirect(r.FormValue("redirectTo"))
|
||||
|
||||
if email == "" || password == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
s.render(w, "login", map[string]any{
|
||||
"Title": "sign in",
|
||||
"RedirectTo": redirectTo,
|
||||
"Error": "Email and password are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.Auth.passwordSignIn(r.Context(), email, password)
|
||||
if err != nil {
|
||||
s.Logger.Info("login: signin failed", "email", email, "err", err)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
s.render(w, "login", map[string]any{
|
||||
"Title": "sign in",
|
||||
"RedirectTo": redirectTo,
|
||||
"Error": humanLoginError(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
s.Auth.setSessionCookies(w, sess)
|
||||
http.Redirect(w, r, postLoginTarget(redirectTo), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleLogout clears both session cookies and redirects to /login.
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, clearCookie(accessTokenCookie))
|
||||
http.SetCookie(w, clearCookie(refreshTokenCookie))
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
}
|
||||
|
||||
// hasValidSession checks current cookies/Bearer against Supabase. Used only by
|
||||
// the login page's "already signed in" short-circuit; the main gate lives in
|
||||
// authMiddleware.
|
||||
func (s *Server) hasValidSession(r *http.Request) bool {
|
||||
if s.Auth == nil {
|
||||
return false
|
||||
}
|
||||
access := tokenFromBearer(r)
|
||||
if access == "" {
|
||||
if c, err := r.Cookie(accessTokenCookie); err == nil {
|
||||
access = c.Value
|
||||
}
|
||||
}
|
||||
if access == "" {
|
||||
return false
|
||||
}
|
||||
_, err := s.Auth.validateAccessToken(r.Context(), access)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func postLoginTarget(redirectTo string) string {
|
||||
if t := safeRedirect(redirectTo); t != "" {
|
||||
return t
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// humanLoginError trims Supabase's noisy prefix down to a sentence the form
|
||||
// can show without leaking internals.
|
||||
func humanLoginError(err error) string {
|
||||
msg := err.Error()
|
||||
if i := strings.Index(msg, "supabase /auth/v1/token (password): "); i >= 0 {
|
||||
msg = msg[i+len("supabase /auth/v1/token (password): "):]
|
||||
}
|
||||
if msg == "" {
|
||||
return "Login failed."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -30,7 +30,9 @@ type Server struct {
|
||||
}
|
||||
|
||||
// New builds a Server. Each page is parsed alongside the layout into its own
|
||||
// Template so per-page `define "content"` blocks don't shadow each other.
|
||||
// Template so per-page `define "content"` blocks don't shadow each other. The
|
||||
// login page is intentionally NOT wrapped in the regular layout (chrome would
|
||||
// imply you're already inside the app).
|
||||
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -54,6 +56,11 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
}
|
||||
pages[name] = t
|
||||
}
|
||||
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse login: %w", err)
|
||||
}
|
||||
pages["login"] = loginTmpl
|
||||
return &Server{Store: s, pages: pages, Logger: logger}, nil
|
||||
}
|
||||
|
||||
@@ -67,6 +74,9 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /new", s.handleNewForm)
|
||||
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
||||
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.Store.Pool.Ping(r.Context()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
@@ -341,8 +351,13 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
|
||||
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
entry := "layout"
|
||||
if name == "login" {
|
||||
// Login page is intentionally standalone — no nav chrome.
|
||||
entry = "login"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
if err := t.ExecuteTemplate(w, entry, data); err != nil {
|
||||
s.Logger.Error("render", "page", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ html { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color:
|
||||
body { margin: 0; }
|
||||
header { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 8px 16px; }
|
||||
header nav { display: flex; gap: 16px; align-items: center; }
|
||||
header .logout-form { margin: 0 0 0 auto; }
|
||||
header .logout-btn { background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px 6px; font: inherit; }
|
||||
header .logout-btn:hover { color: var(--bad); text-decoration: underline; }
|
||||
header .brand { font-weight: 600; font-size: 1.1em; color: var(--fg); text-decoration: none; }
|
||||
header a { color: var(--accent); text-decoration: none; }
|
||||
header a:hover { text-decoration: underline; }
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<nav>
|
||||
<a href="/" class="brand">projax</a>
|
||||
<a href="/admin/classify">classify orphans</a>
|
||||
<form method="post" action="/logout" class="logout-form">
|
||||
<button type="submit" class="logout-btn">sign out</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
83
web/templates/login.tmpl
Normal file
83
web/templates/login.tmpl
Normal file
@@ -0,0 +1,83 @@
|
||||
{{define "login"}}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Sign in — projax</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
background: #111;
|
||||
border: 1px solid #222;
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
.header { text-align: center; margin-bottom: 1.5rem; }
|
||||
.title { font-size: 1.5rem; font-weight: 700; margin: 0 0 0.25rem; color: #e0e0e0; }
|
||||
.subtitle { color: #666; margin: 0; font-size: 0.9rem; }
|
||||
form { display: flex; flex-direction: column; gap: 1.1rem; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
label { font-weight: 500; font-size: 0.85rem; color: #aaa; }
|
||||
input {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #333;
|
||||
border-radius: 0.4rem;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font: inherit;
|
||||
}
|
||||
input:focus { outline: none; border-color: #555; }
|
||||
.err {
|
||||
padding: 0.6rem;
|
||||
border-radius: 0.4rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
button {
|
||||
padding: 0.65rem;
|
||||
background: #e0e0e0;
|
||||
color: #0a0a0a;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1 class="title">projax</h1>
|
||||
<p class="subtitle">Sign in to continue</p>
|
||||
</div>
|
||||
<form method="POST" action="/login">
|
||||
{{if .RedirectTo}}<input type="hidden" name="redirectTo" value="{{.RedirectTo}}">{{end}}
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required autocomplete="username" autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required autocomplete="current-password">
|
||||
</div>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
Reference in New Issue
Block a user