Skip to content

Add in-memory authserver storage implementation#3298

Merged
jhrozek merged 2 commits into
mainfrom
auth-proxy-pr-8-in-memory-storage
Jan 16, 2026
Merged

Add in-memory authserver storage implementation#3298
jhrozek merged 2 commits into
mainfrom
auth-proxy-pr-8-in-memory-storage

Conversation

@jhrozek
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek commented Jan 14, 2026

This is a preparatory commit for the auth-proxy feature. The storage package provides the persistence layer for the OAuth 2.0 authorization server. In the complete branch, this storage is:

  • Passed to authserver.New() and used by the fosite OAuth2 provider for authorization codes, access tokens, refresh tokens, and PKCE
  • Used by handlers to validate clients and store pending authorizations while users authenticate with the upstream identity provider
  • Exposed via Server.IDPTokenStorage() for the token swap middleware, which replaces client JWTs with upstream IDP tokens for backend requests

This commit adds:

  • Storage interface embedding fosite's OAuth2 storage interfaces plus ToolHive-specific interfaces for upstream tokens, pending authorizations, and client registration
  • MemoryStorage implementation with thread-safe maps and background cleanup
  • Comprehensive documentation in doc.go explaining fosite's type system (Requester, Session, signature vs request ID) to help developers understand why the API is designed as it is

The in-memory implementation is suitable for single-instance deployments and development. Production deployments will use a distributed storage backend (Redis or database).

Large PR Justification

We need to implement all of the required fosite interfaces, doing it in multiple commits would just muddy the waters.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large PR Detected

This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.

How to unblock this PR:

Add a section to your PR description with the following format:

## Large PR Justification

[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformation

Alternative:

Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.

See our Contributing Guidelines for more details.


This review will be automatically dismissed once you add the justification section.

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Jan 14, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Jan 14, 2026

Codecov Report

❌ Patch coverage is 92.89216% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.44%. Comparing base (73d4f26) to head (391bb77).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
pkg/authserver/storage/memory.go 92.89% 23 Missing and 6 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3298      +/-   ##
==========================================
+ Coverage   64.18%   64.44%   +0.25%     
==========================================
  Files         365      366       +1     
  Lines       35244    35652     +408     
==========================================
+ Hits        22623    22975     +352     
- Misses      10810    10860      +50     
- Partials     1811     1817       +6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JAORMX
Copy link
Copy Markdown
Collaborator

JAORMX commented Jan 15, 2026

@claude please do a thorough security review of this, leverage agents if possible.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Jan 15, 2026

Claude finished @JAORMX's task in 5m 16s —— View job


Security Review Complete ✅

I've conducted a thorough security review of the OAuth 2.0 authorization server storage implementation, leveraging specialized agents for deep analysis.


Executive Summary

The implementation demonstrates strong foundational security with excellent RFC awareness and thoughtful design patterns. However, 4 critical security issues and 6 important recommendations were identified that should be addressed before production deployment.

Overall Assessment:

  • ✅ Well-documented with clear RFC references
  • ✅ Thread-safe with defensive copying
  • ✅ Excellent authorization code replay protection
  • ✅ JWT assertion replay protection implemented
  • ⚠️ Critical gaps in token revocation cascade and binding validation

Critical Security Issues (Require Immediate Action)

1. Incomplete Token Revocation Cascade (RFC 7009 Violation)

Severity: 🔴 CRITICAL
Location: pkg/authserver/storage/memory.go:542-554

Issue: RevokeRefreshToken does NOT revoke associated access tokens as required by RFC 7009 Section 2.1:

"If the particular token is a refresh token... the authorization server SHOULD also invalidate all access tokens based on the same authorization grant."

Current Code:

func (s *MemoryStorage) RevokeRefreshToken(_ context.Context, requestID string) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    // Only removes refresh tokens - missing access token cascade!
    for sig, entry := range s.refreshTokens {
        if entry.value.GetID() == requestID {
            delete(s.refreshTokens, sig)
        }
    }
    return nil
}

