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.
276 lines
11 KiB
Go
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)
|
|
}
|