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:///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(` Mail

Continue

`)) // 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) }