Impact: Access tokens remain valid after refresh token revocation, violating OAuth 2.0 security requirements.

Fix: Add access token deletion (like RotateRefreshToken does at lines 498-503):

// Also invalidate associated access tokens per RFC 7009 Section 2.1
for sig, entry := range s.accessTokens {
    if entry.value.GetID() == requestID {
        delete(s.accessTokens, sig)
    }
}

2. Missing Refresh Token Binding

Severity: 🔴 CRITICAL
Location: pkg/authserver/storage/memory.go:440

Issue: The accessSignature parameter in CreateRefreshTokenSession is ignored (line 440 shows _ string). Your own TODO comment acknowledges this at line 438:

// TODO: Store the accessSignature in a refreshToAccess map to enable direct lookup
// during token rotation instead of O(n) scan by request ID in RotateRefreshToken.

Impact:

  • RotateRefreshToken uses O(n) scan by request ID (lines 498-503)
  • Could invalidate wrong tokens if multiple tokens share the same request ID
  • No direct binding between refresh token and its access token

Fix: Implement the TODO:

type MemoryStorage struct {
    // ... existing fields ...
    refreshToAccess map[string]string  // refresh signature -> access signature
}

func (s *MemoryStorage) CreateRefreshTokenSession(
    _ context.Context, 
    signature string, 
    accessSignature string,  // Use this parameter!
    request fosite.Requester,
) error {
    // ... validation ...
    s.refreshToAccess[signature] = accessSignature
    // ... rest of implementation ...
}

3. No PKCE Method Validation (Security Best Practice Violation)

Severity: 🟠 HIGH
Location: pkg/authserver/storage/types.go:98

Issue: Storage accepts any PKCE method string without validation. RFC 7636 Section 4.4 states servers MUST NOT allow "plain" method with public clients, and OAuth 2.0 Security BCP deprecates the plain method entirely.

Current Code:

// PKCEMethod is the PKCE challenge method (must be "S256").
PKCEMethod string  // Comment says "must be S256" but no validation!

Impact: Defense-in-depth failure. If validation is missed in handlers, weak PKCE could be accepted.

Fix: Enforce S256 in StorePendingAuthorization:

if pending.PKCEMethod != "" && pending.PKCEMethod != "S256" {
    return fosite.ErrInvalidRequest.WithHint(
        "PKCE challenge method must be S256; plain method is not supported",
    )
}

4. Upstream Token Binding Not Implemented

Severity: 🟠 HIGH
Location: pkg/authserver/storage/memory.go:658-689

Issue: GetUpstreamTokens never validates the Subject and ClientID binding fields, despite:

  • The UpstreamTokens struct documenting these as "binding fields validated on lookup to prevent cross-session attacks" (lines 67-68, 72-73)
  • The interface defining ErrInvalidBinding error (line 45)
  • The interface docs promising binding validation (line 163)

Impact: Cross-session token theft is possible if session IDs are predictable or leaked.

Attack Scenario:

  1. Attacker discovers session ID of victim
  2. Calls GetUpstreamTokens(victim_session_id)
  3. No validation performed
  4. Attacker obtains victim's upstream IDP tokens

Fix: Either implement validation in GetUpstreamTokens, or add a validated variant and document caller responsibility:

// GetUpstreamTokensWithBinding retrieves upstream tokens and validates binding.
func (s *MemoryStorage) GetUpstreamTokensWithBinding(
    ctx context.Context,
    sessionID string,
    expectedSubject string,
    expectedClientID string,
) (*UpstreamTokens, error) {
    tokens, err := s.GetUpstreamTokens(ctx, sessionID)
    if err != nil {
        return nil, err
    }
    
    if tokens.Subject != expectedSubject {
        return nil, fmt.Errorf("%w: subject mismatch", ErrInvalidBinding)
    }
    if tokens.ClientID != expectedClientID {
        return nil, fmt.Errorf("%w: client ID mismatch", ErrInvalidBinding)
    }
    
    return tokens, nil
}

