···1+package communities
2+3+import (
4+ "encoding/base64"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+)
10+11+// parseJWTExpiration extracts the expiration time from a JWT access token
12+// This function does NOT verify the signature - it only parses the exp claim
13+// atproto access tokens use standard JWT format with 'exp' claim (Unix timestamp)
14+func parseJWTExpiration(token string) (time.Time, error) {
15+ // Remove "Bearer " prefix if present
16+ token = strings.TrimPrefix(token, "Bearer ")
17+ token = strings.TrimSpace(token)
18+19+ // JWT format: header.payload.signature
20+ parts := strings.Split(token, ".")
21+ if len(parts) != 3 {
22+ return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
23+ }
24+25+ // Decode payload (second part) - use RawURLEncoding (no padding)
26+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
27+ if err != nil {
28+ return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err)
29+ }
30+31+ // Extract exp claim (Unix timestamp)
32+ var claims struct {
33+ Exp int64 `json:"exp"` // Expiration time (seconds since Unix epoch)
34+ }
35+ if err := json.Unmarshal(payload, &claims); err != nil {
36+ return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err)
37+ }
38+39+ if claims.Exp == 0 {
40+ return time.Time{}, fmt.Errorf("JWT missing 'exp' claim")
41+ }
42+43+ // Convert Unix timestamp to time.Time
44+ return time.Unix(claims.Exp, 0), nil
45+}
46+47+// NeedsRefresh checks if an access token should be refreshed
48+// Returns true if the token expires within the next 5 minutes (or is already expired)
49+// Uses a 5-minute buffer to ensure we refresh before actual expiration
50+func NeedsRefresh(accessToken string) (bool, error) {
51+ if accessToken == "" {
52+ return false, fmt.Errorf("access token is empty")
53+ }
54+55+ expiration, err := parseJWTExpiration(accessToken)
56+ if err != nil {
57+ return false, fmt.Errorf("failed to parse token expiration: %w", err)
58+ }
59+60+ // Refresh if token expires within 5 minutes
61+ // This prevents service interruptions from expired tokens
62+ bufferTime := 5 * time.Minute
63+ expiresWithinBuffer := time.Now().Add(bufferTime).After(expiration)
64+65+ return expiresWithinBuffer, nil
66+}
+26
internal/db/postgres/community_repo.go
···291 return community, nil
292}
29300000000000000000000000000294// Delete removes a community from the database
295func (r *postgresCommunityRepo) Delete(ctx context.Context, did string) error {
296 query := `DELETE FROM communities WHERE did = $1`
···291 return community, nil
292}
293294+// UpdateCredentials atomically updates community's PDS access and refresh tokens
295+// CRITICAL: Both tokens must be updated together because refresh tokens are single-use
296+// After a successful token refresh, the old refresh token is immediately revoked by the PDS
297+func (r *postgresCommunityRepo) UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error {
298+ query := `
299+ UPDATE communities
300+ SET
301+ pds_access_token_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),
302+ pds_refresh_token_encrypted = pgp_sym_encrypt($3, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)),
303+ updated_at = NOW()
304+ WHERE did = $1
305+ RETURNING did`
306+307+ var returnedDID string
308+ err := r.db.QueryRowContext(ctx, query, did, accessToken, refreshToken).Scan(&returnedDID)
309+310+ if err == sql.ErrNoRows {
311+ return communities.ErrCommunityNotFound
312+ }
313+ if err != nil {
314+ return fmt.Errorf("failed to update credentials: %w", err)
315+ }
316+317+ return nil
318+}
319+320// Delete removes a community from the database
321func (r *postgresCommunityRepo) Delete(ctx context.Context, did string) error {
322 query := `DELETE FROM communities WHERE did = $1`