222 lines
6.8 KiB
Go
222 lines
6.8 KiB
Go
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")
|
|
}
|