16 KiB
Protocol Extensions Wishlist for Apophis
From: Arbiter Team (Multi-tenant identity platform with OAuth 2.1, WIMSE S2S, Transaction Tokens, SPIFFE/SPIRE) Date: 2026-04-25 Context: We maintain 58 protocol conformance test files covering OAuth 2.1, WIMSE S2S, Transaction Tokens (RFC 8693), SPIFFE/SPIRE, and related security specs. We are migrating these to Apophis behavioral contracts and have identified gaps between what our protocols require and what APOSTL currently supports.
1. Executive Summary
We have identified three categories of needs:
- Protocol-specific extensions (JWT, X.509, SPIFFE) — these are domain-specific predicates that don't belong in core APOSTL but are essential for security protocol testing
- Core infrastructure enhancements (time control, stateful predicates) — these would benefit all Apophis users, not just protocol testing
- Explicitly out of scope — things we acknowledge are too heavy or complex for Apophis (certificate chain validation, replay across restarts)
2. Protocol Extensions
2.1 JWT Extension
Use cases: OAuth 2.1, Transaction Tokens, WIMSE S2S, SPIFFE JWT-SVID
Proposed predicates:
# Access JWT claims
jwt_claims(this).sub # subject
jwt_claims(this).aud # audience
jwt_claims(this).iss # issuer
jwt_claims(this).exp # expiration
jwt_claims(this).iat # issued at
jwt_claims(this).jti # JWT ID (for replay detection)
jwt_claims(this).scope # scope
jwt_claims(this).cnf.jwk # confirmation key (WIMSE)
jwt_claims(this).txn # transaction token ID
# Access JWT header
jwt_header(this).alg # algorithm
jwt_header(this).kid # key ID
jwt_header(this).typ # type
# Validation
jwt_valid(this) # signature verifies against known key
jwt_format(this) == "compact" # compact vs JSON serialization
# Extensions would need:
# - Extract JWT from: Authorization header, response body, custom headers
# - Decode Base64URL without verification (for claim inspection)
# - Verify signature against configured JWKS or key material
Example contracts:
# OAuth 2.1: Token response contains required claims
if response_code(this) == 200 then jwt_claims(this).sub != null else T
if response_code(this) == 200 then jwt_claims(this).exp > jwt_claims(this).iat else T
# WIMSE: WPT expiration must be short-lived
if response_code(this) == 200 then jwt_claims(this).exp <= jwt_claims(this).iat + 30 else T
# Transaction Tokens: Token type must be transaction_token
if response_code(this) == 200 then jwt_claims(this).txn != null else T
Implementation notes:
- Needs
jwksorkeysoption in extension config for signature verification - Should support extracting JWT from multiple sources (header, body, query param)
- Extension state should track
seen_jtisfor replay detection within a test run
2.2 X.509 Extension
Use cases: SPIFFE X509-SVID, mTLS certificate validation
Proposed predicates:
# Certificate properties
x509_uri_sans(this) # array of URI subject alternative names
x509_uri_sans(this).length # count of URI SANs
x509_ca(this) # is CA certificate? (boolean)
x509_expired(this) # is expired? (boolean)
x509_not_before(this) # notBefore timestamp
x509_not_after(this) # notAfter timestamp
# Chain validation (lightweight)
x509_self_signed(this) # is self-signed?
x509_issuer(this) # issuer DN
x509_subject(this) # subject DN
Example contracts:
# SPIFFE: X509-SVID must have exactly 1 URI SAN
if response_code(this) == 200 then x509_uri_sans(this).length == 1 else T
# SPIFFE: X509-SVID leaf must not be CA
if response_code(this) == 200 then x509_ca(this) == false else T
# SPIFFE: Certificate must not be expired
if response_code(this) == 200 then x509_expired(this) == false else T
Explicitly NOT requested (too heavy for test extension):
x509_chain_valid(this)— full RFC 5280 path validation requires trust store, revocation checking, policy validation. This belongs in the application under test, not the test framework.
2.3 SPIFFE Extension
Use cases: SPIFFE ID validation, trust domain checks
Proposed predicates:
# SPIFFE ID parsing
spiffe_parse(this).trustDomain # trust domain string
spiffe_parse(this).path # path segments (array)
spiffe_parse(this).path.length # path depth
spiffe_validate(this) # boolean: valid SPIFFE ID?
# Properties
spiffe_id(this) # full SPIFFE ID string
spiffe_trust_domain(this) # alias for spiffe_parse(this).trustDomain
Example contracts:
# SPIFFE: Trust domain must be lowercase
if response_code(this) == 200 then spiffe_parse(this).trustDomain matches "^[a-z0-9.-]+$" else T
# SPIFFE: Path must not be empty
if response_code(this) == 200 then spiffe_parse(this).path.length > 0 else T
# SPIFFE: ID must be valid
if response_code(this) == 200 then spiffe_validate(this) == true else T
2.4 Token Hash Extension
Use cases: WIMSE S2S ath (access token hash), tth (transaction token hash), oth (other token hash)
Proposed predicates:
# Token hash validation
ath_valid(this) # access token hash matches Authorization header
tth_valid(this) # transaction token hash matches Txn-Token header
oth_valid(this, "header-name") # custom token hash matches named header
# Raw hash computation
token_hash(this, "sha256") # SHA-256 hash of token from context
Example contracts:
# WIMSE: If ath claim present, must match access token
if jwt_claims(this).ath != null then ath_valid(this) == true else T
# WIMSE: If tth claim present, must match transaction token
if jwt_claims(this).tth != null then tth_valid(this) == true else T
2.5 HTTP Signature Extension
Use cases: WIMSE S2S detached HTTP signatures
Proposed predicates:
# Signature components
signature_input(this) # Signature-Input header parsed
signature(this) # Signature header value
signature_valid(this) # signature verifies against key
# Coverage
signature_covers(this, "@method") # covers HTTP method
signature_covers(this, "@request-target") # covers request target
signature_covers(this, "authorization") # covers auth header
signature_covers(this, "txn-token") # covers txn-token header
Example contracts:
# WIMSE: Signature must cover @method and @request-target
if response_code(this) == 200 then signature_covers(this, "@method") == true else T
if response_code(this) == 200 then signature_covers(this, "@request-target") == true else T
3. Core Infrastructure Enhancements
3.1 Time Control
Problem: Many protocol behaviors depend on time:
- Token expiration (JWT
expclaim) - Refresh token rotation windows
- WIMSE WPT short TTL (≤30 seconds)
- Challenge TTLs
Current limitation: APOSTL has response_time(this) (wall clock duration) but no way to:
- Compare JWT timestamps to "now"
- Fast-forward time for expiration testing
- Test DST transitions, leap seconds, clock skew
Proposed solutions:
Option A: Server-level time mocking
await fastify.register(apophis, {
timeMock: true // enables apophis.time control
})
// In tests or stateful sequences:
await fastify.apophis.time.advance(30000) // +30 seconds
await fastify.apophis.time.set('2026-04-25T12:00:00Z')
Option B: Relative time predicates
# Compare JWT exp to current time (server time)
jwt_claims(this).exp > now()
jwt_claims(this).exp <= now() + 30
# Time since previous request
response_time(this) <= 5000 # already exists
elapsed_since_previous(this) <= 30 # new: seconds since last request in stateful test
Option C: Both
now()for read-only time comparison (safe, no side effects)apophis.time.advance()for stateful tests that need expiration (opt-in, explicit)
Use case — DST testing:
# Test that tokens issued before DST transition work after
if previous(jwt_claims(this).iat).hour == 1 then jwt_valid(this) == true else T
Priority: High. Without time control, we cannot test ~40% of our protocol behaviors.
3.2 Stateful Cross-Request Predicates
Problem: Protocols have multi-step flows where step N depends on step N-1:
- OAuth 2.1 refresh token rotation: First refresh succeeds and returns NEW token. Second refresh with OLD token fails.
- Transaction token single-use: First consumption succeeds. Second consumption with same token fails.
- WIMSE WPT replay: First verification succeeds. Second verification with same jti fails.
Current APOSTL limitation: previous() only compares values, not state transitions.
Proposed enhancement:
# Check if token was seen in previous requests
already_seen(this, jwt_claims(this).jti) == false
# Check if token was consumed
is_consumed(this, jwt_claims(this).jti) == false
# Reference specific previous request by category
previous(constructor).jwt_claims(this).refresh_token # last constructor's refresh token
Implementation approach:
- Extension state (already supported in v1.1) tracks
seenTokens: Set<string> - Provide built-in
already_seen()andis_consumed()predicates - Support referencing by category:
previous(constructor),previous(mutator),previous(observer)
Example contract:
# OAuth 2.1 refresh: new token must differ from old
if response_code(this) == 200 then
response_body(this).refresh_token != previous(request_body(this)).refresh_token
else T
# Transaction token: single use
if response_code(this) == 409 then
response_body(this).error == "transaction_token_replay_detected" &&
already_seen(this, jwt_claims(this).jti) == true
else T
Priority: High. Essential for refresh tokens, single-use tokens, and replay detection.
3.3 Request Context Predicates
Problem: Protocol behaviors depend on request properties that aren't in standard APOSTL:
# URL components
request_url(this) # full URL
request_url(this).path # path only
request_url(this).host # host header
# TLS info (when available)
request_tls(this).cipher # TLS cipher suite
request_tls(this).version # TLS version
request_tls(this).client_cert # client certificate (if mTLS)
# Body hash (for content integrity)
request_body_hash(this, "sha256") # SHA-256 of raw request body
Use case — WIMSE audience validation:
# WPT aud claim must match request URL
if response_code(this) == 200 then jwt_claims(this).aud == request_url(this) else T
Priority: Medium. request_url() is straightforward. TLS info is complex (may not be available in all environments).
3.4 Parallel Execution for Race Detection
Problem: Some protocol behaviors are inherently concurrent:
- Compare-and-swap keyset rotation (S2S-030)
- Token consumption races (two clients consume same single-use token simultaneously)
- Rate limiting under concurrent load
Current limitation: Apophis runs tests sequentially.
Proposed enhancement:
const results = await fastify.apophis.contract({
depth: 'standard',
concurrent: 4, // run 4 requests in parallel
raceMode: true // detect race conditions
})
Priority: Low. We can test these with separate load testing tools. Not essential for contract testing.
4. Explicitly Out of Scope
We acknowledge these are too complex or inappropriate for Apophis:
| Feature | Why Out of Scope |
|---|---|
| Replay detection across restarts | Requires persistent state (database/files). Test frameworks should be stateless. Application should handle this. |
| Full X.509 chain validation | Requires trust store, CRL/OCSP, policy validation. This is application logic, not test logic. |
| Cryptographic algorithm implementation | Apophis should not implement crypto. It should verify signatures using existing libraries. |
| Protocol state machines | OAuth flows (authorize → token → refresh) are too complex for declarative contracts. Use stateful testing or separate integration tests. |
| Network-level testing | TCP behavior, packet inspection, MTU issues. Out of scope for HTTP contract testing. |
5. Implementation Suggestions
5.1 Extension Architecture
Following the v1.1 extension architecture documented in EXTENSION-ARCHITECTURE.md:
// Extension registration
await fastify.register(apophis, {
extensions: [
jwtExtension({ jwks: 'https://auth.example.com/.well-known/jwks.json' }),
x509Extension(),
spiffeExtension(),
tokenHashExtension()
]
})
5.2 Configuration per Route
Some routes need different validation keys:
fastify.get('/wimse/wit', {
schema: {
'x-category': 'observer',
'x-extension-config': {
jwt: { verify: false, extractFrom: 'body' } // don't verify, just parse
},
'x-ensures': [
'jwt_claims(this).sub != null',
'jwt_claims(this).cnf.jwk != null'
]
}
})
fastify.post('/wimse/verify', {
schema: {
'x-extension-config': {
jwt: { verify: true, keySource: 'wit_cnfpubkey' },
tokenHash: { validate: ['ath', 'tth'] }
}
}
})
5.3 Test Data Seeding
For stateful tests that need pre-existing resources:
await fastify.apophis.seed([
{ method: 'POST', url: '/oauth/clients', body: { client_id: 'test-client' } },
{ method: 'POST', url: '/wimse/wit', body: { workload: 'frontend' } }
])
const results = await fastify.apophis.stateful({ depth: 'standard' })
6. Priority Matrix
| Feature | Impact | Effort | Priority |
|---|---|---|---|
| JWT extension (claims + validation) | Very High | Medium | P0 |
Time control (now(), advance()) |
Very High | Medium | P0 |
Stateful predicates (previous(), already_seen()) |
High | Medium | P1 |
| X.509 extension (basic properties) | High | Low | P1 |
| SPIFFE extension | Medium | Low | P2 |
| Token hash extension | Medium | Low | P2 |
| HTTP signature extension | Medium | Medium | P2 |
Request context (request_url()) |
Medium | Low | P2 |
| Parallel execution | Low | High | P3 |
7. Offer to Collaborate
We are happy to:
- Contribute extension implementations — We can build JWT, X.509, SPIFFE extensions and contribute them back
- Provide test cases — We have 58 conformance tests that can serve as real-world validation for extensions
- Beta test — We can test new features on our complex codebase before release
- Documentation — We can write docs and examples for protocol testing patterns
8. Appendix: Protocol Test Inventory
For reference, here's what we need to test:
| Protocol | Test File | Behaviors | Needs Extensions |
|---|---|---|---|
| OAuth 2.1 | oauth21-profile-conformance.test.js |
13 | JWT, time control |
| WIMSE S2S | draft-wimse-s2s-protocol-conformance.test.js |
31 | JWT, token hash, HTTP sig, X.509 |
| Transaction Tokens | draft-oauth-transaction-tokens-conformance.test.js |
25 | JWT, time control, stateful |
| SPIFFE/SPIRE | spiffe-spire-conformance.test.js |
24 | SPIFFE, X.509, JWT |
| Token Exchange | rfc8693-token-exchange-conformance.test.js |
15 | JWT |
| Device Auth | rfc8628-device-authorization-conformance.test.js |
12 | JWT |
| CIBA | ciba-conformance.test.js |
18 | JWT, time control |
Total: 138 protocol behaviors across 7 specifications.
Contact: We'd love to discuss this via GitHub issues, PRs, or video call. Our codebase is open for inspection at /home/johndvorak/Business/workspace/Arbiter.