···8899dependencies:
1010 nixpkgs:
1111- - git
1111+ - gcc
1212 - go
13131414steps:
1515 - name: Run Tests
1616+ environment:
1717+ CGO_ENABLED: 1
1618 command: |
1719 go test -cover ./...
+5
cmd/appview/serve.go
···151151 // Create oauth token refresher
152152 refresher := oauth.NewRefresher(oauthApp)
153153154154+ // Wire up UI session store to refresher so it can invalidate UI sessions on OAuth failures
155155+ if uiSessionStore != nil {
156156+ refresher.SetUISessionStore(uiSessionStore)
157157+ }
158158+154159 // Set global refresher for middleware
155160 middleware.SetGlobalRefresher(refresher)
156161
···4141 config.Middleware = buildMiddlewareConfig(defaultHoldDID)
42424343 // Auth
4444- baseURL := getBaseURL(httpConfig.Addr)
4444+ baseURL := GetBaseURL(httpConfig.Addr)
4545 authConfig, err := buildAuthConfig(baseURL)
4646 if err != nil {
4747 return nil, fmt.Errorf("failed to build auth config: %w", err)
···198198199199 // Full address provided
200200 return fmt.Sprintf("http://%s", httpAddr)
201201-}
202202-203203-// getBaseURL is the internal version used by buildAuthConfig
204204-func getBaseURL(httpAddr string) string {
205205- return GetBaseURL(httpAddr)
206201}
207202208203// getServiceName extracts service name from base URL or uses env var
+18
pkg/appview/db/session_store.go
···128128 }
129129}
130130131131+// DeleteByDID removes all sessions for a given DID
132132+// This is useful when OAuth refresh fails and we need to force re-authentication
133133+func (s *SessionStore) DeleteByDID(did string) {
134134+ result, err := s.db.Exec(`
135135+ DELETE FROM ui_sessions WHERE did = ?
136136+ `, did)
137137+138138+ if err != nil {
139139+ fmt.Printf("Warning: Failed to delete sessions for DID %s: %v\n", did, err)
140140+ return
141141+ }
142142+143143+ deleted, _ := result.RowsAffected()
144144+ if deleted > 0 {
145145+ fmt.Printf("Deleted %d UI session(s) for DID %s due to OAuth failure\n", deleted, did)
146146+ }
147147+}
148148+131149// Cleanup removes expired sessions
132150func (s *SessionStore) Cleanup() {
133151 result, err := s.db.Exec(`
+108-2
pkg/appview/middleware/registry.go
···44 "context"
55 "encoding/json"
66 "fmt"
77+ "io"
88+ "net/http"
99+ "net/url"
710 "strings"
811 "sync"
1212+ "time"
9131014 "github.com/bluesky-social/indigo/atproto/identity"
1115 "github.com/bluesky-social/indigo/atproto/syntax"
1216 "github.com/distribution/distribution/v3"
1717+ "github.com/distribution/distribution/v3/registry/api/errcode"
1318 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
1419 "github.com/distribution/distribution/v3/registry/storage/driver"
1520 "github.com/distribution/reference"
···1823 "atcr.io/pkg/atproto"
1924 "atcr.io/pkg/auth"
2025 "atcr.io/pkg/auth/oauth"
2626+ "atcr.io/pkg/auth/token"
2127)
22282329// Global variables for initialization only
···140146 }
141147 ctx = context.WithValue(ctx, "hold.did", holdDID)
142148149149+ // Get service token for hold authentication
150150+ // Check cache first to avoid unnecessary PDS calls on every request
151151+ var serviceToken string
152152+ if nr.refresher != nil {
153153+ cachedToken, expiresAt := token.GetServiceToken(did, holdDID)
154154+155155+ // Use cached token if it exists and has > 10s remaining
156156+ if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
157157+ fmt.Printf("DEBUG [registry/middleware]: Using cached service token for DID=%s (expires in %v)\n",
158158+ did, time.Until(expiresAt).Round(time.Second))
159159+ serviceToken = cachedToken
160160+ } else {
161161+ // Cache miss or expiring soon - validate OAuth and get new service token
162162+ if cachedToken == "" {
163163+ fmt.Printf("DEBUG [registry/middleware]: Cache miss, fetching service token for DID=%s\n", did)
164164+ } else {
165165+ fmt.Printf("DEBUG [registry/middleware]: Token expiring soon, proactively renewing for DID=%s\n", did)
166166+ }
167167+168168+ session, err := nr.refresher.GetSession(ctx, did)
169169+ if err != nil {
170170+ // OAuth session unavailable - fail fast with proper auth error
171171+ nr.refresher.InvalidateSession(did)
172172+ token.InvalidateServiceToken(did, holdDID)
173173+ fmt.Printf("ERROR [registry/middleware]: Failed to get OAuth session for DID=%s: %v\n", did, err)
174174+ fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
175175+ return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
176176+ }
177177+178178+ // Call com.atproto.server.getServiceAuth on the user's PDS
179179+ // Request 5-minute expiry (PDS may grant less)
180180+ // exp must be absolute Unix timestamp, not relative duration
181181+ expiryTime := time.Now().Unix() + 300 // 5 minutes from now
182182+ serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
183183+ pdsEndpoint,
184184+ atproto.ServerGetServiceAuth,
185185+ url.QueryEscape(holdDID),
186186+ url.QueryEscape("com.atproto.repo.getRecord"),
187187+ expiryTime,
188188+ )
189189+190190+ req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
191191+ if err != nil {
192192+ fmt.Printf("ERROR [registry/middleware]: Failed to create service auth request: %v\n", err)
193193+ return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
194194+ }
195195+196196+ // Use OAuth session to authenticate to PDS (with DPoP)
197197+ resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
198198+ if err != nil {
199199+ // Invalidate session on auth errors (may indicate corrupted session or expired tokens)
200200+ nr.refresher.InvalidateSession(did)
201201+ token.InvalidateServiceToken(did, holdDID)
202202+ fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: %v\n", did, err)
203203+ fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
204204+ return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
205205+ }
206206+ defer resp.Body.Close()
207207+208208+ if resp.StatusCode != http.StatusOK {
209209+ // Invalidate session on auth failures
210210+ bodyBytes, _ := io.ReadAll(resp.Body)
211211+ nr.refresher.InvalidateSession(did)
212212+ token.InvalidateServiceToken(did, holdDID)
213213+ fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: status %d, body: %s\n",
214214+ did, resp.StatusCode, string(bodyBytes))
215215+ fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
216216+ return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
217217+ }
218218+219219+ // Parse response to get service token
220220+ var result struct {
221221+ Token string `json:"token"`
222222+ }
223223+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
224224+ fmt.Printf("ERROR [registry/middleware]: Failed to decode service auth response: %v\n", err)
225225+ return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
226226+ }
227227+228228+ if result.Token == "" {
229229+ fmt.Printf("ERROR [registry/middleware]: Empty token in service auth response\n")
230230+ return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
231231+ }
232232+233233+ serviceToken = result.Token
234234+235235+ // Cache the token (parses JWT to extract actual expiry)
236236+ if err := token.SetServiceToken(did, holdDID, serviceToken); err != nil {
237237+ fmt.Printf("WARN [registry/middleware]: Failed to cache service token: %v\n", err)
238238+ // Non-fatal - we have the token, just won't be cached
239239+ }
240240+241241+ fmt.Printf("DEBUG [registry/middleware]: OAuth validation succeeded for DID=%s\n", did)
242242+ }
243243+ }
244244+143245 // Create a new reference with identity/image format
144246 // Use the identity (or DID) as the namespace to ensure canonical format
145247 // This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path)
···192294 // Cache key is DID + repository name
193295 cacheKey := did + ":" + repositoryName
194296195195- // Check cache first
297297+ // Check cache first and update service token
196298 if cached, ok := nr.repositories.Load(cacheKey); ok {
197197- return cached.(*storage.RoutingRepository), nil
299299+ cachedRepo := cached.(*storage.RoutingRepository)
300300+ // Always update the service token even for cached repos (token may have been renewed)
301301+ cachedRepo.Ctx.ServiceToken = serviceToken
302302+ return cachedRepo, nil
198303 }
199304200305 // Create routing repository - routes manifests to ATProto, blobs to hold service
···205310 HoldDID: holdDID,
206311 PDSEndpoint: pdsEndpoint,
207312 Repository: repositoryName,
313313+ ServiceToken: serviceToken, // Cached service token from middleware validation
208314 ATProtoClient: atprotoClient,
209315 Database: nr.database,
210316 Authorizer: nr.authorizer,
+1
pkg/appview/storage/context.go
···2020 HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io")
2121 PDSEndpoint string // User's PDS endpoint URL
2222 Repository string // Image repository name (e.g., "debian")
2323+ ServiceToken string // Service token for hold authentication (cached by middleware)
2324 ATProtoClient *atproto.Client // Authenticated ATProto client for this user
24252526 // Shared services (same for all requests)
+17-106
pkg/appview/storage/proxy_blob_store.go
···77 "fmt"
88 "io"
99 "net/http"
1010- "net/url"
1110 "sync"
1211 "time"
13121413 "atcr.io/pkg/appview"
1514 "atcr.io/pkg/atproto"
1615 "github.com/distribution/distribution/v3"
1616+ "github.com/distribution/distribution/v3/registry/api/errcode"
1717 "github.com/opencontainers/go-digest"
1818)
1919···3030 globalUploadsMu sync.RWMutex
3131)
32323333-// Service token cache entry
3434-type serviceTokenEntry struct {
3535- token string
3636- expiresAt time.Time
3737-}
3838-3939-// Global service token cache (shared across all ProxyBlobStore instances)
4040-// Cache key: "userDID:holdDID"
4141-// Tokens are valid for 60 seconds from PDS, we cache for 50 seconds to be safe
4242-var (
4343- globalServiceTokens = make(map[string]*serviceTokenEntry)
4444- globalServiceTokensMu sync.RWMutex
4545-)
4646-4733// ProxyBlobStore proxies blob requests to an external storage service
4834type ProxyBlobStore struct {
4935 ctx *RegistryContext // All context and services
···7561 }
7662}
77637878-// getServiceToken gets a service token for the hold service from the user's PDS
7979-// Uses com.atproto.server." endpoint
8080-// Tokens are cached for 50 seconds (they're valid for 60 seconds from PDS)
8181-func (p *ProxyBlobStore) getServiceToken(ctx context.Context) (string, error) {
8282- // Check cache first
8383- cacheKey := p.ctx.DID + ":" + p.ctx.HoldDID
8484- globalServiceTokensMu.RLock()
8585- entry, exists := globalServiceTokens[cacheKey]
8686- globalServiceTokensMu.RUnlock()
8787-8888- if exists && time.Now().Before(entry.expiresAt) {
8989- fmt.Printf("DEBUG [proxy_blob_store]: Using cached service token for %s\n", cacheKey)
9090- return entry.token, nil
9191- }
9292-9393- // No valid cached token, request a new one from PDS
9494- if p.ctx.Refresher == nil {
9595- return "", fmt.Errorf("no OAuth refresher available for service token request")
9696- }
9797-9898- session, err := p.ctx.Refresher.GetSession(ctx, p.ctx.DID)
9999- if err != nil {
100100- return "", fmt.Errorf("failed to get OAuth session: %w", err)
101101- }
102102-103103- // Call com.atproto.server.getServiceAuth on the user's PDS
104104- // Include lxm (lexicon scope) and exp (expiration) parameters
105105- pdsURL := p.ctx.PDSEndpoint
106106- serviceAuthURL := fmt.Sprintf("%s/xrpc/com.atproto.server.getServiceAuth?aud=%s&lxm=%s",
107107- pdsURL,
108108- url.QueryEscape(p.ctx.HoldDID),
109109- url.QueryEscape("com.atproto.repo.getRecord"),
110110- )
111111-112112- req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
113113- if err != nil {
114114- return "", fmt.Errorf("failed to create service auth request: %w", err)
115115- }
116116-117117- // Use OAuth session to authenticate to PDS (with DPoP)
118118- resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
119119- if err != nil {
120120- return "", fmt.Errorf("failed to call getServiceAuth: %w", err)
121121- }
122122- defer resp.Body.Close()
123123-124124- if resp.StatusCode != http.StatusOK {
125125- bodyBytes, _ := io.ReadAll(resp.Body)
126126- return "", fmt.Errorf("getServiceAuth failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
127127- }
128128-129129- // Parse response
130130- var result struct {
131131- Token string `json:"token"`
132132- }
133133- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
134134- return "", fmt.Errorf("failed to decode service auth response: %w", err)
135135- }
136136-137137- if result.Token == "" {
138138- return "", fmt.Errorf("empty token in service auth response")
139139- }
140140-141141- fmt.Printf("DEBUG [proxy_blob_store]: Got new service token for %s (length=%d)\n", cacheKey, len(result.Token))
142142-143143- // Cache the token (expires in 50 seconds)
144144- globalServiceTokensMu.Lock()
145145- globalServiceTokens[cacheKey] = &serviceTokenEntry{
146146- token: result.Token,
147147- expiresAt: time.Now().Add(50 * time.Second),
148148- }
149149- globalServiceTokensMu.Unlock()
150150-151151- return result.Token, nil
152152-}
153153-15464// doAuthenticatedRequest performs an HTTP request with service token authentication
155155-// Gets a service token from the user's PDS and uses it to authenticate to the hold service
6565+// Uses the service token from middleware to authenticate requests to the hold service
15666func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
157157- // Get service token for the hold service
158158- serviceToken, err := p.getServiceToken(ctx)
159159- if err != nil {
160160- fmt.Printf("DEBUG [proxy_blob_store]: Failed to get service token for DID=%s: %v, will attempt without auth\n", p.ctx.DID, err)
161161- // Fall back to non-authenticated request
162162- return p.httpClient.Do(req)
6767+ // Use service token that middleware already validated and cached
6868+ // Middleware fails fast with HTTP 401 if OAuth session is invalid
6969+ if p.ctx.ServiceToken == "" {
7070+ // Should never happen - middleware validates OAuth before handlers run
7171+ fmt.Printf("ERROR [proxy_blob_store]: No service token in context for DID=%s\n", p.ctx.DID)
7272+ return nil, fmt.Errorf("no service token available (middleware should have validated)")
16373 }
1647416575 // Add Bearer token to Authorization header
166166- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", serviceToken))
167167- fmt.Printf("DEBUG [proxy_blob_store]: Using service token for hold service request, DID=%s\n", p.ctx.DID)
7676+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken))
1687716978 return p.httpClient.Do(req)
17079}
···408317 // Start multipart upload via hold service
409318 uploadID, err := p.startMultipartUpload(ctx, tempDigest)
410319 if err != nil {
411411- return nil, fmt.Errorf("failed to start multipart upload: %w", err)
320320+ return nil, err
412321 }
413413-414414- fmt.Printf(" Started multipart upload: uploadID=%s\n", uploadID)
415322416323 writer := &ProxyBlobWriter{
417324 store: p,
···452359 // Use XRPC endpoint: /xrpc/com.atproto.sync.getBlob?did={userDID}&cid={digest}
453360 // The 'did' parameter is the USER's DID (whose blob we're fetching), not the hold service DID
454361 // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
455455- xrpcURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s&method=%s",
456456- p.holdURL, p.ctx.DID, dgst.String(), operation)
362362+ xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s",
363363+ p.holdURL, atproto.SyncGetBlob, p.ctx.DID, dgst.String(), operation)
457364458365 req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
459366 if err != nil {
···462369463370 resp, err := p.doAuthenticatedRequest(ctx, req)
464371 if err != nil {
465465- return "", fmt.Errorf("failed to call hold service: %w", err)
372372+ // Don't wrap errcode errors - return them directly
373373+ if _, ok := err.(errcode.Error); ok {
374374+ return "", err
375375+ }
376376+ return "", fmt.Errorf("failed to get presigned URL: %w", err)
466377 }
467378 defer resp.Body.Close()
468379
+124-178
pkg/appview/storage/proxy_blob_store_test.go
···2233import (
44 "context"
55+ "encoding/base64"
56 "encoding/json"
77+ "fmt"
68 "net/http"
79 "net/http/httptest"
810 "strings"
···1012 "time"
11131214 "atcr.io/pkg/atproto"
1515+ "atcr.io/pkg/auth/token"
1316 "github.com/opencontainers/go-digest"
1417)
15181619// TestGetServiceToken_CachingLogic tests the token caching mechanism
1720func TestGetServiceToken_CachingLogic(t *testing.T) {
1818- // Clear cache before test
1919- globalServiceTokensMu.Lock()
2020- globalServiceTokens = make(map[string]*serviceTokenEntry)
2121- globalServiceTokensMu.Unlock()
2121+ userDID := "did:plc:test"
2222+ holdDID := "did:web:hold.example.com"
22232323- // Test 1: Empty cache
2424- cacheKey := "did:plc:test:did:web:hold.example.com"
2525- globalServiceTokensMu.RLock()
2626- _, exists := globalServiceTokens[cacheKey]
2727- globalServiceTokensMu.RUnlock()
2828-2929- if exists {
2424+ // Test 1: Empty cache - invalidate any existing token
2525+ token.InvalidateServiceToken(userDID, holdDID)
2626+ cachedToken, _ := token.GetServiceToken(userDID, holdDID)
2727+ if cachedToken != "" {
3028 t.Error("Expected empty cache at start")
3129 }
32303331 // Test 2: Insert token into cache
3434- testToken := "test-token-12345"
3535- expiresAt := time.Now().Add(50 * time.Second)
3232+ // Create a JWT-like token with exp claim for testing
3333+ // Format: header.payload.signature where payload has exp claim
3434+ testPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(50*time.Second).Unix())
3535+ testToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(testPayload) + ".signature"
36363737- globalServiceTokensMu.Lock()
3838- globalServiceTokens[cacheKey] = &serviceTokenEntry{
3939- token: testToken,
4040- expiresAt: expiresAt,
3737+ err := token.SetServiceToken(userDID, holdDID, testToken)
3838+ if err != nil {
3939+ t.Fatalf("Failed to set service token: %v", err)
4140 }
4242- globalServiceTokensMu.Unlock()
43414442 // Test 3: Retrieve from cache
4545- globalServiceTokensMu.RLock()
4646- entry, exists := globalServiceTokens[cacheKey]
4747- globalServiceTokensMu.RUnlock()
4848-4949- if !exists {
4343+ cachedToken, expiresAt := token.GetServiceToken(userDID, holdDID)
4444+ if cachedToken == "" {
5045 t.Fatal("Expected token to be in cache")
5146 }
52475353- if entry.token != testToken {
5454- t.Errorf("Expected token %s, got %s", testToken, entry.token)
4848+ if cachedToken != testToken {
4949+ t.Errorf("Expected token %s, got %s", testToken, cachedToken)
5550 }
56515757- if time.Now().After(entry.expiresAt) {
5252+ if time.Now().After(expiresAt) {
5853 t.Error("Expected token to not be expired")
5954 }
60556161- // Test 4: Expired token
6262- globalServiceTokensMu.Lock()
6363- globalServiceTokens[cacheKey] = &serviceTokenEntry{
6464- token: "expired-token",
6565- expiresAt: time.Now().Add(-1 * time.Hour),
6666- }
6767- globalServiceTokensMu.Unlock()
6868-6969- globalServiceTokensMu.RLock()
7070- expiredEntry := globalServiceTokens[cacheKey]
7171- globalServiceTokensMu.RUnlock()
5656+ // Test 4: Expired token - GetServiceToken automatically removes it
5757+ expiredPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(-1*time.Hour).Unix())
5858+ expiredToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(expiredPayload) + ".signature"
5959+ token.SetServiceToken(userDID, holdDID, expiredToken)
72607373- if !time.Now().After(expiredEntry.expiresAt) {
7474- t.Error("Expected token to be expired")
6161+ // GetServiceToken should return empty string for expired token
6262+ cachedToken, _ = token.GetServiceToken(userDID, holdDID)
6363+ if cachedToken != "" {
6464+ t.Error("Expected expired token to be removed from cache")
7565 }
7666}
77677878-// TestGetServiceToken_NoRefresher tests that getServiceToken returns error when refresher is nil
7979-func TestGetServiceToken_NoRefresher(t *testing.T) {
6868+// base64URLEncode helper for creating test JWT tokens
6969+func base64URLEncode(data string) string {
7070+ return strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(data)), "=")
7171+}
7272+7373+// TestServiceToken_EmptyInContext tests that operations fail when service token is missing
7474+func TestServiceToken_EmptyInContext(t *testing.T) {
8075 ctx := &RegistryContext{
8181- DID: "did:plc:test",
8282- HoldDID: "did:web:hold.example.com",
8383- PDSEndpoint: "https://pds.example.com",
8484- Repository: "test-repo",
8585- Refresher: nil, // No refresher
7676+ DID: "did:plc:test",
7777+ HoldDID: "did:web:hold.example.com",
7878+ PDSEndpoint: "https://pds.example.com",
7979+ Repository: "test-repo",
8080+ ServiceToken: "", // No service token (middleware didn't set it)
8181+ Refresher: nil,
8682 }
87838884 store := NewProxyBlobStore(ctx)
89859090- // Clear cache to force token fetch attempt
9191- globalServiceTokensMu.Lock()
9292- delete(globalServiceTokens, "did:plc:test:did:web:hold.example.com")
9393- globalServiceTokensMu.Unlock()
8686+ // Try a write operation that requires authentication
8787+ testDigest := digest.FromString("test-content")
8888+ _, err := store.Stat(context.Background(), testDigest)
94899595- _, err := store.getServiceToken(context.Background())
9090+ // Should fail because no service token is available
9691 if err == nil {
9797- t.Error("Expected error when refresher is nil")
9292+ t.Error("Expected error when service token is empty")
9893 }
9994100100- if !strings.Contains(err.Error(), "no OAuth refresher") {
101101- t.Errorf("Expected error about no OAuth refresher, got: %v", err)
9595+ // Error should indicate authentication issue
9696+ if !strings.Contains(err.Error(), "UNAUTHORIZED") && !strings.Contains(err.Error(), "authentication") {
9797+ t.Logf("Got error (acceptable): %v", err)
10298 }
10399}
104100105101// TestDoAuthenticatedRequest_BearerTokenInjection tests that Bearer tokens are added to requests
106102func TestDoAuthenticatedRequest_BearerTokenInjection(t *testing.T) {
107107- // This test verifies the Bearer token injection logic when a token is cached
103103+ // This test verifies the Bearer token injection logic
108104109109- // Setup: Create a cached token
110110- testToken := "cached-bearer-token-xyz"
111111- cacheKey := "did:plc:bearer-test:did:web:hold.example.com"
112112-113113- globalServiceTokensMu.Lock()
114114- globalServiceTokens[cacheKey] = &serviceTokenEntry{
115115- token: testToken,
116116- expiresAt: time.Now().Add(50 * time.Second),
117117- }
118118- globalServiceTokensMu.Unlock()
105105+ testToken := "test-bearer-token-xyz"
119106120107 // Create a test server to verify the Authorization header
121108 var receivedAuthHeader string
···125112 }))
126113 defer testServer.Close()
127114128128- // Create ProxyBlobStore with cached token
115115+ // Create ProxyBlobStore with service token in context (set by middleware)
129116 ctx := &RegistryContext{
130130- DID: "did:plc:bearer-test",
131131- HoldDID: "did:web:hold.example.com",
132132- PDSEndpoint: "https://pds.example.com",
133133- Repository: "test-repo",
134134- Refresher: nil, // Will use cached token, so refresher not needed
117117+ DID: "did:plc:bearer-test",
118118+ HoldDID: "did:web:hold.example.com",
119119+ PDSEndpoint: "https://pds.example.com",
120120+ Repository: "test-repo",
121121+ ServiceToken: testToken, // Service token from middleware
122122+ Refresher: nil,
135123 }
136124137125 store := NewProxyBlobStore(ctx)
···156144 }
157145}
158146159159-// TestDoAuthenticatedRequest_FallbackWhenTokenUnavailable tests fallback to non-auth
160160-func TestDoAuthenticatedRequest_FallbackWhenTokenUnavailable(t *testing.T) {
161161- // Clear cache
162162- cacheKey := "did:plc:fallback:did:web:hold.example.com"
163163- globalServiceTokensMu.Lock()
164164- delete(globalServiceTokens, cacheKey)
165165- globalServiceTokensMu.Unlock()
166166-167167- // Create test server
147147+// TestDoAuthenticatedRequest_ErrorWhenTokenUnavailable tests that authentication failures return proper errors
148148+func TestDoAuthenticatedRequest_ErrorWhenTokenUnavailable(t *testing.T) {
149149+ // Create test server (should not be called since auth fails first)
168150 called := false
169151 testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
170152 called = true
···172154 }))
173155 defer testServer.Close()
174156175175- // Create ProxyBlobStore without refresher (will fail to get token and fall back)
157157+ // Create ProxyBlobStore without service token (middleware didn't set it)
176158 ctx := &RegistryContext{
177177- DID: "did:plc:fallback",
178178- HoldDID: "did:web:hold.example.com",
179179- PDSEndpoint: "https://pds.example.com",
180180- Repository: "test-repo",
181181- Refresher: nil, // No refresher = can't get token
159159+ DID: "did:plc:fallback",
160160+ HoldDID: "did:web:hold.example.com",
161161+ PDSEndpoint: "https://pds.example.com",
162162+ Repository: "test-repo",
163163+ ServiceToken: "", // No service token
164164+ Refresher: nil,
182165 }
183166184167 store := NewProxyBlobStore(ctx)
···189172 t.Fatalf("Failed to create request: %v", err)
190173 }
191174192192- // Do authenticated request - should fall back to non-auth
175175+ // Do authenticated request - should fail when no service token
193176 resp, err := store.doAuthenticatedRequest(context.Background(), req)
194194- if err != nil {
195195- t.Fatalf("doAuthenticatedRequest should not fail even without token: %v", err)
177177+ if err == nil {
178178+ t.Fatal("Expected doAuthenticatedRequest to fail when no service token is available")
196179 }
197197- defer resp.Body.Close()
180180+ if resp != nil {
181181+ resp.Body.Close()
182182+ }
198183199199- if !called {
200200- t.Error("Expected request to be made despite missing token")
184184+ // Verify error indicates authentication/authorization issue
185185+ errStr := err.Error()
186186+ if !strings.Contains(errStr, "service token") && !strings.Contains(errStr, "UNAUTHORIZED") {
187187+ t.Errorf("Expected service token or unauthorized error, got: %v", err)
201188 }
202189203203- if resp.StatusCode != http.StatusOK {
204204- t.Errorf("Expected status 200, got %d", resp.StatusCode)
190190+ if called {
191191+ t.Error("Expected request to NOT be made when authentication fails")
205192 }
206193}
207194···241228242229// TestServiceTokenCacheExpiry tests that expired cached tokens are not used
243230func TestServiceTokenCacheExpiry(t *testing.T) {
244244- cacheKey := "did:plc:expiry:did:web:hold.example.com"
231231+ userDID := "did:plc:expiry"
232232+ holdDID := "did:web:hold.example.com"
245233246234 // Insert expired token
247247- globalServiceTokensMu.Lock()
248248- globalServiceTokens[cacheKey] = &serviceTokenEntry{
249249- token: "expired-token",
250250- expiresAt: time.Now().Add(-1 * time.Hour), // Expired 1 hour ago
251251- }
252252- globalServiceTokensMu.Unlock()
253253-254254- // Check that it's expired
255255- globalServiceTokensMu.RLock()
256256- entry := globalServiceTokens[cacheKey]
257257- globalServiceTokensMu.RUnlock()
235235+ expiredPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(-1*time.Hour).Unix())
236236+ expiredToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(expiredPayload) + ".signature"
237237+ token.SetServiceToken(userDID, holdDID, expiredToken)
258238259259- if entry == nil {
260260- t.Fatal("Expected token entry to exist")
261261- }
239239+ // GetServiceToken should automatically remove expired tokens
240240+ cachedToken, expiresAt := token.GetServiceToken(userDID, holdDID)
262241263263- if !time.Now().After(entry.expiresAt) {
264264- t.Error("Expected token to be expired")
242242+ // Should return empty string for expired token
243243+ if cachedToken != "" {
244244+ t.Error("Expected GetServiceToken to return empty string for expired token")
265245 }
266246267267- // The getServiceToken function would check time.Now().Before(entry.expiresAt)
268268- // and this would return false for an expired token, causing it to fetch a new one
269269- shouldUseCache := time.Now().Before(entry.expiresAt)
270270- if shouldUseCache {
271271- t.Error("Expected expired token to not be used from cache")
247247+ // expiresAt should be zero time for expired/missing tokens
248248+ if !expiresAt.IsZero() {
249249+ t.Error("Expected zero time for expired token")
272250 }
273251}
274252···327305328306// Benchmark for token cache access
329307func BenchmarkServiceTokenCacheAccess(b *testing.B) {
330330- cacheKey := "did:plc:bench:did:web:hold.example.com"
308308+ userDID := "did:plc:bench"
309309+ holdDID := "did:web:hold.example.com"
331310332332- globalServiceTokensMu.Lock()
333333- globalServiceTokens[cacheKey] = &serviceTokenEntry{
334334- token: "benchmark-token",
335335- expiresAt: time.Now().Add(50 * time.Second),
336336- }
337337- globalServiceTokensMu.Unlock()
311311+ testPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(50*time.Second).Unix())
312312+ testTokenStr := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(testPayload) + ".signature"
313313+ token.SetServiceToken(userDID, holdDID, testTokenStr)
338314339315 b.ResetTimer()
340316 for i := 0; i < b.N; i++ {
341341- globalServiceTokensMu.RLock()
342342- entry, exists := globalServiceTokens[cacheKey]
343343- globalServiceTokensMu.RUnlock()
317317+ cachedToken, expiresAt := token.GetServiceToken(userDID, holdDID)
344318345345- if !exists || time.Now().After(entry.expiresAt) {
319319+ if cachedToken == "" || time.Now().After(expiresAt) {
346320 b.Error("Cache miss in benchmark")
347321 }
348322 }
···374348375349 // Create store with mocked hold URL
376350 ctx := &RegistryContext{
377377- DID: "did:plc:test",
378378- HoldDID: "did:web:hold.example.com",
379379- PDSEndpoint: "https://pds.example.com",
380380- Repository: "test-repo",
351351+ DID: "did:plc:test",
352352+ HoldDID: "did:web:hold.example.com",
353353+ PDSEndpoint: "https://pds.example.com",
354354+ Repository: "test-repo",
355355+ ServiceToken: "test-service-token", // Service token from middleware
381356 }
382357 store := NewProxyBlobStore(ctx)
383358 store.holdURL = holdServer.URL
384384-385385- // Setup token cache to avoid auth errors
386386- globalServiceTokensMu.Lock()
387387- globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{
388388- token: "test-token",
389389- expiresAt: time.Now().Add(50 * time.Second),
390390- }
391391- globalServiceTokensMu.Unlock()
392359393360 // Call completeMultipartUpload
394361 parts := []CompletedPart{
···476443 }))
477444 defer holdServer.Close()
478445479479- // Create store
446446+ // Create store with service token in context
480447 ctx := &RegistryContext{
481481- DID: "did:plc:test",
482482- HoldDID: "did:web:hold.example.com",
483483- PDSEndpoint: "https://pds.example.com",
484484- Repository: "test-repo",
448448+ DID: "did:plc:test",
449449+ HoldDID: "did:web:hold.example.com",
450450+ PDSEndpoint: "https://pds.example.com",
451451+ Repository: "test-repo",
452452+ ServiceToken: "test-service-token", // Service token from middleware
485453 }
486454 store := NewProxyBlobStore(ctx)
487455 store.holdURL = holdServer.URL
488488-489489- // Setup token cache
490490- globalServiceTokensMu.Lock()
491491- globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{
492492- token: "test-token",
493493- expiresAt: time.Now().Add(50 * time.Second),
494494- }
495495- globalServiceTokensMu.Unlock()
496456497457 // Call Get()
498458 dgst := digest.FromBytes(blobData)
···544504 }))
545505 defer holdServer.Close()
546506547547- // Create store
507507+ // Create store with service token in context
548508 ctx := &RegistryContext{
549549- DID: "did:plc:test",
550550- HoldDID: "did:web:hold.example.com",
551551- PDSEndpoint: "https://pds.example.com",
552552- Repository: "test-repo",
509509+ DID: "did:plc:test",
510510+ HoldDID: "did:web:hold.example.com",
511511+ PDSEndpoint: "https://pds.example.com",
512512+ Repository: "test-repo",
513513+ ServiceToken: "test-service-token", // Service token from middleware
553514 }
554515 store := NewProxyBlobStore(ctx)
555516 store.holdURL = holdServer.URL
556556-557557- // Setup token cache
558558- globalServiceTokensMu.Lock()
559559- globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{
560560- token: "test-token",
561561- expiresAt: time.Now().Add(50 * time.Second),
562562- }
563563- globalServiceTokensMu.Unlock()
564517565518 // Call Open()
566519 dgst := digest.FromBytes(blobData)
···636589 }))
637590 defer holdServer.Close()
638591639639- // Create store
592592+ // Create store with service token in context
640593 ctx := &RegistryContext{
641641- DID: "did:plc:test",
642642- HoldDID: "did:web:hold.example.com",
643643- PDSEndpoint: "https://pds.example.com",
644644- Repository: "test-repo",
594594+ DID: "did:plc:test",
595595+ HoldDID: "did:web:hold.example.com",
596596+ PDSEndpoint: "https://pds.example.com",
597597+ Repository: "test-repo",
598598+ ServiceToken: "test-service-token", // Service token from middleware
645599 }
646600 store := NewProxyBlobStore(ctx)
647601 store.holdURL = holdServer.URL
648648-649649- // Setup token cache
650650- globalServiceTokensMu.Lock()
651651- globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{
652652- token: "test-token",
653653- expiresAt: time.Now().Add(50 * time.Second),
654654- }
655655- globalServiceTokensMu.Unlock()
656602657603 // Call the function
658604 _ = tt.testFunc(store) // Ignore error, we just care about the URL
+18-18
pkg/appview/storage/routing_repository.go
···1313// The registry (AppView) is stateless and NEVER stores blobs locally
1414type RoutingRepository struct {
1515 distribution.Repository
1616- ctx *RegistryContext // All context and services
1616+ Ctx *RegistryContext // All context and services (exported for token updates)
1717 manifestStore *atproto.ManifestStore // Cached manifest store instance
1818 blobStore *ProxyBlobStore // Cached blob store instance
1919}
···2222func NewRoutingRepository(baseRepo distribution.Repository, ctx *RegistryContext) *RoutingRepository {
2323 return &RoutingRepository{
2424 Repository: baseRepo,
2525- ctx: ctx,
2525+ Ctx: ctx,
2626 }
2727}
2828···3636 // ManifestStore needs both DID and URL for backward compat (legacy holdEndpoint field)
3737 // For now, pass holdDID twice (will be cleaned up in manifest_store.go later)
3838 r.manifestStore = atproto.NewManifestStore(
3939- r.ctx.ATProtoClient,
4040- r.ctx.Repository,
4141- r.ctx.HoldDID,
4242- r.ctx.HoldDID,
4343- r.ctx.DID,
3939+ r.Ctx.ATProtoClient,
4040+ r.Ctx.Repository,
4141+ r.Ctx.HoldDID,
4242+ r.Ctx.HoldDID,
4343+ r.Ctx.DID,
4444 blobStore,
4545- r.ctx.Database,
4545+ r.Ctx.Database,
4646 )
4747 }
4848···5252 time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
5353 if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
5454 // Cache for 10 minutes - should cover typical pull operations
5555- GetGlobalHoldCache().Set(r.ctx.DID, r.ctx.Repository, holdDID, 10*time.Minute)
5555+ GetGlobalHoldCache().Set(r.Ctx.DID, r.Ctx.Repository, holdDID, 10*time.Minute)
5656 fmt.Printf("DEBUG [storage/routing]: Cached hold DID: did=%s, repo=%s, hold=%s\n",
5757- r.ctx.DID, r.ctx.Repository, holdDID)
5757+ r.Ctx.DID, r.Ctx.Repository, holdDID)
5858 }
5959 }()
6060···6767 // Return cached blob store if available
6868 if r.blobStore != nil {
6969 fmt.Printf("DEBUG [storage/blobs]: Returning cached blob store for did=%s, repo=%s\n",
7070- r.ctx.DID, r.ctx.Repository)
7070+ r.Ctx.DID, r.Ctx.Repository)
7171 return r.blobStore
7272 }
73737474 // For pull operations, check if we have a cached hold DID from a recent manifest fetch
7575 // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered
7676- holdDID := r.ctx.HoldDID // Default to discovery-based DID
7676+ holdDID := r.Ctx.HoldDID // Default to discovery-based DID
77777878- if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.ctx.DID, r.ctx.Repository); ok {
7878+ if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.Ctx.DID, r.Ctx.Repository); ok {
7979 // Use cached hold DID from manifest
8080 holdDID = cachedHoldDID
8181 fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n",
8282- r.ctx.DID, r.ctx.Repository, cachedHoldDID)
8282+ r.Ctx.DID, r.Ctx.Repository, cachedHoldDID)
8383 } else {
8484 // No cached hold, use discovery-based DID (for push or first pull)
8585 fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n",
8686- r.ctx.DID, r.ctx.Repository, holdDID)
8686+ r.Ctx.DID, r.Ctx.Repository, holdDID)
8787 }
88888989 if holdDID == "" {
···9292 }
93939494 // Update context with the correct hold DID (may be cached or discovered)
9595- r.ctx.HoldDID = holdDID
9595+ r.Ctx.HoldDID = holdDID
96969797 // Create and cache proxy blob store
9898- r.blobStore = NewProxyBlobStore(r.ctx)
9898+ r.blobStore = NewProxyBlobStore(r.Ctx)
9999 return r.blobStore
100100}
101101102102// Tags returns the tag service
103103// Tags are stored in ATProto as io.atcr.tag records
104104func (r *RoutingRepository) Tags(ctx context.Context) distribution.TagService {
105105- return atproto.NewTagStore(r.ctx.ATProtoClient, r.ctx.Repository)
105105+ return atproto.NewTagStore(r.Ctx.ATProtoClient, r.Ctx.Repository)
106106}
···44 "context"
55 "fmt"
66 "sync"
77+ "time"
7889 "github.com/bluesky-social/indigo/atproto/auth/oauth"
910 "github.com/bluesky-social/indigo/atproto/syntax"
···1516 SessionID string
1617}
17181919+// UISessionStore interface for managing UI sessions
2020+// Shared between refresher and server
2121+type UISessionStore interface {
2222+ Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
2323+ DeleteByDID(did string)
2424+}
2525+1826// Refresher manages OAuth sessions and token refresh for AppView
1927type Refresher struct {
2020- app *App
2121- sessions map[string]*SessionCache // Key: DID string
2222- mu sync.RWMutex
2323- refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
2424- refreshLockMu sync.Mutex // Protects refreshLocks map
2828+ app *App
2929+ sessions map[string]*SessionCache // Key: DID string
3030+ mu sync.RWMutex
3131+ refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
3232+ refreshLockMu sync.Mutex // Protects refreshLocks map
3333+ uiSessionStore UISessionStore // For invalidating UI sessions on OAuth failures
2534}
26352736// NewRefresher creates a new session refresher
···3140 sessions: make(map[string]*SessionCache),
3241 refreshLocks: make(map[string]*sync.Mutex),
3342 }
4343+}
4444+4545+// SetUISessionStore sets the UI session store for invalidating sessions on OAuth failures
4646+func (r *Refresher) SetUISessionStore(store UISessionStore) {
4747+ r.uiSessionStore = store
3448}
35493650// GetSession gets a fresh OAuth session for a DID
···115129}
116130117131// InvalidateSession removes a cached session for a DID
118118-// This is useful when a new OAuth flow creates a fresh session
132132+// This is useful when a new OAuth flow creates a fresh session or when OAuth refresh fails
133133+// Also invalidates any UI sessions for this DID to force re-authentication
119134func (r *Refresher) InvalidateSession(did string) {
120135 r.mu.Lock()
121136 delete(r.sessions, did)
122137 r.mu.Unlock()
138138+139139+ // Also delete UI sessions to force user to re-authenticate
140140+ if r.uiSessionStore != nil {
141141+ r.uiSessionStore.DeleteByDID(did)
142142+ }
123143}
124144125145// GetSessionID returns the sessionID for a cached session
+1-3
pkg/auth/oauth/server.go
···1616)
17171818// UISessionStore is the interface for UI session management
1919-type UISessionStore interface {
2020- Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
2121-}
1919+// UISessionStore is defined in refresher.go to avoid duplication
22202321// UserStore is the interface for user management
2422type UserStore interface {
+169
pkg/auth/token/cache.go
···11+package token
22+33+import (
44+ "encoding/base64"
55+ "encoding/json"
66+ "fmt"
77+ "strings"
88+ "sync"
99+ "time"
1010+)
1111+1212+// serviceTokenEntry represents a cached service token
1313+type serviceTokenEntry struct {
1414+ token string
1515+ expiresAt time.Time
1616+}
1717+1818+// Global cache for service tokens (DID:HoldDID -> token)
1919+// Service tokens are JWTs issued by a user's PDS to authorize AppView to act on their behalf
2020+// when communicating with hold services. These tokens are scoped to specific holds and have
2121+// limited lifetime (typically 60s, can request up to 5min).
2222+var (
2323+ globalServiceTokens = make(map[string]*serviceTokenEntry)
2424+ globalServiceTokensMu sync.RWMutex
2525+)
2626+2727+// GetServiceToken retrieves a cached service token for the given DID and hold DID
2828+// Returns empty string if no valid cached token exists
2929+func GetServiceToken(did, holdDID string) (token string, expiresAt time.Time) {
3030+ cacheKey := did + ":" + holdDID
3131+3232+ globalServiceTokensMu.RLock()
3333+ entry, exists := globalServiceTokens[cacheKey]
3434+ globalServiceTokensMu.RUnlock()
3535+3636+ if !exists {
3737+ return "", time.Time{}
3838+ }
3939+4040+ // Check if token is still valid
4141+ if time.Now().After(entry.expiresAt) {
4242+ // Token expired, remove from cache
4343+ globalServiceTokensMu.Lock()
4444+ delete(globalServiceTokens, cacheKey)
4545+ globalServiceTokensMu.Unlock()
4646+ return "", time.Time{}
4747+ }
4848+4949+ return entry.token, entry.expiresAt
5050+}
5151+5252+// SetServiceToken stores a service token in the cache
5353+// Automatically parses the JWT to extract the expiry time
5454+// Applies a 10-second safety margin (cache expires 10s before actual JWT expiry)
5555+func SetServiceToken(did, holdDID, token string) error {
5656+ cacheKey := did + ":" + holdDID
5757+5858+ // Parse JWT to extract expiry (don't verify signature - we trust the PDS)
5959+ expiry, err := parseJWTExpiry(token)
6060+ if err != nil {
6161+ // If parsing fails, use default 50s TTL (conservative fallback)
6262+ fmt.Printf("WARN [token/cache]: Failed to parse JWT expiry, using default 50s: %v\n", err)
6363+ expiry = time.Now().Add(50 * time.Second)
6464+ } else {
6565+ // Apply 10s safety margin to avoid using nearly-expired tokens
6666+ expiry = expiry.Add(-10 * time.Second)
6767+ }
6868+6969+ globalServiceTokensMu.Lock()
7070+ globalServiceTokens[cacheKey] = &serviceTokenEntry{
7171+ token: token,
7272+ expiresAt: expiry,
7373+ }
7474+ globalServiceTokensMu.Unlock()
7575+7676+ fmt.Printf("DEBUG [token/cache]: Cached service token for %s (expires in %v)\n",
7777+ cacheKey, time.Until(expiry).Round(time.Second))
7878+7979+ return nil
8080+}
8181+8282+// parseJWTExpiry extracts the expiry time from a JWT without verifying the signature
8383+// We trust tokens from the user's PDS, so signature verification isn't needed here
8484+// Manually decodes the JWT payload to avoid algorithm compatibility issues
8585+func parseJWTExpiry(tokenString string) (time.Time, error) {
8686+ // JWT format: header.payload.signature
8787+ parts := strings.Split(tokenString, ".")
8888+ if len(parts) != 3 {
8989+ return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
9090+ }
9191+9292+ // Decode the payload (second part)
9393+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
9494+ if err != nil {
9595+ return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err)
9696+ }
9797+9898+ // Parse the JSON payload
9999+ var claims struct {
100100+ Exp int64 `json:"exp"`
101101+ }
102102+ if err := json.Unmarshal(payload, &claims); err != nil {
103103+ return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err)
104104+ }
105105+106106+ if claims.Exp == 0 {
107107+ return time.Time{}, fmt.Errorf("JWT missing exp claim")
108108+ }
109109+110110+ return time.Unix(claims.Exp, 0), nil
111111+}
112112+113113+// InvalidateServiceToken removes a service token from the cache
114114+// Used when we detect that a token is invalid or the user's session has expired
115115+func InvalidateServiceToken(did, holdDID string) {
116116+ cacheKey := did + ":" + holdDID
117117+118118+ globalServiceTokensMu.Lock()
119119+ delete(globalServiceTokens, cacheKey)
120120+ globalServiceTokensMu.Unlock()
121121+122122+ fmt.Printf("DEBUG [token/cache]: Invalidated service token for %s\n", cacheKey)
123123+}
124124+125125+// GetCacheStats returns statistics about the service token cache for debugging
126126+func GetCacheStats() map[string]interface{} {
127127+ globalServiceTokensMu.RLock()
128128+ defer globalServiceTokensMu.RUnlock()
129129+130130+ validCount := 0
131131+ expiredCount := 0
132132+ now := time.Now()
133133+134134+ for _, entry := range globalServiceTokens {
135135+ if now.Before(entry.expiresAt) {
136136+ validCount++
137137+ } else {
138138+ expiredCount++
139139+ }
140140+ }
141141+142142+ return map[string]interface{}{
143143+ "total_entries": len(globalServiceTokens),
144144+ "valid_tokens": validCount,
145145+ "expired_tokens": expiredCount,
146146+ }
147147+}
148148+149149+// CleanExpiredTokens removes expired tokens from the cache
150150+// Can be called periodically to prevent unbounded growth (though expired tokens
151151+// are also removed lazily on access)
152152+func CleanExpiredTokens() {
153153+ globalServiceTokensMu.Lock()
154154+ defer globalServiceTokensMu.Unlock()
155155+156156+ now := time.Now()
157157+ removed := 0
158158+159159+ for key, entry := range globalServiceTokens {
160160+ if now.After(entry.expiresAt) {
161161+ delete(globalServiceTokens, key)
162162+ removed++
163163+ }
164164+ }
165165+166166+ if removed > 0 {
167167+ fmt.Printf("DEBUG [token/cache]: Cleaned %d expired service tokens\n", removed)
168168+ }
169169+}