From 8e0ef84060fd0481cdc62d7c5ea4d5d2a35241c4 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 22:16:15 +0200 Subject: [PATCH] Sign in through Keycloak: code flow with PKCE, verified ID token, 12-hour session (issue 1263) Library choice: golang.org/x/oauth2 for the code exchange, github.com/coreos/go-oidc/v3 for the ID token's verification (issuer, audience, expiry, signature from the issuer's JWKS). --- .../mesh-mail-web/cmd/mesh-mail-web/auth.go | 207 ++++++++++ .../cmd/mesh-mail-web/auth_test.go | 364 ++++++++++++++++++ .../mesh-mail-web/cmd/mesh-mail-web/main.go | 38 +- .../mesh-mail-web/cmd/mesh-mail-web/server.go | 15 +- .../cmd/mesh-mail-web/server_test.go | 10 +- .../cmd/mesh-mail-web/sessions.go | 143 +++++++ modules/mesh-mail-web/go.mod | 8 +- modules/mesh-mail-web/go.sum | 6 + 8 files changed, 779 insertions(+), 12 deletions(-) create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/auth.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go create mode 100644 modules/mesh-mail-web/cmd/mesh-mail-web/sessions.go diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/auth.go b/modules/mesh-mail-web/cmd/mesh-mail-web/auth.go new file mode 100644 index 00000000..92ee1a97 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/auth.go @@ -0,0 +1,207 @@ +package main + +// 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" + "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 +} + +// 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. +func (a *Auth) RequireSession(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(SessionCookie); 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) + } +} + +func (a *Auth) redirectToSignIn(w http.ResponseWriter, r *http.Request) { + verifier := oauth2.GenerateVerifier() + state, err := a.Pending.Create(verifier, r.URL.RequestURI()) + 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. +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 = "/" + } + http.Redirect(w, r, returnTo, http.StatusFound) +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go new file mode 100644 index 00000000..b16d269a --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/auth_test.go @@ -0,0 +1,364 @@ +package main + +// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): an httptest stand-in issuer, as the lead's prepare +// findings said to check this against, minting its own ID tokens (RS256) so a test controls exactly what +// go-oidc's verifier is asked to check: the right issuer, audience, expiry and signature, and the wrong ones. + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// fakeIssuer is a minimal OIDC issuer: discovery, JWKS and a token endpoint that answers whatever ID token the +// test set up, regardless of the code it is asked to exchange (the code's own validity is not what issue +// 1263's acceptance criteria are about; the ID token's is). +type fakeIssuer struct { + *httptest.Server + key *rsa.PrivateKey + kid string + nextIDToken string +} + +func newFakeIssuer(t *testing.T) *fakeIssuer { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + fi := &fakeIssuer{key: key, kid: "test-key"} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", fi.discovery) + mux.HandleFunc("/jwks", fi.jwks) + mux.HandleFunc("/token", fi.token) + fi.Server = httptest.NewServer(mux) + t.Cleanup(fi.Server.Close) + return fi +} + +func (fi *fakeIssuer) discovery(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": fi.Server.URL, + "authorization_endpoint": fi.Server.URL + "/authorize", + "token_endpoint": fi.Server.URL + "/token", + "jwks_uri": fi.Server.URL + "/jwks", + "userinfo_endpoint": fi.Server.URL + "/userinfo", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + }) +} + +func (fi *fakeIssuer) jwks(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + n := base64.RawURLEncoding.EncodeToString(fi.key.PublicKey.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(fi.key.PublicKey.E)).Bytes()) + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]any{ + {"kty": "RSA", "alg": "RS256", "use": "sig", "kid": fi.kid, "n": n, "e": e}, + }}) +} + +func (fi *fakeIssuer) token(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": fi.nextIDToken, + }) +} + +func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// sign mints an RS256 JWT of claims, signed with key under kid — the real issuer's own key unless a test asks +// for "signed by another key" (1263/AC4). +func sign(claims map[string]any, kid string, key *rsa.PrivateKey) string { + header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}) + payload, _ := json.Marshal(claims) + signingInput := b64url(header) + "." + b64url(payload) + sum := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) + if err != nil { + panic(err) + } + return signingInput + "." + b64url(sig) +} + +// idToken is a valid ID token's claims, as claimsPatch overrides them (so a test changes exactly one thing — +// the audience, the expiry, the signing key — and nothing else). +func (fi *fakeIssuer) idToken(clientID, username string, now time.Time, claimsPatch map[string]any, key *rsa.PrivateKey, kid string) string { + claims := map[string]any{ + "iss": fi.Server.URL, + "aud": clientID, + "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), + "sub": "user-" + username, + "preferred_username": username, + } + for k, v := range claimsPatch { + claims[k] = v + } + return sign(claims, kid, key) +} + +// testAuth is an Auth over fi, with its own callback redirect https://mail.g14.internal/signed-in, and +// deterministic time so a test controls what "12 hours ago" or "expired" means. +func testAuth(t *testing.T, fi *fakeIssuer, operatorsPath string, now func() time.Time) *Auth { + t.Helper() + cfg := OIDCConfig{ClientID: "mail-web", ClientSecret: "s3cr3t", Issuer: fi.Server.URL} + authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+InternalHost("g14")+SignedInPath) + if err != nil { + t.Fatal(err) + } + return &Auth{ + Auth: authenticator, + Sessions: NewSessions(now), + Pending: NewPendingAuths(now), + Operators: Operators{Path: operatorsPath}, + Logf: t.Logf, + } +} + +func operatorsFile(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + path := dir + "/operators.json" + raw, _ := json.Marshal(map[string]any{"operators": names}) + writeFile(t, path, string(raw)) + return path +} + +// 1263/AC1: no session: a redirect to the issuer's authorisation endpoint with the client id, a state, a PKCE +// challenge, and the redirect https://mail..internal/signed-in. +func TestNoSessionRedirectsToTheIssuersAuthorisationEndpoint(t *testing.T) { + fi := newFakeIssuer(t) + auth := testAuth(t, fi, operatorsFile(t, "alice"), nil) + s := NewServer(InternalHost("g14"), auth) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = InternalHost("g14") + s.ServeHTTP(w, r) + + if w.Code != http.StatusFound { + t.Fatalf("status %d, want 302", w.Code) + } + loc, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(loc.String(), fi.Server.URL+"/authorize") { + t.Fatalf("redirected to %s, not the issuer's authorisation endpoint", loc) + } + q := loc.Query() + if q.Get("client_id") != "mail-web" { + t.Fatalf("client_id: %q", q.Get("client_id")) + } + if q.Get("state") == "" { + t.Fatalf("no state") + } + if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" { + t.Fatalf("no PKCE challenge: %q %q", q.Get("code_challenge"), q.Get("code_challenge_method")) + } + if q.Get("redirect_uri") != "https://"+InternalHost("g14")+SignedInPath { + t.Fatalf("redirect_uri: %q", q.Get("redirect_uri")) + } +} + +// startSignIn drives the redirect step for a test, answering the state and PKCE verifier it issued (read back +// from the pending store by the state the redirect carried), as the next call (the callback) needs them. +func startSignIn(t *testing.T, s *Server, auth *Auth) (state string) { + t.Helper() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = InternalHost("g14") + s.ServeHTTP(w, r) + loc, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + return loc.Query().Get("state") +} + +func callback(s *Server, state, code string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, SignedInPath+"?state="+url.QueryEscape(state)+"&code="+url.QueryEscape(code), nil) + r.Host = InternalHost("g14") + s.ServeHTTP(w, r) + return w +} + +// 1263/AC2: a valid callback whose ID token names an operator sets a session cookie Secure, HttpOnly, +// SameSite=Strict, lasting 12 hours, and sends the browser back to the page it first asked for. +func TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie(t *testing.T) { + fi := newFakeIssuer(t) + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now }) + s := NewServer(InternalHost("g14"), auth) + + state := startSignIn(t, s, auth) + // The session clock (now) is mocked to test the 12-hour bound (TestASessionTwelveHoursOldIsTreatedAsNoSession + // below); the ID token's own iat/exp are judged by go-oidc against the real wall clock, so they are minted + // from it, not from the mocked now. + fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid) + w := callback(s, state, "some-code") + + if w.Code != http.StatusFound { + t.Fatalf("status %d, want 302: %s", w.Code, w.Body.String()) + } + if loc := w.Header().Get("Location"); loc != "/" { + t.Fatalf("returned to %q, want the page first asked for (/)", loc) + } + var found *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == SessionCookie { + found = c + } + } + if found == nil { + t.Fatal("no session cookie set") + } + if !found.Secure || !found.HttpOnly || found.SameSite != http.SameSiteStrictMode { + t.Fatalf("cookie attributes: Secure=%v HttpOnly=%v SameSite=%v", found.Secure, found.HttpOnly, found.SameSite) + } + if found.MaxAge != int(SessionLifetime/time.Second) { + t.Fatalf("MaxAge %d, want %d (12h)", found.MaxAge, int(SessionLifetime/time.Second)) + } +} + +// 1263/AC3: an ID token naming an identity not in operators is answered 403, and no session cookie is set. +func TestACallbackForAnIdentityNotInOperatorsIsRefused(t *testing.T) { + fi := newFakeIssuer(t) + now := time.Now() + auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now }) + s := NewServer(InternalHost("g14"), auth) + + state := startSignIn(t, s, auth) + fi.nextIDToken = fi.idToken("mail-web", "mallory", now, nil, fi.key, fi.kid) + w := callback(s, state, "some-code") + + if w.Code != http.StatusForbidden { + t.Fatalf("status %d, want 403", w.Code) + } + if len(w.Result().Cookies()) != 0 { + t.Fatalf("a cookie was set for a refused identity: %v", w.Result().Cookies()) + } +} + +// 1263/AC4: a state this module did not issue, an ID token of another audience, one signed by another key, or +// one expired — each answered 403, no session made. +func TestACallbackIsRefusedOnAnUnknownStateOrAnInvalidIDToken(t *testing.T) { + fi := newFakeIssuer(t) + now := time.Now() + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + setup func(auth *Auth, s *Server) (state string) + token func() string + }{ + {"unknown state", func(auth *Auth, s *Server) string { return "a-state-nobody-issued" }, + func() string { return fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid) }}, + {"wrong audience", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth) }, + func() string { return fi.idToken("someone-else", "alice", now, nil, fi.key, fi.kid) }}, + {"wrong signing key", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth) }, + func() string { return fi.idToken("mail-web", "alice", now, nil, otherKey, fi.kid) }}, + {"expired", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth) }, + func() string { + return fi.idToken("mail-web", "alice", now, map[string]any{"exp": now.Add(-time.Hour).Unix()}, fi.key, fi.kid) + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now }) + s := NewServer(InternalHost("g14"), auth) + state := c.setup(auth, s) + fi.nextIDToken = c.token() + w := callback(s, state, "some-code") + if w.Code != http.StatusForbidden { + t.Fatalf("%s: status %d, want 403: %s", c.name, w.Code, w.Body.String()) + } + if len(w.Result().Cookies()) != 0 { + t.Fatalf("%s: a cookie was set", c.name) + } + }) + } +} + +// 1263/AC5: a session made 12 hours ago is treated as no session: a page asked then is redirected to sign in +// again. +func TestASessionTwelveHoursOldIsTreatedAsNoSession(t *testing.T) { + fi := newFakeIssuer(t) + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + now := start + auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now }) + s := NewServer(InternalHost("g14"), auth) + + state := startSignIn(t, s, auth) + // The ID token's own iat/exp are judged against the real wall clock (see the comment in + // TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie above); the session's own age is what + // this test mocks, through the session clock (now). + fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid) + w := callback(s, state, "some-code") + var cookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == SessionCookie { + cookie = c + } + } + if cookie == nil { + t.Fatal("no session cookie set") + } + + // Just under 12 hours: still a session. + now = start.Add(12*time.Hour - time.Minute) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = InternalHost("g14") + r.AddCookie(cookie) + w2 := httptest.NewRecorder() + s.ServeHTTP(w2, r) + if w2.Code != http.StatusOK { + t.Fatalf("just under 12h: status %d, want 200", w2.Code) + } + + // Past 12 hours: no session any more — redirected to sign in again. + now = start.Add(12*time.Hour + time.Minute) + r2 := httptest.NewRequest(http.MethodGet, "/", nil) + r2.Host = InternalHost("g14") + r2.AddCookie(cookie) + w3 := httptest.NewRecorder() + s.ServeHTTP(w3, r2) + if w3.Code != http.StatusFound { + t.Fatalf("past 12h: status %d, want 302 (redirect to sign in)", w3.Code) + } +} + +// 1263/AC6: operators empty lets no identity in. +func TestCallbackWithEmptyOperatorsLetsNobodyIn(t *testing.T) { + fi := newFakeIssuer(t) + now := time.Now() + auth := testAuth(t, fi, operatorsFile(t), func() time.Time { return now }) + s := NewServer(InternalHost("g14"), auth) + + state := startSignIn(t, s, auth) + fi.nextIDToken = fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid) + w := callback(s, state, "some-code") + if w.Code != http.StatusForbidden { + t.Fatalf("status %d, want 403", w.Code) + } +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/main.go b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go index 46009ecb..35af11ce 100644 --- a/modules/mesh-mail-web/cmd/mesh-mail-web/main.go +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go @@ -7,10 +7,12 @@ package main import ( + "context" "fmt" "io" "net/http" "os" + "time" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -27,18 +29,50 @@ func main() { } // serveHTTP starts the HTTP endpoint `web` (issue 1283), on the port the mesh gave it; a listen it cannot -// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel. +// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel. Sign-in (issue 1263) +// is set up first, retried on a failure (the issuer may not answer yet): never served with no session guard +// at all, which a nil Auth would be (server.go's own fallback, used by its unit tests alone). func serveHTTP(b *Bundle) { if b.Env.Listen == "" { logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served") return } - s := NewServer(InternalHost(b.Env.Node)) + host := InternalHost(b.Env.Node) + var auth *Auth + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + a, err := buildAuth(host, b.Env) + if err == nil { + auth = a + break + } + logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait) + time.Sleep(wait) + } + s := NewServer(host, auth) if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil { logf("[mesh-mail-web] its HTTP endpoint ended: %v", err) } } +// buildAuth reads the oidc-client binding (issue 1262's oidc.json) and discovers the issuer (issue 1263). +func buildAuth(host string, env Env) (*Auth, error) { + cfg, err := ReadOIDCConfig(env.OIDC) + if err != nil { + return nil, err + } + authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+host+SignedInPath) + if err != nil { + return nil, err + } + return &Auth{ + Auth: authenticator, + Sessions: NewSessions(nil), + Pending: NewPendingAuths(nil), + Operators: Operators{Path: env.Operators}, + Logf: logf, + }, nil +} + // run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout // (mirroring mesh-mail-notifier's own run). func run(b *Bundle, in io.Reader, out io.Writer) error { diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go index 087a9963..6fbd5a35 100644 --- a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go @@ -32,13 +32,20 @@ func InternalHost(node string) string { return "mail." + node + ".internal" } type Server struct { Host string // InternalHost(node) Mux *http.ServeMux + Auth *Auth // nil refuses no page by session (1283 alone); issue 1263 gives one } // NewServer is a Server for host, with the root page issue 1283 itself answers (a stand-in: the real accounts -// and folder pages are issue 1264's). -func NewServer(host string) *Server { - s := &Server{Host: host, Mux: http.NewServeMux()} - s.Mux.HandleFunc("/", s.rootPage) +// and folder pages are issue 1264's) and, when auth is given, the sign-in callback (issue 1263) and every page +// behind a session (RequireSession). +func NewServer(host string, auth *Auth) *Server { + s := &Server{Host: host, Mux: http.NewServeMux(), Auth: auth} + root := s.rootPage + if auth != nil { + s.Mux.HandleFunc(SignedInPath, auth.Callback) + root = auth.RequireSession(root) + } + s.Mux.HandleFunc("/", root) return s } diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go index b2382043..2c25e35a 100644 --- a/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go @@ -12,7 +12,7 @@ import ( // 1283/AC1: a request whose Host is not mail..internal — a public name, an IP, or none — is answered // 404, naming nothing of the mail. func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) { - s := NewServer(InternalHost("g14")) + s := NewServer(InternalHost("g14"), nil) for _, host := range []string{"example.com", "203.0.113.5", "", "mail.other-node.internal"} { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) @@ -29,7 +29,7 @@ func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) { // Its own internal name is answered (not 404). func TestServerAnswersItsOwnInternalName(t *testing.T) { - s := NewServer(InternalHost("g14")) + s := NewServer(InternalHost("g14"), nil) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.Host = InternalHost("g14") @@ -41,7 +41,7 @@ func TestServerAnswersItsOwnInternalName(t *testing.T) { // A Host with a port still matches, port and all (the mesh's own listen carries one). func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) { - s := NewServer(InternalHost("g14")) + s := NewServer(InternalHost("g14"), nil) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.Host = InternalHost("g14") + ":8099" @@ -53,7 +53,7 @@ func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) { // 1283/AC2: every answer (200 and 404 alike) carries the content security policy. func TestServerSetsTheContentSecurityPolicyOnEveryAnswer(t *testing.T) { - s := NewServer(InternalHost("g14")) + s := NewServer(InternalHost("g14"), nil) cases := []string{"example.com", InternalHost("g14")} for _, host := range cases { w := httptest.NewRecorder() @@ -116,7 +116,7 @@ func TestServerStartsAndAnswersAPageWithACwdAndHomeReadOnly(t *testing.T) { } _ = os.Setenv("HOME", filepath.Join(ro, "home-does-not-exist")) - s := NewServer(InternalHost("g14")) + s := NewServer(InternalHost("g14"), nil) srv := httptest.NewServer(s) defer srv.Close() diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/sessions.go b/modules/mesh-mail-web/cmd/mesh-mail-web/sessions.go new file mode 100644 index 00000000..26579612 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/sessions.go @@ -0,0 +1,143 @@ +package main + +// Sessions and the pending authorisations in flight (novox/hq ADR 0440 §3, issue 1263): both kept in memory +// only, never on disk — a restart signs everyone out, accepted (design 59 milestone 1's prepare step). + +import ( + "crypto/rand" + "encoding/base64" + "sync" + "time" +) + +// SessionCookie is the cookie's own name. +const SessionCookie = "mesh_mail_web_session" + +// SessionLifetime is how long a session lasts after it is made (novox/hq ADR 0440 §3, issue 1263/AC2, AC5). +const SessionLifetime = 12 * time.Hour + +// PendingLifetime is how long a sign-in may take between the redirect to the issuer and its callback, before +// the state is no longer honoured. +const PendingLifetime = 10 * time.Minute + +func randomID() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// Session is one signed-in operator's own session: its identity, when it was made (SessionLifetime decides +// when it ends), and the per-session token issue 1284 binds every changing form to. +type Session struct { + Operator string + Made time.Time + Token string +} + +// Sessions keeps every session in memory, by its cookie's own id. +type Sessions struct { + mu sync.Mutex + now func() time.Time + byID map[string]Session +} + +// NewSessions is an empty Sessions; now is time.Now unless a test gives its own. +func NewSessions(now func() time.Time) *Sessions { + if now == nil { + now = time.Now + } + return &Sessions{now: now, byID: map[string]Session{}} +} + +// Create starts a new session for operator, answering its id (the cookie's own value) and its own per-session +// token (issue 1284). +func (s *Sessions) Create(operator string) (id string, err error) { + id, err = randomID() + if err != nil { + return "", err + } + token, err := randomID() + if err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + s.byID[id] = Session{Operator: operator, Made: s.now(), Token: token} + return id, nil +} + +// Get is the session at id, or false when there is none or it ended SessionLifetime ago (1263/AC5): an expired +// session reads as no session at all, dropped on the same look (no background sweep is needed: every read +// already checks the time). +func (s *Sessions) Get(id string) (Session, bool) { + if id == "" { + return Session{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.byID[id] + if !ok { + return Session{}, false + } + if s.now().Sub(sess.Made) > SessionLifetime { + delete(s.byID, id) + return Session{}, false + } + return sess, true +} + +// PendingAuth is one sign-in asked of the issuer, remembered between the redirect and its callback: the PKCE +// verifier the challenge was made from, and the page the browser first asked for. +type PendingAuth struct { + Verifier string + Return string + Created time.Time +} + +// PendingAuths keeps every sign-in in flight, by its own state, in memory only. +type PendingAuths struct { + mu sync.Mutex + now func() time.Time + byState map[string]PendingAuth +} + +// NewPendingAuths is an empty PendingAuths; now is time.Now unless a test gives its own. +func NewPendingAuths(now func() time.Time) *PendingAuths { + if now == nil { + now = time.Now + } + return &PendingAuths{now: now, byState: map[string]PendingAuth{}} +} + +// Create remembers one sign-in in flight, answering the state it is kept under. +func (p *PendingAuths) Create(verifier, returnTo string) (state string, err error) { + state, err = randomID() + if err != nil { + return "", err + } + p.mu.Lock() + defer p.mu.Unlock() + p.byState[state] = PendingAuth{Verifier: verifier, Return: returnTo, Created: p.now()} + return state, nil +} + +// Take reads back and removes the sign-in kept under state — once; a state never issued, already taken, or +// older than PendingLifetime answers false (1263/AC4's "a state it did not issue"). +func (p *PendingAuths) Take(state string) (PendingAuth, bool) { + if state == "" { + return PendingAuth{}, false + } + p.mu.Lock() + defer p.mu.Unlock() + pa, ok := p.byState[state] + if !ok { + return PendingAuth{}, false + } + delete(p.byState, state) + if p.now().Sub(pa.Created) > PendingLifetime { + return PendingAuth{}, false + } + return pa, true +} diff --git a/modules/mesh-mail-web/go.mod b/modules/mesh-mail-web/go.mod index baf38bd6..9664b173 100644 --- a/modules/mesh-mail-web/go.mod +++ b/modules/mesh-mail-web/go.mod @@ -2,4 +2,10 @@ module mesh-mail-web go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c + github.com/coreos/go-oidc/v3 v3.21.0 + golang.org/x/oauth2 v0.37.0 +) + +require github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/modules/mesh-mail-web/go.sum b/modules/mesh-mail-web/go.sum index 25aaa963..f405e4dd 100644 --- a/modules/mesh-mail-web/go.sum +++ b/modules/mesh-mail-web/go.sum @@ -1,2 +1,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c h1:/f6f2ugss172hDJSL3EFZqb9PdfO5DKzYVfPiX2LdAM= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=