Files
auth-jwt/docs/adr/index.md
Rene Nochebuena a9c9f3434e feat(auth-jwt): initial implementation — JWT lifecycle and AuthMiddleware (v1.0.0)
Introduces code.nochebuena.dev/einherjar/auth-jwt — JWT authentication middleware
and token lifecycle management for the Einherjar framework. Absorbs httpauth-jwt
from micro-lib with three changes: logger parameter on AuthMiddleware, httputil.Error
for consistent 401 responses, and added ECDSA support.

Signers and Verifiers (one file per implementation — CT-6 compliant):
- Verifier interface — Verify(tokenString string) (*jwt.Token, error)
- Signer interface — extends Verifier; adds Sign(claims jwt.Claims) (string, error)
- signer_hmac.go — NewHMACSigner(secret) → HS256; jwt.WithJSONNumber() on Verify
- signer_rsa.go — NewRSASigner(key) + NewRSASignerFromPEM(pem) → RS256
- verifier_rsa.go — NewRSAPublicKeyVerifier + NewRSAPublicKeyVerifierFromPEM → RS256 verify-only
- signer_ec.go — NewECSigner(key) + NewECSignerFromPEM(pem) → ES256/384/512; algorithm
  auto-detected from key curve (P-256→ES256, P-384→ES384, P-521→ES512)
- verifier_ec.go — NewECPublicKeyVerifier + NewECPublicKeyVerifierFromPEM → EC verify-only

Token lifecycle:
- TokenConfig struct — AccessTTL, RefreshTTL, Issuer
- TokenPair struct — AccessToken, RefreshToken, ExpiresIn
- IssueTokenPair — access + refresh pair; customClaims merged at top level; refresh
  carries only sub/iss/iat/exp/jti/fam; jwt.WithJSONNumber() preserves int64 bitmasks
- Blacklist interface — IsRevoked + Revoke; satisfied by cache-valkey via duck typing
- ErrTokenRevoked — errors.New sentinel; errors.Is pattern for replay-attack detection
- RefreshTokenPair — verifies token, checks blacklist, revokes old JTI, issues new pair

HTTP middleware:
- AuthMiddleware(logger, verifier, publicPaths) — verifies Bearer tokens; calls
  authmw.SetTokenData on success; 401 routed through httputil.Error (Warn level);
  publicPaths use path.Match wildcards; accepts Verifier (not Signer) to enforce
  narrowest-interface principle for verify-only services

Compliance test (package authjwt_test) enforces CT-6 (≤1 exported TypeSpec/file),
compile-time interface satisfaction, and behavioural coverage: HMAC/RSA/EC sign+verify,
algorithm mismatch rejection, IssueTokenPair claims/jti/fam, RefreshTokenPair success/
revoked/blacklist-error/custom-claims, MaxInt64 json.Number precision, AuthMiddleware
valid/invalid/expired/missing/public-path/wildcard/JSON-body/RSA-verifier/SetTokenData.

Depends on auth v1.0.0, contracts v1.0.0, core v1.0.0, web v1.0.0, jwt/v5 v5.2.1.

- identifiable.go: package-level Module variable (observability.Identifiable) for version
  identification — auth-jwt is a function library; not registered with the launcher
2026-05-29 16:13:01 +00:00

3.5 KiB

Architecture Decision Records — einherjar/auth-jwt

ADR-001: No root factory

Status: Accepted

Context: The question arose whether auth-jwt should expose a root factory (e.g. authjwt.New(secret, cfg)) analogous to web.New.

Decision: No root factory. Callers construct Signer/Verifier directly and wire AuthMiddleware themselves.

Rationale: HMAC vs RSA vs EC is a fundamental architectural choice that cannot be encapsulated in a universal default. The TokenConfig, publicPaths, and the Blacklist for refresh must all be supplied by the application. A root factory would either accept all of these (producing a Config struct as complex as the raw API) or make fixed choices that are wrong for some callers.


ADR-002: Logger added to AuthMiddleware

Status: Accepted

Context: The micro-lib httpauth-jwt AuthMiddleware wrote 401 responses silently with no log emission.

Decision: AuthMiddleware accepts logging.Logger and routes all 401 responses through httputil.Error(logger, w, r, xerrors.Unauthorized(...)).

Rationale: Consistent with auth ADR-002. 401 responses are logged at Warn level by httputil.Error — token expiry, missing headers, and invalid signatures are all client-visible failures that benefit from operator visibility. Silent 401s are invisible in production; the cost of one Warn log per rejected request is negligible.


ADR-003: web dependency for httputil.Error

Status: Accepted

Context: auth-jwt error responses could be written with a local JSON helper to avoid a dependency on web.

Decision: auth-jwt depends on einherjar/web solely for httputil.Error.

Rationale: Consistent with auth ADR-003. Error response shape must be identical to all other API errors. auth-jwt is a transport-layer package; a web dependency is expected and correct. Duplicating the error writer in auth-jwt would create divergence over time.


ADR-004: ErrTokenRevoked as plain sentinel

Status: Accepted

Context: RefreshTokenPair could return a *xerrors.Err with code ErrUnauthenticated instead of a plain errors.New sentinel.

Decision: ErrTokenRevoked = errors.New("token revoked") — idiomatic Go sentinel.

Rationale: errors.Is(err, authjwt.ErrTokenRevoked) is how callers distinguish replay attacks from infrastructure failures. A plain sentinel keeps this check simple and allocation-free. Using xerrors.Unauthorized(...) returns a pointer value whose errors.Is semantics require an Is() method implementation. The sentinel pattern (as used for io.EOF, sql.ErrNoRows) is the established Go idiom for this case. The HTTP 401 response is the caller's responsibility, not RefreshTokenPair's.


ADR-005: EC algorithm auto-detected from key curve

Status: Accepted

Context: ECDSA supports three standardized curves (P-256, P-384, P-521) mapped to three JWT algorithms (ES256, ES384, ES512). The API could require callers to specify the algorithm explicitly.

Decision: NewECSigner detects the algorithm from key.Curve.Params().Name: P-256→ES256, P-384→ES384, P-521→ES512. Default (unknown curve) is ES256.

Rationale: The algorithm is determined by the key — there is no valid scenario where a P-256 key should sign with ES384. Requiring the caller to pass it explicitly would create a new class of misconfiguration errors. Auto-detection eliminates that class entirely. The same logic is applied in ecSigner.Verify to validate the incoming token's algorithm header.