Files
mesh-catalog/modules/mesh-mail-web/internal/web/auth.go
T
jochen f24a4d858a issue 1859: move the mail page into internal/web, so the local run can serve it
A program cannot import a main package; the page's code moves to modules/mesh-mail-web/internal/web, and
cmd/mesh-mail-web is a two-line program calling it, so cmd/mail-page-dev (1859.5) serves the same handlers.
Nothing the module does changes.
2026-10-22 19:58:43 +02:00

276 lines
11 KiB
Go

package web
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): the authorisation-code flow with state and PKCE against
// the bound issuer, the callback at /signed-in, the ID token verified (issuer, audience, expiry, signature from
// the issuer's JWKS), the identity (its preferred_username) checked against the trusted setting operators, and
// a session kept in memory only, its cookie Secure, HttpOnly, SameSite=Strict, ending SessionLifetime after
// sign-in. Library choice, said as the brief asks: golang.org/x/oauth2 for the code exchange and
// github.com/coreos/go-oidc/v3 for the ID token's verification (issuer, audience, expiry and signature against
// the issuer's own JWKS) — chosen over hand-rolling JWT checks here, since go-oidc's verifier is the
// already-reviewed, standard implementation of exactly what ADR 0440 §3 asks for.
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"os"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// SignedInPath is the callback the issuer redirects back to (the oidc-client contribution's own callback,
// module.json).
const SignedInPath = "/signed-in"
// OIDCConfig is the oidc-client binding's own facts this module reads (issue 1262's oidc.json).
type OIDCConfig struct {
ClientID string `json:"client-id"`
ClientSecret string `json:"client-secret"`
Issuer string `json:"issuer"`
}
// ReadOIDCConfig reads the rendered oidc-client binding.
func ReadOIDCConfig(path string) (OIDCConfig, error) {
if path == "" {
return OIDCConfig{}, errors.New("the module was started without its oidc setting (MESH_MAIL_WEB_OIDC)")
}
raw, err := os.ReadFile(path)
if err != nil {
return OIDCConfig{}, fmt.Errorf("the oidc setting cannot be read: %w", err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var c OIDCConfig
if err := dec.Decode(&c); err != nil {
return OIDCConfig{}, fmt.Errorf("the oidc setting is not the module's: %w", err)
}
if c.ClientID == "" || c.Issuer == "" {
return OIDCConfig{}, errors.New("the oidc setting names no client id or issuer")
}
return c, nil
}
// Authenticator is how this module reaches the issuer: build the redirect to it, exchange a code for tokens,
// and verify the ID token it names.
type Authenticator struct {
OAuth2 oauth2.Config
Verifier *oidc.IDTokenVerifier
}
// NewAuthenticator discovers the issuer (its authorisation, token and JWKS endpoints, novox/hq issue 1263) and
// builds an Authenticator for it. redirectURL is this module's own callback, https://<internal host>/signed-in.
func NewAuthenticator(ctx context.Context, cfg OIDCConfig, redirectURL string) (*Authenticator, error) {
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("the issuer %s could not be reached: %w", cfg.Issuer, err)
}
return &Authenticator{
OAuth2: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: redirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile"},
},
Verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
}, nil
}
// BounceParam marks a request that already spent RequireSession's own one-time bounce (review round 1 of
// issue 1263, finding 1): a browser arriving with no session cookie at all may simply have none yet, or may
// hold a live one that this particular navigation does not carry — exactly what a cookie marked
// SameSite=Strict looks like on a navigation this browser does not count as same-site, which both Keycloak's
// own redirect to the callback and a link clicked from another site (Telegram) are, even across an otherwise
// live session. A page this module's own origin serves, whose own follow-on navigation (a meta refresh, not an
// HTTP redirect) is same-site, carries the cookie if there is one; a second cookieless arrival, marked by this
// parameter, means there truly is none, and goes to sign-in instead. Stripped from the return URL a sign-in
// keeps (returnURL below), so it never leaks into the page the browser ends up on.
const BounceParam = "mww-bounced"
// bounceTemplate is both RequireSession's own one-time bounce and Callback's own return to the page first
// asked: the same shape, a 200 page this origin serves whose own navigation carries a Strict cookie that a
// redirect begun elsewhere (Keycloak's 302 to this callback) would not.
var bounceTemplate = template.Must(template.New("bounce").Parse(`<!DOCTYPE html>
<html><head><title>Mail</title><meta http-equiv="refresh" content="0; url={{.}}"></head>
<body><p><a href="{{.}}">Continue</a></p></body></html>`))
// renderBounce answers a 200 page whose own navigation goes to url, next (RequireSession's bounce, or
// Callback's own return once signed in).
func renderBounce(w http.ResponseWriter, url string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = bounceTemplate.Execute(w, url)
}
// returnURL is the page a sign-in should return to once it succeeds, or RequireSession's own bounce should
// repeat: r's own request URI, with BounceParam stripped (it is this module's own bookkeeping, never the page
// the browser should end up on).
func returnURL(r *http.Request) string {
u := *r.URL
if u.RawQuery != "" {
q := u.Query()
q.Del(BounceParam)
u.RawQuery = q.Encode()
}
return u.RequestURI()
}
// bouncedURL is r's own request URI with BounceParam added, so a second cookieless arrival at the same page is
// told apart from the first.
func bouncedURL(r *http.Request) string {
u := *r.URL
q := u.Query()
q.Set(BounceParam, "1")
u.RawQuery = q.Encode()
return u.RequestURI()
}
// Auth wires sign-in into a Server: RequireSession protects a page, an attachment or a send (issue 1263/AC1),
// and Callback is /signed-in.
type Auth struct {
Auth *Authenticator
Sessions *Sessions
Pending *PendingAuths
Operators Operators
Logf func(format string, a ...any)
}
func (a *Auth) logf(format string, args ...any) {
if a.Logf != nil {
a.Logf(format, args...)
}
}
// sessionKey is the context key RequireSession hands the signed-in Session on to the handler it wraps.
type sessionKey struct{}
// SessionFrom is the session a handler wrapped by RequireSession was asked with.
func SessionFrom(r *http.Request) (Session, bool) {
s, ok := r.Context().Value(sessionKey{}).(Session)
return s, ok
}
// RequireSession is issue 1263/AC1, AC5: no session, or one that ended 12 hours ago, is answered a redirect to
// the issuer's authorisation endpoint (state, PKCE challenge, this module's own callback) rather than the page,
// attachment or send asked for; a live session is handed on to next.
//
// A GET that carries no session cookie at all (review round 1 finding 1) is bounced once through this origin's
// own page first, rather than sent to sign in at once: a cookie marked SameSite=Strict is withheld from
// exactly this shape of request when the live session's own cookie would otherwise have been there (Keycloak's
// redirect to the callback, a link from another site), and this origin's own follow-on navigation is where it
// reappears. A cookie that did reach this request but names no live session (it expired, or never existed) is
// not that case — the browser did carry it — so it goes to sign in directly, as does any non-GET (a bounce
// would lose its body) and a second cookieless GET already marked by BounceParam.
func (a *Auth) RequireSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(SessionCookie)
if err == nil {
if sess, ok := a.Sessions.Get(cookie.Value); ok {
ctx := context.WithValue(r.Context(), sessionKey{}, sess)
next(w, r.WithContext(ctx))
return
}
a.redirectToSignIn(w, r)
return
}
if r.Method == http.MethodGet && r.URL.Query().Get(BounceParam) == "" {
renderBounce(w, bouncedURL(r))
return
}
a.redirectToSignIn(w, r)
}
}
func (a *Auth) redirectToSignIn(w http.ResponseWriter, r *http.Request) {
verifier := oauth2.GenerateVerifier()
state, err := a.Pending.Create(verifier, returnURL(r))
if err != nil {
http.Error(w, "sign-in could not be started", http.StatusInternalServerError)
return
}
url := a.Auth.OAuth2.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
http.Redirect(w, r, url, http.StatusFound)
}
// Callback is /signed-in (issue 1263/AC2, AC3, AC4): a state this module did not issue, an exchange that
// fails, an ID token of another audience, signed by another key, or expired, or an identity operators does not
// name, is answered 403 and makes no session. Only once the ID token verifies and names an operator is a
// session made, its cookie set, and the browser sent back to the page it first asked for — through a 200 page
// of this origin's own, not a 302 (review round 1 finding 1): the navigation that landed here started at
// Keycloak, so a redirect onward from it is still, in the browser's own eyes, a navigation Keycloak began, and
// a cookie marked SameSite=Strict (just set by this very answer) is not sent on it or on anything it redirects
// to in turn. A follow-on navigation this page itself starts has no such history, so the cookie is sent on it.
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
refuse := func(why string) {
a.logf("[mesh-mail-web] sign-in refused: %s", why)
http.Error(w, "forbidden", http.StatusForbidden)
}
query := r.URL.Query()
if errDesc := query.Get("error"); errDesc != "" {
refuse("the issuer answered " + errDesc)
return
}
pending, ok := a.Pending.Take(query.Get("state"))
if !ok {
refuse("the state is not one this module issued, or it expired")
return
}
code := query.Get("code")
if code == "" {
refuse("the callback carries no code")
return
}
tok, err := a.Auth.OAuth2.Exchange(r.Context(), code, oauth2.VerifierOption(pending.Verifier))
if err != nil {
refuse(fmt.Sprintf("the code could not be exchanged: %v", err))
return
}
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok || rawIDToken == "" {
refuse("the issuer's answer carries no ID token")
return
}
idToken, err := a.Auth.Verifier.Verify(r.Context(), rawIDToken)
if err != nil {
// Issuer, audience, expiry and signature are all checked here, by the verifier (1263/AC4).
refuse(fmt.Sprintf("the ID token does not verify: %v", err))
return
}
var claims struct {
PreferredUsername string `json:"preferred_username"`
}
if err := idToken.Claims(&claims); err != nil {
refuse(fmt.Sprintf("the ID token's claims could not be read: %v", err))
return
}
allowed, why := a.Operators.Allows(claims.PreferredUsername)
if !allowed {
refuse(why)
return
}
id, err := a.Sessions.Create(claims.PreferredUsername)
if err != nil {
refuse(fmt.Sprintf("the session could not be made: %v", err))
return
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookie,
Value: id,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(SessionLifetime / time.Second),
})
returnTo := pending.Return
if returnTo == "" {
returnTo = "/"
}
renderBounce(w, returnTo)
}