Files
pansy/internal/api/auth.go
T
steveandClaude Opus 4.8 84edf3e42a
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 9m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m32s
Add OIDC login via Authentik: PKCE, JIT provisioning, email linking (#5)
OIDC is pansy's primary login path (Authentik the target IdP); local
auth (#4) remains the fallback and both issue the same session cookie.

- deps: github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 (both pure Go;
  CGO stays off).
- api/oidc.go: lazy issuer discovery (retried per-request, never crashes
  a server that also serves local auth), GET /auth/oidc/login builds an
  authorization-code URL with PKCE S256 + random state + nonce stashed in
  a short-lived HttpOnly cookie, GET /auth/oidc/callback verifies state
  (constant-time), exchanges the code with the PKCE verifier, verifies the
  ID token + nonce, and starts a pansy session. Failures redirect to
  /login?error=... ; success to /gardens.
- service.LoginOIDC: (issuer,subject) match -> login; else *verified*
  email match -> link onto the existing account; else JIT-create (the IdP
  gates access, so PANSY_REGISTRATION doesn't apply). Unverified email
  colliding with an existing account is refused (takeover guard); no email
  is refused (email is the account key). Reuses the atomic CreateUser.
- store: GetUserByOIDC + LinkOIDC (unique-pair backstop).
- config: OIDCReady() (needs issuer+client+BaseURL for the redirect URI);
  /auth/providers now reports oidc from it and defaults the button label
  to "Sign in with Authentik". OIDC routes are only registered when ready,
  so an unconfigured instance 404s them.
- PANSY_LOCAL_AUTH=false rejects/hides local auth but not OIDC.

Tests: service provisioning (JIT, repeat login, link, unverified-collision
refusal, no-email, name fallback, works with local auth off); api
(routes-absent-when-unconfigured, providers reporting, login redirect with
PKCE params + tx cookie via a fake discovery server, callback state/error
paths). Smoke-tested: unreachable issuer degrades to error=oidc_unavailable
with the server still up and local auth working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:13:47 -04:00

233 lines
8.2 KiB
Go

package api
import (
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// sessionCookie is the name of the HttpOnly cookie carrying the raw session token.
const sessionCookie = "pansy_session"
// actorKey is the gin-context key under which requireAuth stashes the
// authenticated *domain.User.
const actorKey = "actor"
// registerRequest / loginRequest are the JSON bodies for the local-auth
// endpoints. gin's binding validates them before the service is called.
type registerRequest struct {
Email string `json:"email" binding:"required,email"`
DisplayName string `json:"displayName" binding:"required"`
Password string `json:"password" binding:"required,min=8,max=1024"`
}
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,max=1024"`
}
// register creates a local account and logs it in (sets the session cookie).
func (h *handlers) register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "email, display name, and an 8+ character password are required")
return
}
user, err := h.svc.Register(c.Request.Context(), service.RegisterInput{
Email: req.Email,
DisplayName: req.DisplayName,
Password: req.Password,
})
if err != nil {
writeServiceError(c, err)
return
}
h.startSessionAndRespond(c, user)
}
// login verifies credentials and sets the session cookie.
func (h *handlers) login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "email and password are required")
return
}
user, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
if err != nil {
writeServiceError(c, err)
return
}
h.startSessionAndRespond(c, user)
}
// logout deletes the current session (if any) and clears the cookie. It is
// idempotent and never requires a valid session.
func (h *handlers) logout(c *gin.Context) {
if token, err := c.Cookie(sessionCookie); err == nil && token != "" {
if err := h.svc.Logout(c.Request.Context(), token); err != nil {
slog.Error("api: logout", "error", err)
}
}
h.clearSessionCookie(c)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// me returns the authenticated user. It sits behind requireAuth.
func (h *handlers) me(c *gin.Context) {
c.JSON(http.StatusOK, mustActor(c))
}
// providers reports which login methods to render on the login page.
func (h *handlers) providers(c *gin.Context) {
c.JSON(http.StatusOK, h.svc.Providers())
}
// requireAuth is middleware that rejects requests without a valid session and,
// on success, stashes the resolved user in the context and slides the cookie's
// lifetime forward in step with the server-side session (otherwise an active
// user would be logged out 30 days after login regardless of activity). Feature
// routers added by later issues (gardens, objects, …) attach this to their
// protected groups.
func (h *handlers) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(sessionCookie)
if err != nil || token == "" {
abortUnauthenticated(c)
return
}
user, expiresAt, err := h.svc.ResolveSession(c.Request.Context(), token)
if err != nil {
// Any resolution failure (missing/expired/tampered) is 401, never 404:
// the client should log in, not think a resource is gone.
abortUnauthenticated(c)
return
}
c.Set(actorKey, user)
// Refresh the client cookie to the session's current expiry so browser and
// server slide together.
h.setSessionCookie(c, token, expiresAt)
c.Next()
}
}
// abortUnauthenticated writes the standard 401 and stops the handler chain.
func abortUnauthenticated(c *gin.Context) {
writeAPIError(c, http.StatusUnauthorized, "UNAUTHENTICATED", "authentication required")
c.Abort()
}
// csrfGuard rejects state-changing requests whose Origin header doesn't match
// the instance's own origin — a defense against login/logout CSRF, which
// SameSite=Lax cookies alone do not prevent. It is a no-op unless PANSY_BASE_URL
// is set (so it never interferes with the local Vite dev proxy), and it allows
// requests with no Origin (non-browser clients like curl, which aren't a CSRF
// vector). Safe methods are always allowed.
func (h *handlers) csrfGuard() gin.HandlerFunc {
var wantHost string
if u, err := url.Parse(h.cfg.BaseURL); err == nil {
wantHost = u.Host
}
return func(c *gin.Context) {
if wantHost == "" {
c.Next()
return
}
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
c.Next()
return
}
if origin := c.GetHeader("Origin"); origin != "" {
if u, err := url.Parse(origin); err != nil || !strings.EqualFold(u.Host, wantHost) {
writeAPIError(c, http.StatusForbidden, "CROSS_ORIGIN", "cross-origin request rejected")
c.Abort()
return
}
}
c.Next()
}
}
// startSession issues a session for the user and writes the session cookie.
func (h *handlers) startSession(c *gin.Context, userID int64) error {
token, expiresAt, err := h.svc.CreateSession(c.Request.Context(), userID)
if err != nil {
return err
}
h.setSessionCookie(c, token, expiresAt)
return nil
}
// startSessionAndRespond issues a session, sets the cookie, and returns the user
// as JSON — the shared tail of register and login.
func (h *handlers) startSessionAndRespond(c *gin.Context, user *domain.User) {
if err := h.startSession(c, user.ID); err != nil {
// The account exists and is usable via login; only the auto-login cookie
// failed. Log it so the operator can see the underlying DB problem.
slog.Error("api: could not start session", "user_id", user.ID, "error", err)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not start session")
return
}
c.JSON(http.StatusOK, user)
}
// cookieSecure reports whether the session cookie should carry the Secure
// attribute: only when the instance is served over HTTPS (PANSY_BASE_URL).
// Marking it Secure under plain-http local dev would make the browser drop it.
func (h *handlers) cookieSecure() bool {
return strings.HasPrefix(h.cfg.BaseURL, "https://")
}
func (h *handlers) setSessionCookie(c *gin.Context, token string, expiresAt time.Time) {
// Callers only ever pass a future expiry (login/register set now+TTL;
// requireAuth uses a session already checked to be unexpired), so this is
// always well above the floor; the floor merely avoids emitting a delete
// cookie (MaxAge<=0) from a pathological clock skew.
maxAge := max(int(time.Until(expiresAt).Seconds()), 1)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, token, maxAge, "/", "", h.cookieSecure(), true)
}
func (h *handlers) clearSessionCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, "", -1, "/", "", h.cookieSecure(), true)
}
// mustActor returns the user stashed by requireAuth. It panics if called on a
// route that isn't behind requireAuth — a programming error.
func mustActor(c *gin.Context) *domain.User {
return c.MustGet(actorKey).(*domain.User)
}
// writeServiceError maps a service-layer sentinel error to pansy's JSON error
// envelope. Login failures never leak which of email/password was wrong.
func writeServiceError(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrInvalidCredentials):
writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password")
case errors.Is(err, domain.ErrEmailTaken):
writeAPIError(c, http.StatusConflict, "EMAIL_TAKEN", "an account with that email already exists")
case errors.Is(err, domain.ErrRegistrationClosed):
writeAPIError(c, http.StatusForbidden, "REGISTRATION_CLOSED", "registration is closed")
case errors.Is(err, domain.ErrLocalAuthDisabled):
writeAPIError(c, http.StatusForbidden, "LOCAL_AUTH_DISABLED", "local authentication is disabled")
case errors.Is(err, domain.ErrInvalidInput):
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
default:
slog.Error("api: unhandled service error", "error", err)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
}
}