package web import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "log/slog" "net/http" "strings" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "pete/internal/config" ) const ( sessionCookie = "pete_session" oauthCookie = "pete_oauth" sessionTTL = 30 * 24 * time.Hour // stay signed in for a month oauthTTL = 10 * time.Minute // login round-trip window ) // Authenticator holds the OIDC plumbing for optional sign-in via Authentik. type Authenticator struct { oauth *oauth2.Config verifier *oidc.IDTokenVerifier secret []byte } // SessionUser is the identity carried in the signed session cookie. type SessionUser struct { Sub string `json:"sub"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` Exp int64 `json:"exp"` } // Display is the friendly name shown in the header (name, else email, else sub). func (u *SessionUser) Display() string { if u.Name != "" { return u.Name } if u.Email != "" { return u.Email } return u.Sub } // Initial is the single uppercase glyph for the avatar bubble. func (u *SessionUser) Initial() string { d := strings.TrimSpace(u.Display()) if d == "" { return "?" } return strings.ToUpper(d[:1]) } // oauthState is the short-lived CSRF/nonce envelope for the login round-trip. type oauthState struct { State string `json:"s"` Nonce string `json:"n"` Next string `json:"r"` Exp int64 `json:"e"` } // newAuthenticator performs OIDC discovery against the configured issuer. It is // non-fatal at the call site: if discovery fails (provider down at boot), the // site still serves anonymously. func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticator, error) { provider, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/")+"/") if err != nil { return nil, fmt.Errorf("oidc discovery: %w", err) } return &Authenticator{ oauth: &oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, RedirectURL: cfg.RedirectURL, Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, }, verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), secret: []byte(cfg.SessionSecret), }, nil } // ---- signed-cookie helpers ------------------------------------------------ // sign returns base64url(payload).base64url(hmac) — a compact stateless token. func (a *Authenticator) sign(payload []byte) string { mac := hmac.New(sha256.New, a.secret) mac.Write(payload) b64 := base64.RawURLEncoding return b64.EncodeToString(payload) + "." + b64.EncodeToString(mac.Sum(nil)) } // verify checks the HMAC and returns the raw payload bytes. func (a *Authenticator) verify(token string) ([]byte, bool) { parts := strings.SplitN(token, ".", 2) if len(parts) != 2 { return nil, false } b64 := base64.RawURLEncoding payload, err := b64.DecodeString(parts[0]) if err != nil { return nil, false } want, err := b64.DecodeString(parts[1]) if err != nil { return nil, false } mac := hmac.New(sha256.New, a.secret) mac.Write(payload) if subtle.ConstantTimeCompare(want, mac.Sum(nil)) != 1 { return nil, false } return payload, true } func randToken() string { b := make([]byte, 32) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } // userFromRequest returns the signed-in user, or nil for anonymous visitors. func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser { c, err := r.Cookie(sessionCookie) if err != nil { return nil } payload, ok := a.verify(c.Value) if !ok { return nil } var u SessionUser if json.Unmarshal(payload, &u) != nil { return nil } if u.Sub == "" || time.Now().Unix() > u.Exp { return nil } return &u } func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: name, Value: value, Path: "/", Expires: time.Now().Add(ttl), MaxAge: int(ttl.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } // ---- handlers ------------------------------------------------------------- // handleLogin starts the OIDC authorization-code flow. func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) { st := oauthState{ State: randToken(), Nonce: randToken(), Next: safeNext(r.URL.Query().Get("next")), Exp: time.Now().Add(oauthTTL).Unix(), } payload, _ := json.Marshal(st) a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL) http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound) } // handleCallback completes the flow: validates state, exchanges the code, // verifies the ID token, and issues a session cookie. func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(oauthCookie) if err != nil { http.Error(w, "login expired, please try again", http.StatusBadRequest) return } a.clearCookie(w, oauthCookie) payload, ok := a.verify(c.Value) if !ok { http.Error(w, "invalid login state", http.StatusBadRequest) return } var st oauthState if json.Unmarshal(payload, &st) != nil || time.Now().Unix() > st.Exp { http.Error(w, "login expired, please try again", http.StatusBadRequest) return } if subtle.ConstantTimeCompare([]byte(st.State), []byte(r.URL.Query().Get("state"))) != 1 { http.Error(w, "state mismatch", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code")) if err != nil { slog.Error("auth: code exchange failed", "err", err) http.Error(w, "sign-in failed", http.StatusBadGateway) return } rawID, ok := tok.Extra("id_token").(string) if !ok { http.Error(w, "no id_token in response", http.StatusBadGateway) return } idToken, err := a.verifier.Verify(ctx, rawID) if err != nil { slog.Error("auth: id_token verify failed", "err", err) http.Error(w, "sign-in failed", http.StatusBadGateway) return } if idToken.Nonce != st.Nonce { http.Error(w, "nonce mismatch", http.StatusBadRequest) return } var claims struct { Sub string `json:"sub"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` Email string `json:"email"` } if err := idToken.Claims(&claims); err != nil { http.Error(w, "sign-in failed", http.StatusBadGateway) return } name := claims.Name if name == "" { name = claims.PreferredUsername } u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()} sess, _ := json.Marshal(u) a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL) slog.Info("auth: user signed in", "sub", claims.Sub, "name", name) http.Redirect(w, r, st.Next, http.StatusFound) } // handleLogout clears the local session. It does not end the upstream Authentik // SSO session (that stays the user's choice). func (a *Authenticator) handleLogout(w http.ResponseWriter, r *http.Request) { a.clearCookie(w, sessionCookie) http.Redirect(w, r, "/", http.StatusFound) } // safeNext keeps post-login redirects on-site (no open redirects). It rejects // "//host" and "/\host" — browsers normalize the backslash to a slash, so the // latter would otherwise resolve to a protocol-relative off-site URL. func safeNext(next string) string { if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { return "/" } return next }