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") }