Initial commit

This commit is contained in:
mats committed 2026-09-22 08:41:20 +02:00
commit 89053153cc
8152 files changed
+2086461

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
package api
import (
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.k6n.net/mats/fullstack-template/internal/auth"
)
// AuthDeps bundles the OIDC collaborators; nil Auth in Deps disables the
// /api/auth routes and the /api/v1 guard (standalone mode).
type AuthDeps struct {
Client *auth.Client
Signer *auth.Signer
SessionTTL time.Duration // zero means the 24h default
}
const defaultSessionTTL = 24 * time.Hour
type authHandler struct {
client *auth.Client
signer *auth.Signer
sessionTTL time.Duration
logger *slog.Logger
}
// sessionUser is the public shape of the signed-in identity.
type sessionUser struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
// login starts the browser flow: it parks a login transaction (state,
// nonce, PKCE verifier, return path) in a short-lived cookie and redirects
// to the issuer's authorization endpoint.
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
tx, err := auth.NewLoginTx(sanitizeReturnTo(r.URL.Query().Get("returnTo")))
if err != nil {
h.fail(w, err)
return
}
cookie, err := h.signer.NewLoginTxCookie(tx, requestSecure(r))
if err != nil {
h.fail(w, err)
return
}
http.SetCookie(w, cookie)
http.Redirect(w, r, h.client.AuthCodeURL(tx, redirectURI(r)), http.StatusFound)
}
// callback completes the flow: it checks state, exchanges the code with the
// PKCE verifier, verifies the ID token (signature, iss/aud/exp, nonce), and
// installs the session cookie. Any failure redirects back to the app with
// an auth_error code — the SPA surfaces it; details stay in server logs.
func (h *authHandler) callback(w http.ResponseWriter, r *http.Request) {
secure := requestSecure(r)
http.SetCookie(w, auth.Expired(auth.CookieLoginTx, secure))
fail := func(code string) {
http.Redirect(w, r, "/?auth_error="+url.QueryEscape(code), http.StatusFound)
}
// An issuer-reported error (user denied, interaction required, …) is
// more useful than our own missing-transaction complaint.
if e := r.URL.Query().Get("error"); e != "" {
fail(e)
return
}
tx, ok := h.readLoginTx(r)
if !ok {
fail("expired_login")
return
}
q := r.URL.Query()
if q.Get("state") != tx.State {
fail("state_mismatch")
return
}
user, err := h.client.CompleteCode(r.Context(), q.Get("code"), redirectURI(r), tx)
if err != nil {
h.logger.Error("oidc code exchange failed", "err", err)
fail("code_exchange")
return
}
cookie, err := h.signer.NewSessionCookie(
auth.Session{Sub: user.Sub, Name: user.Name, Email: user.Email, Roles: user.Roles},
h.sessionTTL, secure)
if err != nil {
h.fail(w, err)
return
}
http.SetCookie(w, cookie)
http.Redirect(w, r, tx.ReturnTo, http.StatusFound)
}
// session reports who is signed in; it answers 200 either way so the SPA
// can poll it without treating "anonymous" as an error.
func (h *authHandler) session(w http.ResponseWriter, r *http.Request) {
type sessionResponse struct {
Authenticated bool `json:"authenticated"`
User *sessionUser `json:"user"`
}
res := sessionResponse{}
if sess, ok := CurrentSession(h.signer, r); ok {
res.Authenticated = true
res.User = &sessionUser{Sub: sess.Sub, Name: sess.Name, Email: sess.Email, Roles: sess.Roles}
}
writeJSON(w, http.StatusOK, res)
}
// logout clears the session cookie.
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.Expired(auth.CookieSession, requestSecure(r)))
w.WriteHeader(http.StatusNoContent)
}
// readLoginTx loads and validates the login-transaction cookie.
func (h *authHandler) readLoginTx(r *http.Request) (auth.LoginTx, bool) {
cookie, err := r.Cookie(auth.CookieLoginTx)
if err != nil {
return auth.LoginTx{}, false
}
var tx auth.LoginTx
if h.signer.Open(cookie.Value, &tx) != nil || !tx.Valid() {
return auth.LoginTx{}, false
}
return tx, true
}
// CurrentSession returns the valid session behind the request, if any. It
// is the single read path shared by the session endpoint and requireAuth.
func CurrentSession(signer *auth.Signer, r *http.Request) (auth.Session, bool) {
cookie, err := r.Cookie(auth.CookieSession)
if err != nil {
return auth.Session{}, false
}
var sess auth.Session
if signer.Open(cookie.Value, &sess) != nil || !sess.Valid() {
return auth.Session{}, false
}
return sess, true
}
// requireAuth guards the business API behind a valid session cookie. It is
// installed inside the chain, so unknown /api/v1/* routes answer 401, not
// 404 — route existence is not public information.
func requireAuth(signer *auth.Signer) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v1/") {
if _, ok := CurrentSession(signer, r); !ok {
writeError(w, http.StatusUnauthorized, "unauthorized",
"sign in via /api/auth/login to call the API")
return
}
}
next.ServeHTTP(w, r)
})
}
}
// redirectURI rebuilds the public origin the browser used, so the issuer
// sends the user back through the same host that started the flow — the
// vite proxy (localhost or *.preview.k6n.net) or the API itself. The proxy
// sets X-Forwarded-Host/Proto; direct requests fall back to r.Host/TLS.
func redirectURI(r *http.Request) string {
return requestScheme(r) + "://" + requestHost(r) + "/api/auth/callback"
}
// requestSecure reports whether the browser reached us over https.
func requestSecure(r *http.Request) bool { return requestScheme(r) == "https" }
// requestScheme resolves the scheme from X-Forwarded-Proto (first hop) or
// the connection itself.
func requestScheme(r *http.Request) string {
if proto := forwardedValue(r.Header.Get("X-Forwarded-Proto")); proto != "" {
return proto
}
if r.TLS != nil {
return "https"
}
return "http"
}
// requestHost resolves the host from X-Forwarded-Host (first hop) or Host.
func requestHost(r *http.Request) string {
if host := forwardedValue(r.Header.Get("X-Forwarded-Host")); host != "" {
return host
}
return r.Host
}
// forwardedValue returns the first value of a forwarded header, which is
// what our own proxy appended.
func forwardedValue(v string) string {
first, _, _ := strings.Cut(v, ",")
return strings.TrimSpace(first)
}
// sanitizeReturnTo keeps post-login redirects inside the app: relative
// paths only, no protocol-relative //host or scheme tricks.
func sanitizeReturnTo(v string) string {
if v == "" || !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") ||
strings.ContainsAny(v, "\r\n\\") {
return "/"
}
return v
}
// fail maps unexpected internal errors to the standard envelope.
func (h *authHandler) fail(w http.ResponseWriter, err error) {
h.logger.Error("auth handler failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}