Important Security Recommendations

5. Client Registration Missing Duplicate Check

Severity: 🟡 MEDIUM
Location: pkg/authserver/storage/memory.go:249-256

Issue: RegisterClient silently overwrites existing clients, violating its interface contract that says "Returns ErrAlreadyExists if a client with the same ID already exists" (line 148 in types.go). RFC 7591 Section 3.2.1 requires refusing duplicate registrations.

Fix:

func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    if _, exists := s.clients[client.GetID()]; exists {
        return fmt.Errorf("%w: client %s already registered", 
            ErrAlreadyExists, client.GetID())
    }
    
    s.clients[client.GetID()] = client
    return nil
}

6. Missing Audit Logging

Severity: 🟡 MEDIUM

Issue: Limited logging for security-sensitive operations. Only debug-level logs for not-found cases.

Recommendation: Add structured audit logging for:

  • Authorization code invalidation
  • Token revocation
  • Client registration
  • Upstream token retrieval
  • Failed binding validations

7. No Memory Exhaustion Protection

Severity: 🟡 MEDIUM

Issue: No limits on storage size. An attacker could exhaust memory via:

  • Unlimited client registrations
  • Unlimited authorization requests
  • Unlimited pending authorizations

Recommendation: Add configurable limits:

type MemoryStorage struct {
    // ... existing fields ...
    maxClients               int
    maxActiveSessions        int
    maxPendingAuthorizations int
}

8. Session ID Entropy Not Validated

Severity: 🟡 MEDIUM

Issue: Storage accepts any string as session ID. If session IDs are predictable, Finding #4 becomes more severe.

Recommendation: Validate minimum entropy (e.g., 256 bits) for session IDs, or document caller responsibility.

9. Grace Period Not Implemented

Severity: 🔵 LOW
Location: pkg/authserver/storage/memory.go:558-561

Issue: RevokeRefreshTokenMaybeGracePeriod comment states "we don't support grace periods" but doesn't explain why.

Recommendation: Document the decision or implement grace period support.

10. Test Coverage Gaps

Issue: Missing tests for:

  • Token revocation cascade behavior
  • Upstream token binding validation
  • PKCE method enforcement
  • Client registration duplicate detection

Strengths Worth Highlighting

The implementation has several excellent security practices:

✅ Authorization Code Replay Protection

Lines 78-79, 356-374: Separate invalidatedCodes map with 30-minute retention window enables detecting replay attacks even after code expiration. This is excellent defense-in-depth.

✅ JWT Assertion Replay Protection

Lines 277-305: Proper JTI tracking per RFC 7523 with automatic cleanup. Well implemented.

✅ Thread Safety

Consistent use of sync.RWMutex with proper locking patterns throughout.

✅ Defensive Copying

Lines 637-648 (store), 681-688 (retrieve), 731 (slices): Prevents aliasing issues that could lead to data corruption.

✅ Comprehensive Documentation

The 192-line doc.go file excellently explains fosite's type system, storage architecture, and design decisions.

✅ Token Lifetime Policies

All token lifetimes are RFC-compliant and use secure defaults:

  • Authorization codes: 10 minutes (RFC 6749 max)
  • Access tokens: 1 hour
  • Refresh tokens: 30 days
  • Invalidated codes: 30 minutes (replay detection window)

RFC Compliance Summary

RFC Requirement Status
RFC 6749 (OAuth 2.0) Authorization code one-time use ✅ Compliant
RFC 6749 Authorization code expiration (≤10 min) ✅ Compliant
RFC 7009 (Token Revocation) Cascade revocation to access tokens Finding #1
RFC 7636 (PKCE) PKCE storage and expiration ✅ Compliant
RFC 7636 S256 enforcement ⚠️ Finding #3
RFC 7523 (JWT Assertions) JTI replay protection ✅ Compliant
RFC 7591 (Client Registration) Reject duplicates ⚠️ Finding #5

