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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user