diff --git a/.env.example b/.env.example index 19e8cf5..a3a694f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,11 @@ FMSG_DATA_DIR=/var/lib/fmsgd/ + +# Production RS256 JWT verification (uncomment all four to use JWKS mode). +#FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json +#FMSG_JWT_ISSUER=https://idp.example.com/ +#FMSG_JWT_AUDIENCE=fmsg-web-client +#FMSG_JWT_ADDRESS_CLAIM=fmsg_address + FMSG_API_JWT_SECRET=fmsg-dev-secret-do-not-use-in-production PGHOST=localhost PGUSER=fmsg @@ -10,4 +17,4 @@ PGSSLMODE=disable # with: npx web-push generate-vapid-keys #FMSG_VAPID_PUBLIC_KEY= #FMSG_VAPID_PRIVATE_KEY= -#FMSG_VAPID_SUBJECT=mailto:admin@example.com \ No newline at end of file +#FMSG_VAPID_SUBJECT=mailto:admin@example.com diff --git a/AGENTS.md b/AGENTS.md index a91d811..8fd0eca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,11 @@ - Build: `cd src && go build ./...` - Test: `cd src && go test ./...` - Always run tests after making code changes to verify nothing is broken. +- This repository is an open-source implementation and not tied to one specific deployment - config should come from the environment ## README -**Keep `README.md` up to date whenever you:** +**Keep `README.md` up to date and concise whenever you:** - Add, remove, or rename a route. - Change a route's query parameters, request body, or response shape. @@ -21,7 +22,7 @@ The API routes table and each route's section must reflect the live code in ## Database -- Schema source of truth: `https://github.com/markmnl/fmsgd/blob/add-to-batches/dd.sql` +- Schema source of truth: `https://github.com/markmnl/fmsgd/blob/main/dd.sql` - Ensure all SQL in Go source files aligns with that schema. - When adding recipients via the `add-to` route, insert one `msg_add_to_batch` row (`add_to_from` = authenticated identity, plus `time_added`) and insert the diff --git a/README.md b/README.md index 9dd3aa9..192c27c 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o | Variable | Default | Description | | ------------------- | ------------------------ | ------------------------------------------------------- | | `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/var/lib/fmsgd/` | -| `FMSG_JWT_JWKS_URL` | *(prod)* | URL of the IdP's JWKS endpoint (e.g. `https://idp.example.com/.well-known/jwks.json`). When set, the API verifies EdDSA tokens issued by the IdP. Public keys are fetched and cached, refreshed and looked up by the token's `kid` header. | -| `FMSG_JWT_ISSUER` | *(prod, required with JWKS)* | Expected `iss` claim value (e.g. `https://idp.example.com`). Tokens with a different issuer are rejected. | -| `FMSG_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. | +| `FMSG_JWT_JWKS_URL` | *(prod)* | JWKS endpoint for the configured identity provider (e.g. `https://idp.example.com/.well-known/jwks.json`). When set, the API verifies RS256 JWTs. RSA public keys are fetched and cached, refreshed and looked up by the token's `kid` header. | +| `FMSG_JWT_ISSUER` | *(prod, required with JWKS)* | Expected `iss` claim value (e.g. `https://idp.example.com/`). Tokens with a different issuer are rejected. This must exactly match the token issuer. | +| `FMSG_JWT_AUDIENCE` | *(prod, required with JWKS)* | Expected `aud` claim value for this application or API. | +| `FMSG_JWT_ADDRESS_CLAIM` | *(prod, required with JWKS)* | JWT claim name containing the fmsg address in `@user@domain` form, e.g. `fmsg_address` or a namespaced custom claim. | | `FMSG_API_JWT_SECRET` | *(dev)* | HMAC secret for HS256 token verification. Used only in dev mode (when `FMSG_JWT_JWKS_URL` is unset). Prefix with `base64:` to supply a base64-encoded key. Either this or `FMSG_JWT_JWKS_URL` must be set. | | `FMSG_TLS_CERT` | *(optional)* | Path to the TLS certificate file (e.g. `/etc/letsencrypt/live/example.com/fullchain.pem`). When set with `FMSG_TLS_KEY`, enables HTTPS. | | `FMSG_TLS_KEY` | *(optional)* | Path to the TLS private key file (e.g. `/etc/letsencrypt/live/example.com/privkey.pem`). Must be set together with `FMSG_TLS_CERT`. | @@ -38,27 +39,34 @@ A `.env` file placed in the working directory is loaded automatically at startup All `/fmsg/*` routes require an `Authorization: Bearer ` header. The API operates in one of two verification modes, selected automatically at startup: -### EdDSA (production) +### RS256 (production, JWKS-backed JWTs) -Active when `FMSG_JWT_JWKS_URL` is set. Tokens are expected to be issued by the -fmsg IdP and signed with Ed25519. The JWKS endpoint is polled on a schedule; -the IdP can rotate keys by adding a new JWK with a fresh `kid`. +Active when `FMSG_JWT_JWKS_URL` is set. Tokens must be issued by the configured +identity provider and signed with RS256. The JWKS endpoint is polled on a +schedule; the provider can rotate keys by adding a new JWK with a fresh `kid`. -Required token header: `alg: EdDSA`, `kid: `, `typ: JWT`. +Required token header: `alg: RS256`, `kid: `, `typ: JWT`. -Required claims: +Relevant claims: | Claim | Description | | ----- | ----------- | | `iss` | Must equal `FMSG_JWT_ISSUER`. | -| `sub` | User address in `@user@domain` form. | -| `iat` | Issued-at timestamp (Unix seconds). | -| `nbf` | Not-before timestamp. | +| `aud` | Must contain `FMSG_JWT_AUDIENCE`. | +| configured address claim | The claim named by `FMSG_JWT_ADDRESS_CLAIM`; must contain the user address in `@user@domain` form. Tokens without this claim are rejected with `403 no fmsg account for this identity`. | +| `sub` | Provider-specific identity. It is validated as part of the signed token but is not used as the fmsg address in RS256 mode. | | `exp` | Expiry timestamp (must be in the future, ±10 s leeway). | -| `jti` | Optional unique token ID. | -| `aud` | Optional; required only when `FMSG_JWT_AUDIENCE` is set. | +| `iat` | Optional issued-at timestamp; validated when present. | +| `nbf` | Optional not-before timestamp; validated when present. | -A 10-second clock-skew leeway is applied to `iat`/`nbf`/`exp` validation. +A 10-second clock-skew leeway is applied to `iat`/`nbf`/`exp` validation. After +the address claim is validated, the API checks fmsgid to confirm the address is +known and accepting new messages. + +Clients must send a JWT that matches the configured issuer and audience and +includes the configured address claim. Whether that token is an ID token or +access token is determined by the identity provider configuration for the +deployment. ### HMAC (development) @@ -92,7 +100,9 @@ by default; override with `FMSG_API_PORT`. ```bash export FMSG_DATA_DIR=/opt/fmsg/data export FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json -export FMSG_JWT_ISSUER=https://idp.example.com +export FMSG_JWT_ISSUER=https://idp.example.com/ +export FMSG_JWT_AUDIENCE=fmsg-web-client +export FMSG_JWT_ADDRESS_CLAIM=fmsg_address export FMSG_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem export FMSG_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem export PGHOST=localhost diff --git a/src/handlers/push.go b/src/handlers/push.go index 7b474bd..0414fc7 100644 --- a/src/handlers/push.go +++ b/src/handlers/push.go @@ -40,7 +40,7 @@ type PushHandler struct { } // NewPushHandler creates a PushHandler. vapidPublic/vapidPrivate are URL-safe -// base64 VAPID keys; vapidSubject is the VAPID "sub" (e.g. "mailto:admin@fmsg.io"). +// base64 VAPID keys; vapidSubject is the VAPID "sub" (e.g. "mailto:admin@example.com"). func NewPushHandler(database *db.DB, msgs *MessageHandler, vapidPublic, vapidPrivate, vapidSubject, iconURL string) *PushHandler { h := &PushHandler{ DB: database, diff --git a/src/main.go b/src/main.go index 0e0355d..f76ccdd 100644 --- a/src/main.go +++ b/src/main.go @@ -29,11 +29,12 @@ func main() { dataDir := mustEnv("FMSG_DATA_DIR") // JWT configuration. Mode is selected automatically: - // * EdDSA (prod) when FMSG_JWT_JWKS_URL is set. + // * RS256 (prod, JWKS-backed JWTs) when FMSG_JWT_JWKS_URL is set. // * HMAC (dev) otherwise, using FMSG_API_JWT_SECRET. jwksURL := os.Getenv("FMSG_JWT_JWKS_URL") jwtIssuer := os.Getenv("FMSG_JWT_ISSUER") jwtAudience := os.Getenv("FMSG_JWT_AUDIENCE") + jwtAddressClaim := os.Getenv("FMSG_JWT_ADDRESS_CLAIM") // TLS configuration (optional — omit both to run plain HTTP). tlsCert := os.Getenv("FMSG_TLS_CERT") @@ -51,7 +52,7 @@ func main() { shortTextSize := envOrDefaultInt("FMSG_API_SHORT_TEXT_SIZE", 768) // CORS: comma-separated list of allowed browser origins, e.g. - // "https://fmsg.io,https://www.fmsg.io". Empty disables CORS. + // "https://app.example.com,https://www.example.com". Empty disables CORS. corsOrigins := parseCSV(os.Getenv("FMSG_CORS_ORIGINS")) // Web Push (VAPID) configuration. Push is enabled only when all three @@ -74,7 +75,7 @@ func main() { log.Println("connected to PostgreSQL") // Initialise JWT middleware. - jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, idURL) + jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, jwtAddressClaim, idURL) if err != nil { log.Fatalf("failed to configure JWT: %v", err) } @@ -233,26 +234,27 @@ func envOrDefaultInt(key string, defaultValue int) int { } // buildJWTConfig assembles a middleware.Config from environment-derived -// inputs, picking EdDSA (prod) when a JWKS URL is supplied and falling back -// to HMAC (dev) otherwise. -func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, idURL string) (middleware.Config, error) { +// inputs, picking RS256 (prod, JWKS-backed JWTs) when a JWKS URL is supplied +// and falling back to HMAC (dev) otherwise. +func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, addressClaim, idURL string) (middleware.Config, error) { cfg := middleware.Config{ - Issuer: issuer, - Audience: audience, - IDURL: idURL, + Issuer: issuer, + Audience: audience, + AddressClaim: addressClaim, + IDURL: idURL, } if jwksURL != "" { - if issuer == "" { - return cfg, errors.New("FMSG_JWT_ISSUER is required when FMSG_JWT_JWKS_URL is set") + if issuer == "" || audience == "" || addressClaim == "" { + return cfg, errors.New("FMSG_JWT_ISSUER, FMSG_JWT_AUDIENCE and FMSG_JWT_ADDRESS_CLAIM are required when FMSG_JWT_JWKS_URL is set") } k, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL}) if err != nil { return cfg, err } - cfg.Mode = middleware.ModeEdDSA + cfg.Mode = middleware.ModeRS256 cfg.JWKS = k.Keyfunc - log.Printf("JWT mode: EdDSA (issuer=%s, jwks=%s)", issuer, jwksURL) + log.Printf("JWT mode: RS256 (issuer=%s, jwks=%s, audience=%s, address_claim=%s)", issuer, jwksURL, audience, addressClaim) return cfg, nil } diff --git a/src/middleware/cors.go b/src/middleware/cors.go index 7ba4272..ba9e8c9 100644 --- a/src/middleware/cors.go +++ b/src/middleware/cors.go @@ -12,8 +12,8 @@ import ( // CORSConfig configures the CORS middleware. type CORSConfig struct { // AllowedOrigins is the list of exact origins permitted to access the API - // from a browser, e.g. "https://fmsg.io". A single entry of "*" allows any - // origin (only valid when credentials are not used). An empty list + // from a browser, e.g. "https://app.example.com". A single entry of "*" + // allows any origin (only valid when credentials are not used). An empty list // disables CORS entirely. AllowedOrigins []string // AllowedMethods are the HTTP methods returned in the preflight response. diff --git a/src/middleware/cors_test.go b/src/middleware/cors_test.go index 2b648cf..edc9680 100644 --- a/src/middleware/cors_test.go +++ b/src/middleware/cors_test.go @@ -23,7 +23,7 @@ func newCORSTestRouter(origins []string) *gin.Engine { } func TestCORS_NoOriginPassesThrough(t *testing.T) { - r := newCORSTestRouter([]string{"https://fmsg.io"}) + r := newCORSTestRouter([]string{"https://app.example.com"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/x", nil) r.ServeHTTP(w, req) @@ -37,17 +37,17 @@ func TestCORS_NoOriginPassesThrough(t *testing.T) { } func TestCORS_AllowedOriginGetsHeaders(t *testing.T) { - r := newCORSTestRouter([]string{"https://fmsg.io"}) + r := newCORSTestRouter([]string{"https://app.example.com"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/x", nil) - req.Header.Set("Origin", "https://fmsg.io") + req.Header.Set("Origin", "https://app.example.com") r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } - if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://fmsg.io" { - t.Errorf("Access-Control-Allow-Origin = %q, want https://fmsg.io", got) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Errorf("Access-Control-Allow-Origin = %q, want https://app.example.com", got) } if got := w.Header().Get("Vary"); got == "" { t.Errorf("Vary header missing") @@ -55,7 +55,7 @@ func TestCORS_AllowedOriginGetsHeaders(t *testing.T) { } func TestCORS_DisallowedOriginGetsNoHeaders(t *testing.T) { - r := newCORSTestRouter([]string{"https://fmsg.io"}) + r := newCORSTestRouter([]string{"https://app.example.com"}) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/x", nil) req.Header.Set("Origin", "https://evil.example") @@ -72,7 +72,7 @@ func TestCORS_DisallowedOriginGetsNoHeaders(t *testing.T) { func TestCORS_PreflightShortCircuits(t *testing.T) { r := gin.New() cfg := DefaultCORSConfig() - cfg.AllowedOrigins = []string{"https://fmsg.io"} + cfg.AllowedOrigins = []string{"https://app.example.com"} r.Use(NewCORS(cfg)) // Downstream middleware that would reject if reached. r.Use(func(c *gin.Context) { @@ -82,7 +82,7 @@ func TestCORS_PreflightShortCircuits(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodOptions, "/x", nil) - req.Header.Set("Origin", "https://fmsg.io") + req.Header.Set("Origin", "https://app.example.com") req.Header.Set("Access-Control-Request-Method", "POST") req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type") r.ServeHTTP(w, req) @@ -90,7 +90,7 @@ func TestCORS_PreflightShortCircuits(t *testing.T) { if w.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204", w.Code) } - if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://fmsg.io" { + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { t.Errorf("Access-Control-Allow-Origin = %q", got) } if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" { @@ -120,7 +120,7 @@ func TestCORS_DisabledWhenNoOrigins(t *testing.T) { r := newCORSTestRouter(nil) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/x", nil) - req.Header.Set("Origin", "https://fmsg.io") + req.Header.Set("Origin", "https://app.example.com") r.ServeHTTP(w, req) if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { diff --git a/src/middleware/jwt.go b/src/middleware/jwt.go index 5346c4e..7b42108 100644 --- a/src/middleware/jwt.go +++ b/src/middleware/jwt.go @@ -30,31 +30,36 @@ const ( // ModeHMAC verifies HS256 tokens with a shared symmetric secret. // Intended for development and testing. ModeHMAC Mode = iota - // ModeEdDSA verifies EdDSA (Ed25519) tokens whose public keys are - // served by an external IdP via JWKS. - ModeEdDSA + // ModeRS256 verifies RS256 JWTs whose public keys are served via JWKS. + ModeRS256 ) // Config configures the JWT middleware. type Config struct { - // Mode selects HMAC (dev) or EdDSA (prod) verification. + // Mode selects HMAC (dev) or RS256 (prod) verification. Mode Mode // HMACKey is the symmetric secret bytes (required when Mode == ModeHMAC). HMACKey []byte - // JWKS resolves Ed25519 public keys (typically by token header `kid`). - // Required when Mode == ModeEdDSA. + // JWKS resolves RSA public keys (typically by token header `kid`). + // Required when Mode == ModeRS256. JWKS jwt.Keyfunc // Issuer, when non-empty, is required to match the token `iss` claim. - // Mandatory in EdDSA mode. + // Mandatory in RS256 mode. Issuer string // Audience, when non-empty, is required to be present in the token - // `aud` claim. Optional. + // `aud` claim. Mandatory in RS256 mode to pin tokens to the configured + // application or API. Audience string + // AddressClaim is the JWT claim name carrying the user's fmsg address. + // Mandatory in RS256 mode because external identity providers usually + // put provider-specific identifiers in `sub`. + AddressClaim string + // IDURL is the base URL of the fmsgid identity service. IDURL string @@ -68,9 +73,11 @@ type Config struct { // which authenticates outside the Gin middleware chain (browsers cannot set an // Authorization header on a WebSocket connection). type Verifier struct { - parser *jwt.Parser - keyFunc jwt.Keyfunc - idURL string + mode Mode + parser *jwt.Parser + keyFunc jwt.Keyfunc + idURL string + addressClaim string } // NewVerifier constructs a Verifier from the given configuration. @@ -97,17 +104,23 @@ func NewVerifier(cfg Config) (*Verifier, error) { } return key, nil } - case ModeEdDSA: + case ModeRS256: if cfg.JWKS == nil { - return nil, errors.New("middleware: EdDSA mode requires a JWKS keyfunc") + return nil, errors.New("middleware: RS256 mode requires a JWKS keyfunc") } if cfg.Issuer == "" { - return nil, errors.New("middleware: EdDSA mode requires an Issuer") + return nil, errors.New("middleware: RS256 mode requires an Issuer") + } + if cfg.Audience == "" { + return nil, errors.New("middleware: RS256 mode requires an Audience") } - validMethods = []string{jwt.SigningMethodEdDSA.Alg()} + if cfg.AddressClaim == "" { + return nil, errors.New("middleware: RS256 mode requires an AddressClaim") + } + validMethods = []string{jwt.SigningMethodRS256.Alg()} jwks := cfg.JWKS keyFunc = func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) } return jwks(t) @@ -130,14 +143,21 @@ func NewVerifier(cfg Config) (*Verifier, error) { } return &Verifier{ - parser: jwt.NewParser(parserOpts...), - keyFunc: keyFunc, - idURL: cfg.IDURL, + mode: cfg.Mode, + parser: jwt.NewParser(parserOpts...), + keyFunc: keyFunc, + idURL: cfg.IDURL, + addressClaim: cfg.AddressClaim, }, nil } // Authenticate parses & verifies a bearer token string, validates its claims, -// and confirms via fmsgid that the user is known and accepting messages. +// derives the user's fmsg address, and confirms via fmsgid that the user is +// known and accepting messages. +// +// The address is derived per mode: in RS256 mode the address comes from the +// configured address claim because `sub` is usually a provider-specific +// identifier; in HMAC dev mode the `sub` claim is the address. // // On success it returns the user address and http.StatusOK. On failure it // returns the empty address, an HTTP status (400/401/403/503), and a @@ -149,9 +169,23 @@ func (v *Verifier) Authenticate(tokenStr string) (addr string, status int, msg s return "", http.StatusUnauthorized, "invalid token" } - addr, _ = claims["sub"].(string) + switch v.mode { + case ModeRS256: + // The token is valid and the user authenticated; a missing address + // claim just means no fmsg account exists yet, so respond 403 + // rather than 401 (which would trigger client token refreshes). + addr, _ = claims[v.addressClaim].(string) + if addr == "" { + sub, _ := claims["sub"].(string) + log.Printf("auth rejected: reason=no_address_claim claim=%q sub=%q", v.addressClaim, sub) + return "", http.StatusForbidden, "no fmsg account for this identity" + } + default: + addr, _ = claims["sub"].(string) + } + if !IsValidAddr(addr) { - log.Printf("auth rejected: reason=invalid_addr sub=%q", addr) + log.Printf("auth rejected: reason=invalid_addr addr=%q", addr) return "", http.StatusUnauthorized, "invalid identity" } @@ -181,7 +215,8 @@ func (v *Verifier) Authenticate(tokenStr string) (addr string, status int, msg s // - extracts a Bearer token from the Authorization header, // - parses & verifies the signature according to cfg.Mode, // - validates iss/aud/exp/nbf claims, -// - extracts sub as the user address and validates its shape, +// - derives the user address (RS256: the configured address claim; +// HMAC: the sub claim) and validates its shape, // - calls fmsgid to confirm the user is known and accepting messages, // - on success stores the address in the Gin context under IdentityKey. // diff --git a/src/middleware/jwt_test.go b/src/middleware/jwt_test.go index f3e0ea2..679782e 100644 --- a/src/middleware/jwt_test.go +++ b/src/middleware/jwt_test.go @@ -1,8 +1,8 @@ package middleware import ( - "crypto/ed25519" "crypto/rand" + "crypto/rsa" "encoding/json" "net/http" "net/http/httptest" @@ -13,6 +13,13 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// Provider values used by the RS256 fixtures. +const ( + testIssuer = "https://issuer.example.test/" + testAudience = "fmsg-web-client" + testAddressClaim = "fmsg_address" +) + func init() { gin.SetMode(gin.TestMode) } @@ -40,9 +47,9 @@ func TestIsValidAddr(t *testing.T) { } } -// fakeJWKS returns a jwt.Keyfunc that yields a fixed Ed25519 public key for -// a single known kid. -func fakeJWKS(kid string, pub ed25519.PublicKey) jwt.Keyfunc { +// fakeJWKS returns a jwt.Keyfunc that yields a fixed RSA public key for a +// single known kid. +func fakeJWKS(kid string, pub *rsa.PublicKey) jwt.Keyfunc { return func(t *jwt.Token) (interface{}, error) { k, _ := t.Header["kid"].(string) if k != kid { @@ -81,9 +88,9 @@ func runMiddleware(t *testing.T, mw gin.HandlerFunc, token string) *httptest.Res return w } -func signEdDSA(t *testing.T, priv ed25519.PrivateKey, kid string, claims jwt.MapClaims) string { +func signRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { t.Helper() - tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) tok.Header["kid"] = kid s, err := tok.SignedString(priv) if err != nil { @@ -102,6 +109,22 @@ func signHS256(t *testing.T, secret []byte, claims jwt.MapClaims) string { return s } +// rs256Claims returns provider-token-shaped claims carrying the given fmsg +// address in the configured address claim. +func rs256Claims(addr string) jwt.MapClaims { + claims := jwt.MapClaims{ + "iss": testIssuer, + "aud": testAudience, + "sub": "provider|abc123", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + } + if addr != "" { + claims[testAddressClaim] = addr + } + return claims +} + func TestHMACMode_Happy(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() @@ -165,139 +188,198 @@ func TestHMACMode_MissingHeader(t *testing.T) { } } -func newEdDSAFixture(t *testing.T) (priv ed25519.PrivateKey, jwks jwt.Keyfunc) { +func newRS256Fixture(t *testing.T) (priv *rsa.PrivateKey, jwks jwt.Keyfunc) { t.Helper() - pub, priv, err := ed25519.GenerateKey(rand.Reader) + priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("genkey: %v", err) } - return priv, fakeJWKS("prod-1", pub) + return priv, fakeJWKS("prod-1", &priv.PublicKey) } -func eddsaConfig(idURL string, jwks jwt.Keyfunc) Config { +func rs256Config(idURL string, jwks jwt.Keyfunc) Config { return Config{ - Mode: ModeEdDSA, - JWKS: jwks, - Issuer: "https://idp.fmsg.io", - IDURL: idURL, + Mode: ModeRS256, + JWKS: jwks, + Issuer: testIssuer, + Audience: testAudience, + AddressClaim: testAddressClaim, + IDURL: idURL, } } -func TestEdDSAMode_Happy(t *testing.T) { +func TestRS256Mode_Happy(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newEdDSAFixture(t) - mw, err := New(eddsaConfig(srv.URL, jwks)) + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) if err != nil { t.Fatalf("New: %v", err) } - tok := signEdDSA(t, priv, "prod-1", jwt.MapClaims{ - "iss": "https://idp.fmsg.io", - "sub": "@alice@example.com", - "iat": time.Now().Unix(), - "nbf": time.Now().Unix(), - "exp": time.Now().Add(time.Hour).Unix(), - "jti": "11111111-1111-1111-1111-111111111111", - }) + tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) if w := runMiddleware(t, mw, tok); w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } } -func TestEdDSAMode_WrongIssuer(t *testing.T) { +func TestRS256Mode_IdentityIsAddressClaim(t *testing.T) { + const addr = "@claim@example.com" + fmsgIDCache.Delete(addr) + defer fmsgIDCache.Delete(addr) + + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if r.URL.Path != "/fmsgid/"+addr { + http.Error(w, "wrong address", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"acceptingNew": true}) + })) + defer srv.Close() + priv, jwks := newRS256Fixture(t) + v, err := NewVerifier(rs256Config(srv.URL, jwks)) + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + + tok := signRS256(t, priv, "prod-1", rs256Claims(addr)) + gotAddr, status, _ := v.Authenticate(tok) + if status != http.StatusOK || gotAddr != addr { + t.Fatalf("got addr=%q status=%d, want %s/200", gotAddr, status, addr) + } + if hits != 1 { + t.Fatalf("fmsgid hits = %d, want 1", hits) + } +} + +func TestRS256Mode_MissingAddressClaim(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newEdDSAFixture(t) - mw, err := New(eddsaConfig(srv.URL, jwks)) + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) if err != nil { t.Fatal(err) } - tok := signEdDSA(t, priv, "prod-1", jwt.MapClaims{ - "iss": "https://evil.example.com", - "sub": "@alice@example.com", - "exp": time.Now().Add(time.Hour).Unix(), - "jti": "a", - }) + // A valid ID token whose identity has no fmsg account yet. + tok := signRS256(t, priv, "prod-1", rs256Claims("")) + if w := runMiddleware(t, mw, tok); w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestRS256Mode_MalformedAddressClaim(t *testing.T) { + srv := fmsgIDServer(t, http.StatusOK, true) + defer srv.Close() + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) + if err != nil { + t.Fatal(err) + } + tok := signRS256(t, priv, "prod-1", rs256Claims("not-an-address")) if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } -func TestEdDSAMode_UnknownKID(t *testing.T) { +func TestRS256Mode_WrongIssuer(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newEdDSAFixture(t) - mw, err := New(eddsaConfig(srv.URL, jwks)) + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) if err != nil { t.Fatal(err) } - tok := signEdDSA(t, priv, "rotated-key", jwt.MapClaims{ - "iss": "https://idp.fmsg.io", - "sub": "@alice@example.com", - "exp": time.Now().Add(time.Hour).Unix(), - "jti": "a", - }) + claims := rs256Claims("@alice@example.com") + claims["iss"] = "https://evil.example.com/" + tok := signRS256(t, priv, "prod-1", claims) if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } -func TestEdDSAMode_AlgDowngrade(t *testing.T) { +func TestRS256Mode_WrongAudience(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - _, jwks := newEdDSAFixture(t) - mw, err := New(eddsaConfig(srv.URL, jwks)) + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) if err != nil { t.Fatal(err) } - // Sign with HS256 — must be rejected by an EdDSA-only middleware. - tok := signHS256(t, []byte("anything"), jwt.MapClaims{ - "iss": "https://idp.fmsg.io", - "sub": "@alice@example.com", - "exp": time.Now().Add(time.Hour).Unix(), - "jti": "a", - }) + + // Token minted for a different configured application or API. + claims := rs256Claims("@alice@example.com") + claims["aud"] = "SomeOtherClientID" + tok := signRS256(t, priv, "prod-1", claims) + if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { + t.Fatalf("wrong aud: expected 401, got %d", w.Code) + } + + // Token with no audience at all. + claims = rs256Claims("@alice@example.com") + delete(claims, "aud") + tok = signRS256(t, priv, "prod-1", claims) + if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { + t.Fatalf("missing aud: expected 401, got %d", w.Code) + } +} + +func TestRS256Mode_UnknownKID(t *testing.T) { + srv := fmsgIDServer(t, http.StatusOK, true) + defer srv.Close() + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) + if err != nil { + t.Fatal(err) + } + tok := signRS256(t, priv, "rotated-key", rs256Claims("@alice@example.com")) if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } -func TestEdDSAMode_Expired(t *testing.T) { +func TestRS256Mode_AlgDowngrade(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newEdDSAFixture(t) - mw, err := New(eddsaConfig(srv.URL, jwks)) + _, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) if err != nil { t.Fatal(err) } - tok := signEdDSA(t, priv, "prod-1", jwt.MapClaims{ - "iss": "https://idp.fmsg.io", - "sub": "@alice@example.com", - "exp": time.Now().Add(-time.Hour).Unix(), - "jti": "a", - }) + // Sign with HS256 - must be rejected by an RS256-only middleware. + tok := signHS256(t, []byte("anything"), rs256Claims("@alice@example.com")) if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } -func TestEdDSAMode_Reuse(t *testing.T) { +func TestRS256Mode_Expired(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newEdDSAFixture(t) - mw, err := New(eddsaConfig(srv.URL, jwks)) + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) if err != nil { t.Fatal(err) } - claims := jwt.MapClaims{ - "iss": "https://idp.fmsg.io", - "sub": "@alice@example.com", - "iat": time.Now().Unix(), - "exp": time.Now().Add(time.Hour).Unix(), - "jti": "reuse-me", + claims := rs256Claims("@alice@example.com") + claims["exp"] = time.Now().Add(-time.Hour).Unix() + tok := signRS256(t, priv, "prod-1", claims) + if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestRS256Mode_Reuse(t *testing.T) { + srv := fmsgIDServer(t, http.StatusOK, true) + defer srv.Close() + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) + if err != nil { + t.Fatal(err) } - tok := signEdDSA(t, priv, "prod-1", claims) + tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) if w := runMiddleware(t, mw, tok); w.Code != http.StatusOK { t.Fatalf("first call expected 200, got %d", w.Code) @@ -307,6 +389,57 @@ func TestEdDSAMode_Reuse(t *testing.T) { } } +func TestRS256Mode_ConfigValidation(t *testing.T) { + _, jwks := newRS256Fixture(t) + + if _, err := NewVerifier(Config{Mode: ModeRS256, Issuer: testIssuer, Audience: testAudience, AddressClaim: testAddressClaim}); err == nil { + t.Error("missing JWKS: expected error") + } + if _, err := NewVerifier(Config{Mode: ModeRS256, JWKS: jwks, Audience: testAudience, AddressClaim: testAddressClaim}); err == nil { + t.Error("missing Issuer: expected error") + } + if _, err := NewVerifier(Config{Mode: ModeRS256, JWKS: jwks, Issuer: testIssuer, AddressClaim: testAddressClaim}); err == nil { + t.Error("missing Audience: expected error") + } + if _, err := NewVerifier(Config{Mode: ModeRS256, JWKS: jwks, Issuer: testIssuer, Audience: testAudience}); err == nil { + t.Error("missing AddressClaim: expected error") + } +} + +func TestRS256Mode_FmsgIDNotFound(t *testing.T) { + fmsgIDCache.Delete("@alice@example.com") + defer fmsgIDCache.Delete("@alice@example.com") + + srv := fmsgIDServer(t, http.StatusNotFound, false) + defer srv.Close() + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) + if err != nil { + t.Fatal(err) + } + tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) + if w := runMiddleware(t, mw, tok); w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestRS256Mode_FmsgIDNotAccepting(t *testing.T) { + fmsgIDCache.Delete("@alice@example.com") + defer fmsgIDCache.Delete("@alice@example.com") + + srv := fmsgIDServer(t, http.StatusOK, false) + defer srv.Close() + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) + if err != nil { + t.Fatal(err) + } + tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) + if w := runMiddleware(t, mw, tok); w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d body=%s", w.Code, w.Body.String()) + } +} + func TestVerifier_Authenticate(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() @@ -346,21 +479,16 @@ func TestVerifier_Authenticate(t *testing.T) { } } -func TestEdDSAMode_FmsgIDUnavailable(t *testing.T) { +func TestRS256Mode_FmsgIDUnavailable(t *testing.T) { fmsgIDCache.Delete("@alice@example.com") srv := fmsgIDServer(t, http.StatusInternalServerError, false) defer srv.Close() - priv, jwks := newEdDSAFixture(t) - mw, err := New(eddsaConfig(srv.URL, jwks)) + priv, jwks := newRS256Fixture(t) + mw, err := New(rs256Config(srv.URL, jwks)) if err != nil { t.Fatal(err) } - tok := signEdDSA(t, priv, "prod-1", jwt.MapClaims{ - "iss": "https://idp.fmsg.io", - "sub": "@alice@example.com", - "exp": time.Now().Add(time.Hour).Unix(), - "jti": "x", - }) + tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) if w := runMiddleware(t, mw, tok); w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) }