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).
This commit is contained in:
jochen
2026-10-19 22:16:15 +02:00
parent 68c141849f
commit 8e0ef84060
8 changed files with 779 additions and 12 deletions
@@ -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://<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
}
// 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)
}
@@ -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.<node>.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)
}
}
@@ -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 {
@@ -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
}
@@ -12,7 +12,7 @@ import (
// 1283/AC1: a request whose Host is not mail.<node>.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()
@@ -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
}
+7 -1
View File
@@ -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
+6
View File
@@ -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=