Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4f02572a3 | ||
|
|
0d01ae406c | ||
|
|
40b723d7ab | ||
|
|
b26444e260 | ||
|
|
650a379d2f |
23
.tangled/workflows/loom-amd64.yml
Normal file
23
.tangled/workflows/loom-amd64.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
when:
|
||||
- event: ["push"]
|
||||
branch: ["*"]
|
||||
- event: ["pull_request"]
|
||||
branch: ["main"]
|
||||
|
||||
engine: kubernetes
|
||||
image: golang:1.24-bookworm
|
||||
architecture: amd64
|
||||
|
||||
steps:
|
||||
- name: Download and Generate
|
||||
environment:
|
||||
CGO_ENABLED: 1
|
||||
command: |
|
||||
go mod download
|
||||
go generate ./...
|
||||
|
||||
- name: Run Tests
|
||||
environment:
|
||||
CGO_ENABLED: 1
|
||||
command: |
|
||||
go test -cover ./...
|
||||
23
.tangled/workflows/loom-arm64.yml
Normal file
23
.tangled/workflows/loom-arm64.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
when:
|
||||
- event: ["push"]
|
||||
branch: ["*"]
|
||||
- event: ["pull_request"]
|
||||
branch: ["main"]
|
||||
|
||||
engine: kubernetes
|
||||
image: golang:1.24-bookworm
|
||||
architecture: arm64
|
||||
|
||||
steps:
|
||||
- name: Download and Generate
|
||||
environment:
|
||||
CGO_ENABLED: 1
|
||||
command: |
|
||||
go mod download
|
||||
go generate ./...
|
||||
|
||||
- name: Run Tests
|
||||
environment:
|
||||
CGO_ENABLED: 1
|
||||
command: |
|
||||
go test -cover ./...
|
||||
@@ -7,7 +7,7 @@
|
||||
# Triggers on version tags (v*) pushed to the repository.
|
||||
|
||||
when:
|
||||
- event: ["push"]
|
||||
- event: ["manual"]
|
||||
tag: ["v*"]
|
||||
|
||||
engine: "nixery"
|
||||
|
||||
@@ -2,15 +2,10 @@
|
||||
# Triggers on version tags and builds cross-platform binaries using buildah
|
||||
|
||||
when:
|
||||
- event: ["manual"]
|
||||
- event: ["push"]
|
||||
tag: ["v*"]
|
||||
|
||||
engine: "nixery"
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- buildah
|
||||
- gnugrep # Required for tag detection
|
||||
engine: "buildah"
|
||||
|
||||
environment:
|
||||
IMAGE_REGISTRY: atcr.io
|
||||
@@ -19,6 +14,7 @@ environment:
|
||||
steps:
|
||||
- name: Get tag for current commit
|
||||
command: |
|
||||
#test
|
||||
# Fetch tags (shallow clone doesn't include them by default)
|
||||
git fetch --tags
|
||||
|
||||
@@ -37,19 +33,19 @@ steps:
|
||||
echo "Building version: $TAG"
|
||||
echo "$TAG" > .version
|
||||
|
||||
- name: Setup build environment
|
||||
- name: Setup registry credentials
|
||||
command: |
|
||||
if ! grep -q "^root:" /etc/passwd 2>/dev/null; then
|
||||
echo "root:x:0:0:root:/root:/bin/sh" >> /etc/passwd
|
||||
fi
|
||||
|
||||
- name: Login to registry
|
||||
command: |
|
||||
echo "${APP_PASSWORD}" | buildah login \
|
||||
--storage-driver vfs \
|
||||
-u "${IMAGE_USER}" \
|
||||
--password-stdin \
|
||||
${IMAGE_REGISTRY}
|
||||
mkdir -p ~/.docker
|
||||
cat > ~/.docker/config.json <<EOF
|
||||
{
|
||||
"auths": {
|
||||
"${IMAGE_REGISTRY}": {
|
||||
"auth": "$(echo -n "${IMAGE_USER}:${APP_PASSWORD}" | base64)"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
chmod 600 ~/.docker/config.json
|
||||
|
||||
- name: Build and push AppView image
|
||||
command: |
|
||||
@@ -62,6 +58,10 @@ steps:
|
||||
--file ./Dockerfile.appview \
|
||||
.
|
||||
|
||||
buildah push \
|
||||
--storage-driver vfs \
|
||||
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:${TAG}
|
||||
|
||||
buildah push \
|
||||
--storage-driver vfs \
|
||||
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:latest
|
||||
@@ -77,6 +77,10 @@ steps:
|
||||
--file ./Dockerfile.hold \
|
||||
.
|
||||
|
||||
buildah push \
|
||||
--storage-driver vfs \
|
||||
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:${TAG}
|
||||
|
||||
buildah push \
|
||||
--storage-driver vfs \
|
||||
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:latest
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
when:
|
||||
- event: ["push"]
|
||||
branch: ["main", "test"]
|
||||
- event: ["pull_request"]
|
||||
branch: ["main"]
|
||||
|
||||
engine: "nixery"
|
||||
|
||||
|
||||
94
CLAUDE.md
94
CLAUDE.md
@@ -206,9 +206,62 @@ ATCR uses middleware and routing to handle requests:
|
||||
- Implements `distribution.Repository`
|
||||
- Returns custom `Manifests()` and `Blobs()` implementations
|
||||
- Routes manifests to ATProto, blobs to S3 or BYOS
|
||||
- **IMPORTANT**: RoutingRepository is created fresh on EVERY request (no caching)
|
||||
- Each Docker layer upload is a separate HTTP request (possibly different process)
|
||||
- OAuth sessions can be refreshed/invalidated between requests
|
||||
- The OAuth refresher already caches sessions efficiently (in-memory + DB)
|
||||
- Previous caching of repositories with stale ATProtoClient caused "invalid refresh token" errors
|
||||
|
||||
### Authentication Architecture
|
||||
|
||||
#### Token Types and Flows
|
||||
|
||||
ATCR uses three distinct token types in its authentication flow:
|
||||
|
||||
**1. OAuth Tokens (Access + Refresh)**
|
||||
- **Issued by:** User's PDS via OAuth flow
|
||||
- **Stored in:** AppView database (`oauth_sessions` table)
|
||||
- **Cached in:** Refresher's in-memory map (per-DID)
|
||||
- **Used for:** AppView → User's PDS communication (write manifests, read profiles)
|
||||
- **Managed by:** Indigo library with DPoP (automatic refresh)
|
||||
- **Lifetime:** Access ~2 hours, Refresh ~90 days (PDS controlled)
|
||||
|
||||
**2. Registry JWTs**
|
||||
- **Issued by:** AppView after OAuth login
|
||||
- **Stored in:** Docker credential helper (`~/.atcr/credential-helper-token.json`)
|
||||
- **Used for:** Docker client → AppView authentication
|
||||
- **Lifetime:** 15 minutes (configurable via `ATCR_TOKEN_EXPIRATION`)
|
||||
- **Format:** JWT with DID claim
|
||||
|
||||
**3. Service Tokens**
|
||||
- **Issued by:** User's PDS via `com.atproto.server.getServiceAuth`
|
||||
- **Stored in:** AppView memory (in-memory cache with ~50s TTL)
|
||||
- **Used for:** AppView → Hold service authentication (acting on behalf of user)
|
||||
- **Lifetime:** 60 seconds (PDS controlled), cached for 50s
|
||||
- **Required:** OAuth session to obtain (catch-22 solved by Refresher)
|
||||
|
||||
**Token Flow Diagram:**
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ Docker │ ─── Registry JWT ──────────────→ │ AppView │
|
||||
│ Client │ │ │
|
||||
└─────────────┘ └──────┬───────┘
|
||||
│
|
||||
│ OAuth tokens
|
||||
│ (access + refresh)
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ User's PDS │
|
||||
└──────┬───────┘
|
||||
│
|
||||
│ Service token
|
||||
│ (via getServiceAuth)
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ Hold Service │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
#### ATProto OAuth with DPoP
|
||||
|
||||
ATCR implements the full ATProto OAuth specification with mandatory security features:
|
||||
@@ -220,13 +273,22 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
|
||||
|
||||
**Key Components** (`pkg/auth/oauth/`):
|
||||
|
||||
1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration
|
||||
- Uses indigo's `NewLocalhostConfig()` for localhost (public client)
|
||||
- Uses `NewPublicConfig()` for production base (upgraded to confidential if key provided)
|
||||
- `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"`
|
||||
- `GetDefaultScopes()` - returns ATCR registry scopes
|
||||
- `GetConfigRef()` - returns mutable config for `SetClientSecret()` calls
|
||||
- All OAuth flows (authorization, token exchange, refresh) in one place
|
||||
1. **Client** (`client.go`) - OAuth client configuration and session management
|
||||
- **ClientApp setup:**
|
||||
- `NewClientApp()` - Creates configured `*oauth.ClientApp` (uses indigo directly, no wrapper)
|
||||
- Uses `NewLocalhostConfig()` for localhost (public client)
|
||||
- Uses `NewPublicConfig()` for production (upgraded to confidential with P-256 key)
|
||||
- `GetDefaultScopes()` - Returns ATCR-specific OAuth scopes
|
||||
- `ScopesMatch()` - Compares scope lists (order-independent)
|
||||
- **Session management (Refresher):**
|
||||
- `NewRefresher()` - Creates session cache manager for AppView
|
||||
- **Purpose:** In-memory cache for `*oauth.ClientSession` objects (performance optimization)
|
||||
- **Why needed:** Saves 1-2 DB queries per request (~2ms) with minimal code complexity
|
||||
- Per-DID locking prevents concurrent database loads
|
||||
- Calls `ClientApp.ResumeSession()` on cache miss
|
||||
- Indigo handles token refresh automatically (transparent to ATCR)
|
||||
- **Performance:** Essential for high-traffic deployments, negligible for low-traffic
|
||||
- **Architecture:** Single file containing both ClientApp helpers and Refresher (combined from previous two-file structure)
|
||||
|
||||
2. **Keys** (`keys.go`) - P-256 key management for confidential clients
|
||||
- `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk
|
||||
@@ -235,21 +297,17 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
|
||||
- `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API
|
||||
- **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys)
|
||||
|
||||
3. **Token Storage** (`store.go`) - Persists OAuth sessions for AppView
|
||||
- SQLite-backed storage in UI database (not file-based)
|
||||
- Client uses `~/.atcr/oauth-token.json` (credential helper)
|
||||
3. **Storage** - Persists OAuth sessions
|
||||
- `db/oauth_store.go` - SQLite-backed storage for AppView (in UI database)
|
||||
- `store.go` - File-based storage for CLI tools (`~/.atcr/oauth-sessions.json`)
|
||||
- Implements indigo's `ClientAuthStore` interface
|
||||
|
||||
4. **Refresher** (`refresher.go`) - Token refresh manager for AppView
|
||||
- Caches OAuth sessions with automatic token refresh (handled by indigo library)
|
||||
- Per-DID locking prevents concurrent refresh races
|
||||
- Uses Client methods for consistency
|
||||
|
||||
5. **Server** (`server.go`) - OAuth authorization endpoints for AppView
|
||||
4. **Server** (`server.go`) - OAuth authorization endpoints for AppView
|
||||
- `GET /auth/oauth/authorize` - starts OAuth flow
|
||||
- `GET /auth/oauth/callback` - handles OAuth callback
|
||||
- Uses Client methods for authorization and token exchange
|
||||
- Uses `ClientApp` methods directly (no wrapper)
|
||||
|
||||
6. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
|
||||
5. **Interactive Flow** (`interactive.go`) - Reusable OAuth flow for CLI tools
|
||||
- Used by credential helper and hold service registration
|
||||
- Two-phase callback setup ensures PAR metadata availability
|
||||
|
||||
|
||||
@@ -119,10 +119,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope")
|
||||
}
|
||||
|
||||
// Create OAuth app (automatically configures confidential client for production)
|
||||
oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
|
||||
// Create OAuth client app (automatically configures confidential client for production)
|
||||
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
|
||||
oauthClientApp, err := oauth.NewClientApp(baseURL, oauthStore, desiredScopes, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OAuth app: %w", err)
|
||||
return fmt.Errorf("failed to create OAuth client app: %w", err)
|
||||
}
|
||||
if testMode {
|
||||
slog.Info("Using OAuth scopes with transition:generic (test mode)")
|
||||
@@ -132,7 +133,6 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Invalidate sessions with mismatched scopes on startup
|
||||
// This ensures all users have the latest required scopes after deployment
|
||||
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
|
||||
invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err)
|
||||
@@ -141,7 +141,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Create oauth token refresher
|
||||
refresher := oauth.NewRefresher(oauthApp)
|
||||
refresher := oauth.NewRefresher(oauthClientApp)
|
||||
|
||||
// Wire up UI session store to refresher so it can invalidate UI sessions on OAuth failures
|
||||
if uiSessionStore != nil {
|
||||
@@ -189,7 +189,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
Database: uiDatabase,
|
||||
ReadOnlyDB: uiReadOnlyDB,
|
||||
SessionStore: uiSessionStore,
|
||||
OAuthApp: oauthApp,
|
||||
OAuthClientApp: oauthClientApp,
|
||||
OAuthStore: oauthStore,
|
||||
Refresher: refresher,
|
||||
BaseURL: baseURL,
|
||||
@@ -202,7 +202,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Create OAuth server
|
||||
oauthServer := oauth.NewServer(oauthApp)
|
||||
oauthServer := oauth.NewServer(oauthClientApp)
|
||||
// Connect server to refresher for cache invalidation
|
||||
oauthServer.SetRefresher(refresher)
|
||||
// Connect UI session store for web login
|
||||
@@ -223,7 +223,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Resume OAuth session to get authenticated client
|
||||
session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID)
|
||||
session, err := oauthClientApp.ResumeSession(ctx, didParsed, sessionID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resume session", "component", "appview/callback", "did", did, "error", err)
|
||||
// Fallback: update user without avatar
|
||||
@@ -385,7 +385,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// OAuth client metadata endpoint
|
||||
mainRouter.Get("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
config := oauthApp.GetConfig()
|
||||
config := oauthClientApp.Config
|
||||
metadata := config.ClientMetadata()
|
||||
|
||||
// For confidential clients, ensure JWKS is included
|
||||
|
||||
@@ -211,7 +211,7 @@ These components are essential to registry operation and still need coverage.
|
||||
|
||||
OAuth implementation has test files but many functions remain untested.
|
||||
|
||||
#### refresher.go (Partial coverage)
|
||||
#### client.go - Session Management (Refresher) (Partial coverage)
|
||||
|
||||
**Well-covered:**
|
||||
- `NewRefresher()` - 100% ✅
|
||||
@@ -227,6 +227,8 @@ OAuth implementation has test files but many functions remain untested.
|
||||
- Session retrieval and caching
|
||||
- Token refresh flow
|
||||
- Concurrent refresh handling (per-DID locking)
|
||||
|
||||
**Note:** Refresher functionality merged into client.go (previously separate refresher.go file)
|
||||
- Cache expiration
|
||||
- Error handling for failed refreshes
|
||||
|
||||
@@ -509,8 +511,9 @@ UI initialization and setup. Low priority.
|
||||
**In Progress:**
|
||||
9. 🔴 `pkg/appview/db/*` - Database layer (41.2%, needs improvement)
|
||||
- queries.go, session_store.go, device_store.go
|
||||
10. 🔴 `pkg/auth/oauth/refresher.go` - Token refresh (Partial → 70%+)
|
||||
10. 🔴 `pkg/auth/oauth/client.go` - Session management (Refresher) (Partial → 70%+)
|
||||
- `GetSession()`, `resumeSession()` (currently 0%)
|
||||
- Note: Refresher merged into client.go
|
||||
11. 🔴 `pkg/auth/oauth/server.go` - OAuth endpoints (50.7%, continue improvements)
|
||||
- `ServeCallback()` at 16.3% needs major improvement
|
||||
12. 🔴 `pkg/appview/storage/crew.go` - Crew validation (11.1% → 80%+)
|
||||
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// LogoutHandler handles user logout with proper OAuth token revocation
|
||||
type LogoutHandler struct {
|
||||
OAuthApp *oauth.App
|
||||
Refresher *oauth.Refresher
|
||||
SessionStore *db.SessionStore
|
||||
OAuthStore *db.OAuthStore
|
||||
OAuthClientApp *indigooauth.ClientApp
|
||||
Refresher *oauth.Refresher
|
||||
SessionStore *db.SessionStore
|
||||
OAuthStore *db.OAuthStore
|
||||
}
|
||||
|
||||
func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -37,17 +38,13 @@ func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Attempt to revoke OAuth tokens on PDS side
|
||||
if uiSession.OAuthSessionID != "" {
|
||||
// Call indigo's Logout to revoke tokens on PDS
|
||||
if err := h.OAuthApp.GetClientApp().Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil {
|
||||
if err := h.OAuthClientApp.Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil {
|
||||
// Log error but don't block logout - best effort revocation
|
||||
slog.Warn("Failed to revoke OAuth tokens on PDS", "component", "logout", "did", uiSession.DID, "error", err)
|
||||
} else {
|
||||
slog.Info("Successfully revoked OAuth tokens on PDS", "component", "logout", "did", uiSession.DID)
|
||||
}
|
||||
|
||||
// Invalidate refresher cache to clear local access tokens
|
||||
h.Refresher.InvalidateSession(uiSession.DID)
|
||||
slog.Info("Invalidated local OAuth cache", "component", "logout", "did", uiSession.DID)
|
||||
|
||||
// Delete OAuth session from database (cleanup, might already be done by Logout)
|
||||
if err := h.OAuthStore.DeleteSession(r.Context(), did, uiSession.OAuthSessionID); err != nil {
|
||||
slog.Warn("Failed to delete OAuth session from database", "component", "logout", "error", err)
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/distribution/distribution/v3"
|
||||
"github.com/distribution/distribution/v3/registry/api/errcode"
|
||||
@@ -69,7 +68,6 @@ type NamespaceResolver struct {
|
||||
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
|
||||
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
|
||||
testMode bool // If true, fallback to default hold when user's hold is unreachable
|
||||
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
|
||||
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
|
||||
database storage.DatabaseMetrics // Metrics database (copied from global on init)
|
||||
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
|
||||
@@ -224,20 +222,15 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
// Example: "evan.jarrett.net/debian" -> store as "debian"
|
||||
repositoryName := imageName
|
||||
|
||||
// Cache key is DID + repository name
|
||||
cacheKey := did + ":" + repositoryName
|
||||
|
||||
// Check cache first and update service token
|
||||
if cached, ok := nr.repositories.Load(cacheKey); ok {
|
||||
cachedRepo := cached.(*storage.RoutingRepository)
|
||||
// Always update the service token even for cached repos (token may have been renewed)
|
||||
cachedRepo.Ctx.ServiceToken = serviceToken
|
||||
return cachedRepo, nil
|
||||
}
|
||||
|
||||
// Create routing repository - routes manifests to ATProto, blobs to hold service
|
||||
// The registry is stateless - no local storage is used
|
||||
// Bundle all context into a single RegistryContext struct
|
||||
//
|
||||
// NOTE: We create a fresh RoutingRepository on every request (no caching) because:
|
||||
// 1. Each layer upload is a separate HTTP request (possibly different process)
|
||||
// 2. OAuth sessions can be refreshed/invalidated between requests
|
||||
// 3. The refresher already caches sessions efficiently (in-memory + DB)
|
||||
// 4. Caching the repository with a stale ATProtoClient causes refresh token errors
|
||||
registryCtx := &storage.RegistryContext{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
@@ -251,12 +244,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
Refresher: nr.refresher,
|
||||
ReadmeCache: nr.readmeCache,
|
||||
}
|
||||
routingRepo := storage.NewRoutingRepository(repo, registryCtx)
|
||||
|
||||
// Cache the repository
|
||||
nr.repositories.Store(cacheKey, routingRepo)
|
||||
|
||||
return routingRepo, nil
|
||||
return storage.NewRoutingRepository(repo, registryCtx), nil
|
||||
}
|
||||
|
||||
// Repositories delegates to underlying namespace
|
||||
|
||||
@@ -13,21 +13,22 @@ import (
|
||||
"atcr.io/pkg/appview/readme"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
)
|
||||
|
||||
// UIDependencies contains all dependencies needed for UI route registration
|
||||
type UIDependencies struct {
|
||||
Database *sql.DB
|
||||
ReadOnlyDB *sql.DB
|
||||
SessionStore *db.SessionStore
|
||||
OAuthApp *oauth.App
|
||||
OAuthStore *db.OAuthStore
|
||||
Refresher *oauth.Refresher
|
||||
BaseURL string
|
||||
DeviceStore *db.DeviceStore
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeCache *readme.Cache
|
||||
Templates *template.Template
|
||||
Database *sql.DB
|
||||
ReadOnlyDB *sql.DB
|
||||
SessionStore *db.SessionStore
|
||||
OAuthClientApp *indigooauth.ClientApp
|
||||
OAuthStore *db.OAuthStore
|
||||
Refresher *oauth.Refresher
|
||||
BaseURL string
|
||||
DeviceStore *db.DeviceStore
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeCache *readme.Cache
|
||||
Templates *template.Template
|
||||
}
|
||||
|
||||
// RegisterUIRoutes registers all web UI and API routes on the provided router
|
||||
@@ -90,7 +91,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
router.Get("/api/stats/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.GetStatsHandler{
|
||||
DB: deps.ReadOnlyDB,
|
||||
Directory: deps.OAuthApp.Directory(),
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
},
|
||||
).ServeHTTP)
|
||||
|
||||
@@ -98,7 +99,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.StarRepositoryHandler{
|
||||
DB: deps.Database, // Needs write access
|
||||
Directory: deps.OAuthApp.Directory(),
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
Refresher: deps.Refresher,
|
||||
},
|
||||
).ServeHTTP)
|
||||
@@ -106,7 +107,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
router.Delete("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.UnstarRepositoryHandler{
|
||||
DB: deps.Database, // Needs write access
|
||||
Directory: deps.OAuthApp.Directory(),
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
Refresher: deps.Refresher,
|
||||
},
|
||||
).ServeHTTP)
|
||||
@@ -114,7 +115,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
router.Get("/api/stars/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.CheckStarHandler{
|
||||
DB: deps.ReadOnlyDB, // Read-only check
|
||||
Directory: deps.OAuthApp.Directory(),
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
Refresher: deps.Refresher,
|
||||
},
|
||||
).ServeHTTP)
|
||||
@@ -123,7 +124,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
router.Get("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.ManifestDetailHandler{
|
||||
DB: deps.ReadOnlyDB,
|
||||
Directory: deps.OAuthApp.Directory(),
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
},
|
||||
).ServeHTTP)
|
||||
|
||||
@@ -145,7 +146,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
DB: deps.ReadOnlyDB,
|
||||
Templates: deps.Templates,
|
||||
RegistryURL: registryURL,
|
||||
Directory: deps.OAuthApp.Directory(),
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
Refresher: deps.Refresher,
|
||||
HealthChecker: deps.HealthChecker,
|
||||
ReadmeCache: deps.ReadmeCache,
|
||||
@@ -202,10 +203,10 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
// Logout endpoint (supports both GET and POST)
|
||||
// Properly revokes OAuth tokens on PDS side before clearing local session
|
||||
logoutHandler := &uihandlers.LogoutHandler{
|
||||
OAuthApp: deps.OAuthApp,
|
||||
Refresher: deps.Refresher,
|
||||
SessionStore: deps.SessionStore,
|
||||
OAuthStore: deps.OAuthStore,
|
||||
OAuthClientApp: deps.OAuthClientApp,
|
||||
Refresher: deps.Refresher,
|
||||
SessionStore: deps.SessionStore,
|
||||
OAuthStore: deps.OAuthStore,
|
||||
}
|
||||
router.Get("/auth/logout", logoutHandler.ServeHTTP)
|
||||
router.Post("/auth/logout", logoutHandler.ServeHTTP)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package oauth provides OAuth client and flow implementation for ATCR.
|
||||
// It wraps indigo's OAuth library with ATCR-specific configuration,
|
||||
// including default scopes, client metadata, token refreshing, and
|
||||
// Package oauth provides OAuth client configuration and helper functions for ATCR.
|
||||
// It provides helpers for setting up indigo's OAuth library with ATCR-specific
|
||||
// configuration, including default scopes, confidential client setup, and
|
||||
// interactive browser-based authentication flows.
|
||||
package oauth
|
||||
|
||||
@@ -8,31 +8,19 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// App wraps indigo's ClientApp with ATCR-specific configuration
|
||||
type App struct {
|
||||
clientApp *oauth.ClientApp
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewApp creates a new OAuth app for ATCR with default scopes
|
||||
func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, keyPath string, clientName string) (*App, error) {
|
||||
return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid), keyPath, clientName)
|
||||
}
|
||||
|
||||
// NewAppWithScopes creates a new OAuth app for ATCR with custom scopes
|
||||
// NewClientApp creates an indigo OAuth ClientApp with ATCR-specific configuration
|
||||
// Automatically configures confidential client for production deployments
|
||||
// keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost)
|
||||
// clientName is added to OAuth client metadata
|
||||
func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*App, error) {
|
||||
// clientName is added to OAuth client metadata (currently unused, reserved for future)
|
||||
func NewClientApp(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*oauth.ClientApp, error) {
|
||||
var config oauth.ClientConfig
|
||||
redirectURI := RedirectURI(baseURL)
|
||||
|
||||
@@ -68,60 +56,7 @@ func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []stri
|
||||
clientApp := oauth.NewClientApp(&config, store)
|
||||
clientApp.Dir = atproto.GetDirectory()
|
||||
|
||||
return &App{
|
||||
clientApp: clientApp,
|
||||
baseURL: baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) GetConfig() *oauth.ClientConfig {
|
||||
return a.clientApp.Config
|
||||
}
|
||||
|
||||
// StartAuthFlow initiates an OAuth authorization flow for a given handle
|
||||
// Returns the authorization URL (state is stored in the auth store)
|
||||
func (a *App) StartAuthFlow(ctx context.Context, handle string) (authURL string, err error) {
|
||||
// Start auth flow with handle as identifier
|
||||
// Indigo will resolve the handle internally
|
||||
authURL, err = a.clientApp.StartAuthFlow(ctx, handle)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to start auth flow: %w", err)
|
||||
}
|
||||
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// ProcessCallback processes an OAuth callback with authorization code and state
|
||||
// Returns ClientSessionData which contains the session information
|
||||
func (a *App) ProcessCallback(ctx context.Context, params url.Values) (*oauth.ClientSessionData, error) {
|
||||
sessionData, err := a.clientApp.ProcessCallback(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process OAuth callback: %w", err)
|
||||
}
|
||||
|
||||
return sessionData, nil
|
||||
}
|
||||
|
||||
// ResumeSession resumes an existing OAuth session
|
||||
// Returns a ClientSession that can be used to make authenticated requests
|
||||
func (a *App) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSession, error) {
|
||||
session, err := a.clientApp.ResumeSession(ctx, did, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resume session: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetClientApp returns the underlying indigo ClientApp
|
||||
// This is useful for advanced use cases that need direct access
|
||||
func (a *App) GetClientApp() *oauth.ClientApp {
|
||||
return a.clientApp
|
||||
}
|
||||
|
||||
// Directory returns the identity directory used by the OAuth app
|
||||
func (a *App) Directory() identity.Directory {
|
||||
return a.clientApp.Dir
|
||||
return clientApp, nil
|
||||
}
|
||||
|
||||
// RedirectURI returns the OAuth redirect URI for ATCR
|
||||
@@ -188,3 +123,111 @@ func ScopesMatch(stored, desired []string) bool {
|
||||
func isLocalhost(baseURL string) bool {
|
||||
return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Session Management
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// SessionCache represents a cached OAuth session
|
||||
type SessionCache struct {
|
||||
Session *oauth.ClientSession
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// UISessionStore interface for managing UI sessions
|
||||
// Shared between refresher and server
|
||||
type UISessionStore interface {
|
||||
Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
|
||||
DeleteByDID(did string)
|
||||
}
|
||||
|
||||
// Refresher manages OAuth sessions and token refresh for AppView
|
||||
// Sessions are loaded fresh from database on every request (database is source of truth)
|
||||
type Refresher struct {
|
||||
clientApp *oauth.ClientApp
|
||||
uiSessionStore UISessionStore // For invalidating UI sessions on OAuth failures
|
||||
}
|
||||
|
||||
// NewRefresher creates a new session refresher
|
||||
func NewRefresher(clientApp *oauth.ClientApp) *Refresher {
|
||||
return &Refresher{
|
||||
clientApp: clientApp,
|
||||
}
|
||||
}
|
||||
|
||||
// SetUISessionStore sets the UI session store for invalidating sessions on OAuth failures
|
||||
func (r *Refresher) SetUISessionStore(store UISessionStore) {
|
||||
r.uiSessionStore = store
|
||||
}
|
||||
|
||||
// GetSession gets a fresh OAuth session for a DID
|
||||
// Loads session from database on every request (database is source of truth)
|
||||
func (r *Refresher) GetSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
|
||||
return r.resumeSession(ctx, did)
|
||||
}
|
||||
|
||||
// resumeSession loads a session from storage
|
||||
func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
|
||||
// Parse DID
|
||||
accountDID, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse DID: %w", err)
|
||||
}
|
||||
|
||||
// Get the latest session for this DID from SQLite store
|
||||
// The store must implement GetLatestSessionForDID (returns newest by updated_at)
|
||||
type sessionGetter interface {
|
||||
GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error)
|
||||
}
|
||||
|
||||
getter, ok := r.clientApp.Store.(sessionGetter)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)")
|
||||
}
|
||||
|
||||
sessionData, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no session found for DID: %s", did)
|
||||
}
|
||||
|
||||
// Validate that session scopes match current desired scopes
|
||||
desiredScopes := r.clientApp.Config.Scopes
|
||||
if !ScopesMatch(sessionData.Scopes, desiredScopes) {
|
||||
slog.Debug("Scope mismatch, deleting session",
|
||||
"did", did,
|
||||
"storedScopes", sessionData.Scopes,
|
||||
"desiredScopes", desiredScopes)
|
||||
|
||||
// Delete the session from database since scopes have changed
|
||||
if err := r.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil {
|
||||
slog.Warn("Failed to delete session with mismatched scopes", "error", err, "did", did)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("OAuth scopes changed, re-authentication required")
|
||||
}
|
||||
|
||||
// Resume session
|
||||
session, err := r.clientApp.ResumeSession(ctx, accountDID, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resume session: %w", err)
|
||||
}
|
||||
|
||||
// Set up callback to persist token updates to SQLite
|
||||
// This ensures that when indigo automatically refreshes tokens,
|
||||
// the new tokens are saved to the database immediately
|
||||
session.PersistSessionCallback = func(callbackCtx context.Context, updatedData *oauth.ClientSessionData) {
|
||||
if err := r.clientApp.Store.SaveSession(callbackCtx, *updatedData); err != nil {
|
||||
slog.Error("Failed to persist OAuth session update",
|
||||
"component", "oauth/refresher",
|
||||
"did", did,
|
||||
"sessionID", sessionID,
|
||||
"error", err)
|
||||
} else {
|
||||
slog.Debug("Persisted OAuth token refresh to database",
|
||||
"component", "oauth/refresher",
|
||||
"did", did,
|
||||
"sessionID", sessionID)
|
||||
}
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewApp(t *testing.T) {
|
||||
func TestNewClientApp(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
storePath := tmpDir + "/oauth-test.json"
|
||||
keyPath := tmpDir + "/oauth-key.bin"
|
||||
@@ -15,23 +15,23 @@ func TestNewApp(t *testing.T) {
|
||||
}
|
||||
|
||||
baseURL := "http://localhost:5000"
|
||||
holdDID := "did:web:hold.example.com"
|
||||
scopes := GetDefaultScopes("*")
|
||||
|
||||
app, err := NewApp(baseURL, store, holdDID, keyPath, "AT Container Registry")
|
||||
clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
if app == nil {
|
||||
t.Fatal("Expected non-nil app")
|
||||
if clientApp == nil {
|
||||
t.Fatal("Expected non-nil clientApp")
|
||||
}
|
||||
|
||||
if app.baseURL != baseURL {
|
||||
t.Errorf("Expected baseURL %q, got %q", baseURL, app.baseURL)
|
||||
if clientApp.Dir == nil {
|
||||
t.Error("Expected directory to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAppWithScopes(t *testing.T) {
|
||||
func TestNewClientAppWithCustomScopes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
storePath := tmpDir + "/oauth-test.json"
|
||||
keyPath := tmpDir + "/oauth-key.bin"
|
||||
@@ -44,19 +44,20 @@ func TestNewAppWithScopes(t *testing.T) {
|
||||
baseURL := "http://localhost:5000"
|
||||
scopes := []string{"atproto", "custom:scope"}
|
||||
|
||||
app, err := NewAppWithScopes(baseURL, store, scopes, keyPath, "AT Container Registry")
|
||||
clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewAppWithScopes() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
if app == nil {
|
||||
t.Fatal("Expected non-nil app")
|
||||
if clientApp == nil {
|
||||
t.Fatal("Expected non-nil clientApp")
|
||||
}
|
||||
|
||||
// Verify scopes are set in config
|
||||
config := app.GetConfig()
|
||||
if len(config.Scopes) != len(scopes) {
|
||||
t.Errorf("Expected %d scopes, got %d", len(scopes), len(config.Scopes))
|
||||
// Verify clientApp was created successfully
|
||||
// (Note: indigo's oauth.ClientApp doesn't expose scopes directly,
|
||||
// but we can verify it was created without error)
|
||||
if clientApp.Dir == nil {
|
||||
t.Error("Expected directory to be set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,3 +122,59 @@ func TestScopesMatch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Session Management (Refresher) Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func TestNewRefresher(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
storePath := tmpDir + "/oauth-test.json"
|
||||
|
||||
store, err := NewFileStore(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
refresher := NewRefresher(clientApp)
|
||||
if refresher == nil {
|
||||
t.Fatal("Expected non-nil refresher")
|
||||
}
|
||||
|
||||
if refresher.clientApp == nil {
|
||||
t.Error("Expected clientApp to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_SetUISessionStore(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
storePath := tmpDir + "/oauth-test.json"
|
||||
|
||||
store, err := NewFileStore(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
refresher := NewRefresher(clientApp)
|
||||
|
||||
// Test that SetUISessionStore doesn't panic with nil
|
||||
// Full mock implementation requires implementing the interface
|
||||
refresher.SetUISessionStore(nil)
|
||||
|
||||
// Verify nil is accepted
|
||||
if refresher.uiSessionStore != nil {
|
||||
t.Error("Expected UI session store to be nil after setting nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type InteractiveResult struct {
|
||||
SessionData *oauth.ClientSessionData
|
||||
Session *oauth.ClientSession
|
||||
App *App
|
||||
ClientApp *oauth.ClientApp
|
||||
}
|
||||
|
||||
// InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling
|
||||
@@ -32,19 +32,16 @@ func InteractiveFlowWithCallback(
|
||||
return nil, fmt.Errorf("failed to create OAuth store: %w", err)
|
||||
}
|
||||
|
||||
// Create OAuth app with custom scopes (or defaults if nil)
|
||||
// Create OAuth client app with custom scopes (or defaults if nil)
|
||||
// Interactive flows are typically for production use (credential helper, etc.)
|
||||
// so we default to testMode=false
|
||||
// For CLI tools, we use an empty keyPath since they're typically localhost (public client)
|
||||
// or ephemeral sessions
|
||||
var app *App
|
||||
if scopes != nil {
|
||||
app, err = NewAppWithScopes(baseURL, store, scopes, "", "AT Container Registry")
|
||||
} else {
|
||||
app, err = NewApp(baseURL, store, "*", "", "AT Container Registry")
|
||||
if scopes == nil {
|
||||
scopes = GetDefaultScopes("*")
|
||||
}
|
||||
clientApp, err := NewClientApp(baseURL, store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth app: %w", err)
|
||||
return nil, fmt.Errorf("failed to create OAuth client app: %w", err)
|
||||
}
|
||||
|
||||
// Channel to receive callback result
|
||||
@@ -54,7 +51,7 @@ func InteractiveFlowWithCallback(
|
||||
// Create callback handler
|
||||
callbackHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// Process callback
|
||||
sessionData, err := app.ProcessCallback(r.Context(), r.URL.Query())
|
||||
sessionData, err := clientApp.ProcessCallback(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
errorChan <- fmt.Errorf("failed to process callback: %w", err)
|
||||
http.Error(w, "OAuth callback failed", http.StatusInternalServerError)
|
||||
@@ -62,7 +59,7 @@ func InteractiveFlowWithCallback(
|
||||
}
|
||||
|
||||
// Resume session
|
||||
session, err := app.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID)
|
||||
session, err := clientApp.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID)
|
||||
if err != nil {
|
||||
errorChan <- fmt.Errorf("failed to resume session: %w", err)
|
||||
http.Error(w, "Failed to resume session", http.StatusInternalServerError)
|
||||
@@ -73,7 +70,7 @@ func InteractiveFlowWithCallback(
|
||||
resultChan <- &InteractiveResult{
|
||||
SessionData: sessionData,
|
||||
Session: session,
|
||||
App: app,
|
||||
ClientApp: clientApp,
|
||||
}
|
||||
|
||||
// Return success to browser
|
||||
@@ -87,7 +84,7 @@ func InteractiveFlowWithCallback(
|
||||
}
|
||||
|
||||
// Start auth flow
|
||||
authURL, err := app.StartAuthFlow(ctx, handle)
|
||||
authURL, err := clientApp.StartAuthFlow(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start auth flow: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// SessionCache represents a cached OAuth session
|
||||
type SessionCache struct {
|
||||
Session *oauth.ClientSession
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// UISessionStore interface for managing UI sessions
|
||||
// Shared between refresher and server
|
||||
type UISessionStore interface {
|
||||
Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
|
||||
DeleteByDID(did string)
|
||||
}
|
||||
|
||||
// Refresher manages OAuth sessions and token refresh for AppView
|
||||
type Refresher struct {
|
||||
app *App
|
||||
sessions map[string]*SessionCache // Key: DID string
|
||||
mu sync.RWMutex
|
||||
refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
|
||||
refreshLockMu sync.Mutex // Protects refreshLocks map
|
||||
uiSessionStore UISessionStore // For invalidating UI sessions on OAuth failures
|
||||
}
|
||||
|
||||
// NewRefresher creates a new session refresher
|
||||
func NewRefresher(app *App) *Refresher {
|
||||
return &Refresher{
|
||||
app: app,
|
||||
sessions: make(map[string]*SessionCache),
|
||||
refreshLocks: make(map[string]*sync.Mutex),
|
||||
}
|
||||
}
|
||||
|
||||
// SetUISessionStore sets the UI session store for invalidating sessions on OAuth failures
|
||||
func (r *Refresher) SetUISessionStore(store UISessionStore) {
|
||||
r.uiSessionStore = store
|
||||
}
|
||||
|
||||
// GetSession gets a fresh OAuth session for a DID
|
||||
// Returns cached session if still valid, otherwise resumes from store
|
||||
func (r *Refresher) GetSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
|
||||
// Check cache first (fast path)
|
||||
r.mu.RLock()
|
||||
cached, ok := r.sessions[did]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if ok && cached.Session != nil {
|
||||
// Session cached, tokens will auto-refresh if needed
|
||||
return cached.Session, nil
|
||||
}
|
||||
|
||||
// Session not cached, need to resume from store
|
||||
// Get or create per-DID lock to prevent concurrent resume operations
|
||||
r.refreshLockMu.Lock()
|
||||
didLock, ok := r.refreshLocks[did]
|
||||
if !ok {
|
||||
didLock = &sync.Mutex{}
|
||||
r.refreshLocks[did] = didLock
|
||||
}
|
||||
r.refreshLockMu.Unlock()
|
||||
|
||||
// Acquire DID-specific lock
|
||||
didLock.Lock()
|
||||
defer didLock.Unlock()
|
||||
|
||||
// Double-check cache after acquiring lock (another goroutine might have loaded it)
|
||||
r.mu.RLock()
|
||||
cached, ok = r.sessions[did]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if ok && cached.Session != nil {
|
||||
return cached.Session, nil
|
||||
}
|
||||
|
||||
// Actually resume the session
|
||||
return r.resumeSession(ctx, did)
|
||||
}
|
||||
|
||||
// resumeSession loads a session from storage and caches it
|
||||
func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) {
|
||||
// Parse DID
|
||||
accountDID, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse DID: %w", err)
|
||||
}
|
||||
|
||||
// Get the latest session for this DID from SQLite store
|
||||
// The store must implement GetLatestSessionForDID (returns newest by updated_at)
|
||||
type sessionGetter interface {
|
||||
GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error)
|
||||
}
|
||||
|
||||
getter, ok := r.app.clientApp.Store.(sessionGetter)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)")
|
||||
}
|
||||
|
||||
sessionData, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no session found for DID: %s", did)
|
||||
}
|
||||
|
||||
// Validate that session scopes match current desired scopes
|
||||
desiredScopes := r.app.GetConfig().Scopes
|
||||
if !ScopesMatch(sessionData.Scopes, desiredScopes) {
|
||||
slog.Debug("Scope mismatch, deleting session",
|
||||
"did", did,
|
||||
"storedScopes", sessionData.Scopes,
|
||||
"desiredScopes", desiredScopes)
|
||||
|
||||
// Delete the session from database since scopes have changed
|
||||
if err := r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil {
|
||||
slog.Warn("Failed to delete session with mismatched scopes", "error", err, "did", did)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("OAuth scopes changed, re-authentication required")
|
||||
}
|
||||
|
||||
// Resume session
|
||||
session, err := r.app.ResumeSession(ctx, accountDID, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resume session: %w", err)
|
||||
}
|
||||
|
||||
// Set up callback to persist token updates to SQLite
|
||||
// This ensures that when indigo automatically refreshes tokens,
|
||||
// the new tokens are saved to the database immediately
|
||||
session.PersistSessionCallback = func(callbackCtx context.Context, updatedData *oauth.ClientSessionData) {
|
||||
if err := r.app.GetClientApp().Store.SaveSession(callbackCtx, *updatedData); err != nil {
|
||||
slog.Error("Failed to persist OAuth session update",
|
||||
"component", "oauth/refresher",
|
||||
"did", did,
|
||||
"sessionID", sessionID,
|
||||
"error", err)
|
||||
} else {
|
||||
slog.Debug("Persisted OAuth token refresh to database",
|
||||
"component", "oauth/refresher",
|
||||
"did", did,
|
||||
"sessionID", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the session
|
||||
r.mu.Lock()
|
||||
r.sessions[did] = &SessionCache{
|
||||
Session: session,
|
||||
SessionID: sessionID,
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// InvalidateSession removes a cached session for a DID
|
||||
// This is useful when a new OAuth flow creates a fresh session or when OAuth refresh fails
|
||||
// Also invalidates any UI sessions for this DID to force re-authentication
|
||||
func (r *Refresher) InvalidateSession(did string) {
|
||||
r.mu.Lock()
|
||||
delete(r.sessions, did)
|
||||
r.mu.Unlock()
|
||||
|
||||
// Also delete UI sessions to force user to re-authenticate
|
||||
if r.uiSessionStore != nil {
|
||||
r.uiSessionStore.DeleteByDID(did)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSessionID returns the sessionID for a cached session
|
||||
// Returns empty string if session not cached
|
||||
func (r *Refresher) GetSessionID(did string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
cached, ok := r.sessions[did]
|
||||
if !ok || cached == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return cached.SessionID
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewRefresher(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
storePath := tmpDir + "/oauth-test.json"
|
||||
|
||||
store, err := NewFileStore(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
}
|
||||
|
||||
refresher := NewRefresher(app)
|
||||
if refresher == nil {
|
||||
t.Fatal("Expected non-nil refresher")
|
||||
}
|
||||
|
||||
if refresher.app == nil {
|
||||
t.Error("Expected app to be set")
|
||||
}
|
||||
|
||||
if refresher.sessions == nil {
|
||||
t.Error("Expected sessions map to be initialized")
|
||||
}
|
||||
|
||||
if refresher.refreshLocks == nil {
|
||||
t.Error("Expected refreshLocks map to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresher_SetUISessionStore(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
storePath := tmpDir + "/oauth-test.json"
|
||||
|
||||
store, err := NewFileStore(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
}
|
||||
|
||||
refresher := NewRefresher(app)
|
||||
|
||||
// Test that SetUISessionStore doesn't panic with nil
|
||||
// Full mock implementation requires implementing the interface
|
||||
refresher.SetUISessionStore(nil)
|
||||
|
||||
// Verify nil is accepted
|
||||
if refresher.uiSessionStore != nil {
|
||||
t.Error("Expected UI session store to be nil after setting nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Full session management tests will be added in comprehensive implementation
|
||||
// Those tests will require mocking OAuth sessions and testing cache behavior
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
)
|
||||
|
||||
// UISessionStore is the interface for UI session management
|
||||
// UISessionStore is defined in refresher.go to avoid duplication
|
||||
// UISessionStore is defined in client.go (session management section)
|
||||
|
||||
// UserStore is the interface for user management
|
||||
type UserStore interface {
|
||||
@@ -28,16 +29,16 @@ type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, sessio
|
||||
|
||||
// Server handles OAuth authorization for the AppView
|
||||
type Server struct {
|
||||
app *App
|
||||
clientApp *oauth.ClientApp
|
||||
refresher *Refresher
|
||||
uiSessionStore UISessionStore
|
||||
postAuthCallback PostAuthCallback
|
||||
}
|
||||
|
||||
// NewServer creates a new OAuth server
|
||||
func NewServer(app *App) *Server {
|
||||
func NewServer(clientApp *oauth.ClientApp) *Server {
|
||||
return &Server{
|
||||
app: app,
|
||||
clientApp: clientApp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Debug("Starting OAuth flow", "handle", handle)
|
||||
|
||||
// Start auth flow via indigo
|
||||
authURL, err := s.app.StartAuthFlow(r.Context(), handle)
|
||||
authURL, err := s.clientApp.StartAuthFlow(r.Context(), handle)
|
||||
if err != nil {
|
||||
slog.Error("Failed to start auth flow", "error", err, "handle", handle)
|
||||
|
||||
@@ -111,7 +112,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Process OAuth callback via indigo (handles state validation internally)
|
||||
sessionData, err := s.app.ProcessCallback(r.Context(), r.URL.Query())
|
||||
sessionData, err := s.clientApp.ProcessCallback(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err))
|
||||
return
|
||||
@@ -129,7 +130,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
type sessionCleaner interface {
|
||||
DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error
|
||||
}
|
||||
if cleaner, ok := s.app.clientApp.Store.(sessionCleaner); ok {
|
||||
if cleaner, ok := s.clientApp.Store.(sessionCleaner); ok {
|
||||
if err := cleaner.DeleteOldSessionsForDID(r.Context(), did, sessionID); err != nil {
|
||||
slog.Warn("Failed to clean up old OAuth sessions", "did", did, "error", err)
|
||||
// Non-fatal - log and continue
|
||||
@@ -138,14 +139,6 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cached session (if any) since we have a new session with new tokens
|
||||
// This happens AFTER deleting old sessions from database, ensuring the cache
|
||||
// will load the correct session when it's next accessed
|
||||
if s.refresher != nil {
|
||||
s.refresher.InvalidateSession(did)
|
||||
slog.Debug("Invalidated cached session after creating new session", "did", did)
|
||||
}
|
||||
|
||||
// Look up identity (resolve DID to handle)
|
||||
_, handle, _, err := atproto.ResolveIdentity(r.Context(), did)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,18 +19,19 @@ func TestNewServer(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
if server == nil {
|
||||
t.Fatal("Expected non-nil server")
|
||||
}
|
||||
|
||||
if server.app == nil {
|
||||
t.Error("Expected app to be set")
|
||||
if server.clientApp == nil {
|
||||
t.Error("Expected clientApp to be set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +44,14 @@ func TestServer_SetRefresher(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
refresher := NewRefresher(app)
|
||||
server := NewServer(clientApp)
|
||||
refresher := NewRefresher(clientApp)
|
||||
|
||||
server.SetRefresher(refresher)
|
||||
if server.refresher == nil {
|
||||
@@ -66,12 +68,13 @@ func TestServer_SetPostAuthCallback(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
// Set callback with correct signature
|
||||
server.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, sessionID string) error {
|
||||
@@ -92,12 +95,13 @@ func TestServer_SetUISessionStore(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
mockStore := &mockUISessionStore{}
|
||||
|
||||
server.SetUISessionStore(mockStore)
|
||||
@@ -155,12 +159,13 @@ func TestServer_ServeAuthorize_MissingHandle(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oauth/authorize", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -182,12 +187,13 @@ func TestServer_ServeAuthorize_InvalidMethod(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oauth/authorize?handle=alice.bsky.social", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -211,12 +217,13 @@ func TestServer_ServeCallback_InvalidMethod(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/oauth/callback", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -238,12 +245,13 @@ func TestServer_ServeCallback_OAuthError(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oauth/callback?error=access_denied&error_description=User+denied+access", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -270,12 +278,13 @@ func TestServer_ServeCallback_WithPostAuthCallback(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
callbackInvoked := false
|
||||
server.SetPostAuthCallback(func(ctx context.Context, d, h, pds, sessionID string) error {
|
||||
@@ -314,12 +323,13 @@ func TestServer_ServeCallback_UIFlow_SessionCreationLogic(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
server.SetUISessionStore(uiStore)
|
||||
|
||||
// Verify UI session store is set
|
||||
@@ -343,12 +353,13 @@ func TestServer_RenderError(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.renderError(w, "Test error message")
|
||||
@@ -377,12 +388,13 @@ func TestServer_RenderRedirectToSettings(t *testing.T) {
|
||||
t.Fatalf("NewFileStore() error = %v", err)
|
||||
}
|
||||
|
||||
app, err := NewApp("http://localhost:5000", store, "*", "", "AT Container Registry")
|
||||
scopes := GetDefaultScopes("*")
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
|
||||
if err != nil {
|
||||
t.Fatalf("NewApp() error = %v", err)
|
||||
t.Fatalf("NewClientApp() error = %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(app)
|
||||
server := NewServer(clientApp)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.renderRedirectToSettings(w, "alice.bsky.social")
|
||||
|
||||
@@ -46,8 +46,7 @@ func GetOrFetchServiceToken(
|
||||
|
||||
session, err := refresher.GetSession(ctx, did)
|
||||
if err != nil {
|
||||
// OAuth session unavailable - invalidate and fail
|
||||
refresher.InvalidateSession(did)
|
||||
// OAuth session unavailable - fail
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
return "", fmt.Errorf("failed to get OAuth session: %w", err)
|
||||
}
|
||||
@@ -73,17 +72,15 @@ func GetOrFetchServiceToken(
|
||||
// Use OAuth session to authenticate to PDS (with DPoP)
|
||||
resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
|
||||
if err != nil {
|
||||
// Invalidate session on auth errors (may indicate corrupted session or expired tokens)
|
||||
refresher.InvalidateSession(did)
|
||||
// Auth error - may indicate expired tokens or corrupted session
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
return "", fmt.Errorf("OAuth validation failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Invalidate session on auth failures
|
||||
// Service auth failed
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
refresher.InvalidateSession(did)
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user