Priority Remediation Plan

Estimated total time: ~1.5 hours for critical fixes

  1. Fix RevokeRefreshToken cascade (15 min) - Finding fix(typo): corrects readme #1
  2. Implement refreshToAccess mapping (30 min) - Finding Do we want the container monitor? #2
  3. Add PKCE S256 enforcement (10 min) - Finding Bump golangci/golangci-lint-action from 2f856675483cb8b9378ee77ee0beb67955aca9d7 to 4696ba8babb6127d732c3c6dde519db15edab9ea #3
  4. Document/implement token binding validation (20 min) - Finding Implement secret store #4
  5. Add client registration duplicate check (5 min) - Finding Implement secret injection #5

Conclusion

This is a well-designed implementation with strong security foundations. The authorization code replay protection and JWT assertion tracking are exemplary. However, the 4 critical findings must be addressed before production deployment.

The implementation is suitable for development/testing in its current state, but requires remediation for production use.

Code Coverage Note

Codecov reports 92.76% coverage with 27 lines missing. The security-critical paths appear to be well-tested based on the comprehensive test suite in memory_test.go.


References:

  • RFC 6749: OAuth 2.0 Authorization Framework
  • RFC 7009: OAuth 2.0 Token Revocation
  • RFC 7636: Proof Key for Code Exchange (PKCE)
  • RFC 7523: JWT Profile for OAuth 2.0 Client Authentication
  • RFC 7591: OAuth 2.0 Dynamic Client Registration
  • OAuth 2.0 Security Best Current Practice

Comment thread pkg/authserver/storage/memory.go
Comment thread pkg/authserver/storage/memory.go Outdated
Comment thread pkg/authserver/storage/memory.go Outdated
This is a preparatory commit for the auth-proxy feature. The storage
package provides the persistence layer for the OAuth 2.0 authorization
server. In the complete branch, this storage is:

- Passed to authserver.New() and used by the fosite OAuth2 provider
  for authorization codes, access tokens, refresh tokens, and PKCE
- Used by handlers to validate clients and store pending authorizations
  while users authenticate with the upstream identity provider
- Exposed via Server.IDPTokenStorage() for the token swap middleware,
  which replaces client JWTs with upstream IDP tokens for backend requests

This commit adds:

- Storage interface embedding fosite's OAuth2 storage interfaces plus
  ToolHive-specific interfaces for upstream tokens, pending authorizations,
  and client registration
- MemoryStorage implementation with thread-safe maps and background cleanup
- Comprehensive documentation in doc.go explaining fosite's type system
  (Requester, Session, signature vs request ID) to help developers
  understand why the API is designed as it is

The in-memory implementation is suitable for single-instance deployments
and development. Production deployments will use a distributed storage
backend (Redis or database).
This commit addresses three review comments from the PR:

- Remove authorization code from debug log
- Remove internal state from debug logs

- Refactor cleanupExpired() to collect-then-delete pattern

The original implementation held a write lock while iterating all 8
storage maps, blocking all read and write operations during cleanup.

The new implementation:
- Acquires READ lock to collect expired keys into slices
- Releases read lock and returns early if nothing to delete
- Acquires WRITE lock only for the deletion phase

This reduces write lock hold time from O(total entries) to O(expired
entries), improving concurrency for read-heavy workloads.
@jhrozek jhrozek force-pushed the auth-proxy-pr-8-in-memory-storage branch from 4aaf665 to 391bb77 Compare January 15, 2026 15:40
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jan 15, 2026
@github-actions github-actions Bot dismissed their stale review January 15, 2026 15:40

Large PR justification has been provided. Thank you!

@github-actions
Copy link
Copy Markdown
Contributor

✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review.

@jhrozek jhrozek merged commit 224acb0 into main Jan 16, 2026
48 of 49 checks passed
@jhrozek jhrozek deleted the auth-proxy-pr-8-in-memory-storage branch January 16, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants