5 Commits

Author SHA1 Message Date
Evan Jarrett
a4f02572a3 try and fix bad oauth cache 2025-11-08 20:47:10 -06:00
Evan Jarrett
0d01ae406c test 2025-11-07 23:07:23 -06:00
Evan Jarrett
40b723d7ab loom test 2025-11-07 22:57:10 -06:00
Evan Jarrett
b26444e260 fix 2025-11-07 22:57:10 -06:00
Evan Jarrett
650a379d2f test buildah spindle engine 2025-11-07 22:57:10 -06:00
19 changed files with 457 additions and 520 deletions

View 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 ./...

View 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 ./...

View File

@@ -7,7 +7,7 @@
# Triggers on version tags (v*) pushed to the repository. # Triggers on version tags (v*) pushed to the repository.
when: when:
- event: ["push"] - event: ["manual"]
tag: ["v*"] tag: ["v*"]
engine: "nixery" engine: "nixery"

View File

@@ -2,15 +2,10 @@
# Triggers on version tags and builds cross-platform binaries using buildah # Triggers on version tags and builds cross-platform binaries using buildah
when: when:
- event: ["manual"] - event: ["push"]
tag: ["v*"] tag: ["v*"]
engine: "nixery" engine: "buildah"
dependencies:
nixpkgs:
- buildah
- gnugrep # Required for tag detection
environment: environment:
IMAGE_REGISTRY: atcr.io IMAGE_REGISTRY: atcr.io
@@ -19,6 +14,7 @@ environment:
steps: steps:
- name: Get tag for current commit - name: Get tag for current commit
command: | command: |
#test
# Fetch tags (shallow clone doesn't include them by default) # Fetch tags (shallow clone doesn't include them by default)
git fetch --tags git fetch --tags
@@ -37,19 +33,19 @@ steps:
echo "Building version: $TAG" echo "Building version: $TAG"
echo "$TAG" > .version echo "$TAG" > .version
- name: Setup build environment - name: Setup registry credentials
command: | command: |
if ! grep -q "^root:" /etc/passwd 2>/dev/null; then mkdir -p ~/.docker
echo "root:x:0:0:root:/root:/bin/sh" >> /etc/passwd cat > ~/.docker/config.json <<EOF
fi {
"auths": {
- name: Login to registry "${IMAGE_REGISTRY}": {
command: | "auth": "$(echo -n "${IMAGE_USER}:${APP_PASSWORD}" | base64)"
echo "${APP_PASSWORD}" | buildah login \ }
--storage-driver vfs \ }
-u "${IMAGE_USER}" \ }
--password-stdin \ EOF
${IMAGE_REGISTRY} chmod 600 ~/.docker/config.json
- name: Build and push AppView image - name: Build and push AppView image
command: | command: |
@@ -62,6 +58,10 @@ steps:
--file ./Dockerfile.appview \ --file ./Dockerfile.appview \
. .
buildah push \
--storage-driver vfs \
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:${TAG}
buildah push \ buildah push \
--storage-driver vfs \ --storage-driver vfs \
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:latest ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-appview:latest
@@ -77,6 +77,10 @@ steps:
--file ./Dockerfile.hold \ --file ./Dockerfile.hold \
. .
buildah push \
--storage-driver vfs \
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:${TAG}
buildah push \ buildah push \
--storage-driver vfs \ --storage-driver vfs \
${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:latest ${IMAGE_REGISTRY}/${IMAGE_USER}/atcr-hold:latest

View File

@@ -1,8 +1,6 @@
when: when:
- event: ["push"] - event: ["push"]
branch: ["main", "test"] branch: ["main", "test"]
- event: ["pull_request"]
branch: ["main"]
engine: "nixery" engine: "nixery"

View File

@@ -206,9 +206,62 @@ ATCR uses middleware and routing to handle requests:
- Implements `distribution.Repository` - Implements `distribution.Repository`
- Returns custom `Manifests()` and `Blobs()` implementations - Returns custom `Manifests()` and `Blobs()` implementations
- Routes manifests to ATProto, blobs to S3 or BYOS - 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 ### 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 #### ATProto OAuth with DPoP
ATCR implements the full ATProto OAuth specification with mandatory security features: 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/`): **Key Components** (`pkg/auth/oauth/`):
1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration 1. **Client** (`client.go`) - OAuth client configuration and session management
- Uses indigo's `NewLocalhostConfig()` for localhost (public client) - **ClientApp setup:**
- Uses `NewPublicConfig()` for production base (upgraded to confidential if key provided) - `NewClientApp()` - Creates configured `*oauth.ClientApp` (uses indigo directly, no wrapper)
- `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"` - Uses `NewLocalhostConfig()` for localhost (public client)
- `GetDefaultScopes()` - returns ATCR registry scopes - Uses `NewPublicConfig()` for production (upgraded to confidential with P-256 key)
- `GetConfigRef()` - returns mutable config for `SetClientSecret()` calls - `GetDefaultScopes()` - Returns ATCR-specific OAuth scopes
- All OAuth flows (authorization, token exchange, refresh) in one place - `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 2. **Keys** (`keys.go`) - P-256 key management for confidential clients
- `GenerateOrLoadClientKey()` - generates or loads P-256 key from disk - `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 - `PrivateKeyToMultibase()` - converts key for `SetClientSecret()` API
- **Key type:** P-256 (ES256) for OAuth standard compatibility (not K-256 like PDS keys) - **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 3. **Storage** - Persists OAuth sessions
- SQLite-backed storage in UI database (not file-based) - `db/oauth_store.go` - SQLite-backed storage for AppView (in UI database)
- Client uses `~/.atcr/oauth-token.json` (credential helper) - `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 4. **Server** (`server.go`) - OAuth authorization endpoints 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
- `GET /auth/oauth/authorize` - starts OAuth flow - `GET /auth/oauth/authorize` - starts OAuth flow
- `GET /auth/oauth/callback` - handles OAuth callback - `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 - Used by credential helper and hold service registration
- Two-phase callback setup ensures PAR metadata availability - Two-phase callback setup ensures PAR metadata availability

View File

@@ -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") 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) // Create OAuth client app (automatically configures confidential client for production)
oauthApp, err := oauth.NewApp(baseURL, oauthStore, defaultHoldDID, cfg.Server.OAuthKeyPath, cfg.Server.ClientName) desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
oauthClientApp, err := oauth.NewClientApp(baseURL, oauthStore, desiredScopes, cfg.Server.OAuthKeyPath, cfg.Server.ClientName)
if err != nil { 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 { if testMode {
slog.Info("Using OAuth scopes with transition:generic (test mode)") 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 // Invalidate sessions with mismatched scopes on startup
// This ensures all users have the latest required scopes after deployment // This ensures all users have the latest required scopes after deployment
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes) invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
if err != nil { if err != nil {
slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err) 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 // 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 // Wire up UI session store to refresher so it can invalidate UI sessions on OAuth failures
if uiSessionStore != nil { if uiSessionStore != nil {
@@ -189,7 +189,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
Database: uiDatabase, Database: uiDatabase,
ReadOnlyDB: uiReadOnlyDB, ReadOnlyDB: uiReadOnlyDB,
SessionStore: uiSessionStore, SessionStore: uiSessionStore,
OAuthApp: oauthApp, OAuthClientApp: oauthClientApp,
OAuthStore: oauthStore, OAuthStore: oauthStore,
Refresher: refresher, Refresher: refresher,
BaseURL: baseURL, BaseURL: baseURL,
@@ -202,7 +202,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
} }
// Create OAuth server // Create OAuth server
oauthServer := oauth.NewServer(oauthApp) oauthServer := oauth.NewServer(oauthClientApp)
// Connect server to refresher for cache invalidation // Connect server to refresher for cache invalidation
oauthServer.SetRefresher(refresher) oauthServer.SetRefresher(refresher)
// Connect UI session store for web login // 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 // Resume OAuth session to get authenticated client
session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID) session, err := oauthClientApp.ResumeSession(ctx, didParsed, sessionID)
if err != nil { if err != nil {
slog.Warn("Failed to resume session", "component", "appview/callback", "did", did, "error", err) slog.Warn("Failed to resume session", "component", "appview/callback", "did", did, "error", err)
// Fallback: update user without avatar // Fallback: update user without avatar
@@ -385,7 +385,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// OAuth client metadata endpoint // OAuth client metadata endpoint
mainRouter.Get("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { mainRouter.Get("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
config := oauthApp.GetConfig() config := oauthClientApp.Config
metadata := config.ClientMetadata() metadata := config.ClientMetadata()
// For confidential clients, ensure JWKS is included // For confidential clients, ensure JWKS is included

View File

@@ -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. OAuth implementation has test files but many functions remain untested.
#### refresher.go (Partial coverage) #### client.go - Session Management (Refresher) (Partial coverage)
**Well-covered:** **Well-covered:**
- `NewRefresher()` - 100% ✅ - `NewRefresher()` - 100% ✅
@@ -227,6 +227,8 @@ OAuth implementation has test files but many functions remain untested.
- Session retrieval and caching - Session retrieval and caching
- Token refresh flow - Token refresh flow
- Concurrent refresh handling (per-DID locking) - Concurrent refresh handling (per-DID locking)
**Note:** Refresher functionality merged into client.go (previously separate refresher.go file)
- Cache expiration - Cache expiration
- Error handling for failed refreshes - Error handling for failed refreshes
@@ -509,8 +511,9 @@ UI initialization and setup. Low priority.
**In Progress:** **In Progress:**
9. 🔴 `pkg/appview/db/*` - Database layer (41.2%, needs improvement) 9. 🔴 `pkg/appview/db/*` - Database layer (41.2%, needs improvement)
- queries.go, session_store.go, device_store.go - 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%) - `GetSession()`, `resumeSession()` (currently 0%)
- Note: Refresher merged into client.go
11. 🔴 `pkg/auth/oauth/server.go` - OAuth endpoints (50.7%, continue improvements) 11. 🔴 `pkg/auth/oauth/server.go` - OAuth endpoints (50.7%, continue improvements)
- `ServeCallback()` at 16.3% needs major improvement - `ServeCallback()` at 16.3% needs major improvement
12. 🔴 `pkg/appview/storage/crew.go` - Crew validation (11.1% → 80%+) 12. 🔴 `pkg/appview/storage/crew.go` - Crew validation (11.1% → 80%+)

View File

@@ -6,15 +6,16 @@ import (
"atcr.io/pkg/appview/db" "atcr.io/pkg/appview/db"
"atcr.io/pkg/auth/oauth" "atcr.io/pkg/auth/oauth"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/atproto/syntax"
) )
// LogoutHandler handles user logout with proper OAuth token revocation // LogoutHandler handles user logout with proper OAuth token revocation
type LogoutHandler struct { type LogoutHandler struct {
OAuthApp *oauth.App OAuthClientApp *indigooauth.ClientApp
Refresher *oauth.Refresher Refresher *oauth.Refresher
SessionStore *db.SessionStore SessionStore *db.SessionStore
OAuthStore *db.OAuthStore OAuthStore *db.OAuthStore
} }
func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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 // Attempt to revoke OAuth tokens on PDS side
if uiSession.OAuthSessionID != "" { if uiSession.OAuthSessionID != "" {
// Call indigo's Logout to revoke tokens on PDS // 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 // 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) slog.Warn("Failed to revoke OAuth tokens on PDS", "component", "logout", "did", uiSession.DID, "error", err)
} else { } else {
slog.Info("Successfully revoked OAuth tokens on PDS", "component", "logout", "did", uiSession.DID) 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) // Delete OAuth session from database (cleanup, might already be done by Logout)
if err := h.OAuthStore.DeleteSession(r.Context(), did, uiSession.OAuthSessionID); err != nil { 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) slog.Warn("Failed to delete OAuth session from database", "component", "logout", "error", err)

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"sync"
"github.com/distribution/distribution/v3" "github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/registry/api/errcode" "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") defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
baseURL string // Base URL for error messages (e.g., "https://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 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) refresher *oauth.Refresher // OAuth session manager (copied from global on init)
database storage.DatabaseMetrics // Metrics database (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) 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" // Example: "evan.jarrett.net/debian" -> store as "debian"
repositoryName := imageName 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 // Create routing repository - routes manifests to ATProto, blobs to hold service
// The registry is stateless - no local storage is used // The registry is stateless - no local storage is used
// Bundle all context into a single RegistryContext struct // 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{ registryCtx := &storage.RegistryContext{
DID: did, DID: did,
Handle: handle, Handle: handle,
@@ -251,12 +244,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
Refresher: nr.refresher, Refresher: nr.refresher,
ReadmeCache: nr.readmeCache, ReadmeCache: nr.readmeCache,
} }
routingRepo := storage.NewRoutingRepository(repo, registryCtx)
// Cache the repository return storage.NewRoutingRepository(repo, registryCtx), nil
nr.repositories.Store(cacheKey, routingRepo)
return routingRepo, nil
} }
// Repositories delegates to underlying namespace // Repositories delegates to underlying namespace

View File

@@ -13,21 +13,22 @@ import (
"atcr.io/pkg/appview/readme" "atcr.io/pkg/appview/readme"
"atcr.io/pkg/auth/oauth" "atcr.io/pkg/auth/oauth"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
) )
// UIDependencies contains all dependencies needed for UI route registration // UIDependencies contains all dependencies needed for UI route registration
type UIDependencies struct { type UIDependencies struct {
Database *sql.DB Database *sql.DB
ReadOnlyDB *sql.DB ReadOnlyDB *sql.DB
SessionStore *db.SessionStore SessionStore *db.SessionStore
OAuthApp *oauth.App OAuthClientApp *indigooauth.ClientApp
OAuthStore *db.OAuthStore OAuthStore *db.OAuthStore
Refresher *oauth.Refresher Refresher *oauth.Refresher
BaseURL string BaseURL string
DeviceStore *db.DeviceStore DeviceStore *db.DeviceStore
HealthChecker *holdhealth.Checker HealthChecker *holdhealth.Checker
ReadmeCache *readme.Cache ReadmeCache *readme.Cache
Templates *template.Template Templates *template.Template
} }
// RegisterUIRoutes registers all web UI and API routes on the provided router // 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)( router.Get("/api/stats/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.GetStatsHandler{ &uihandlers.GetStatsHandler{
DB: deps.ReadOnlyDB, DB: deps.ReadOnlyDB,
Directory: deps.OAuthApp.Directory(), Directory: deps.OAuthClientApp.Dir,
}, },
).ServeHTTP) ).ServeHTTP)
@@ -98,7 +99,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)( router.Post("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
&uihandlers.StarRepositoryHandler{ &uihandlers.StarRepositoryHandler{
DB: deps.Database, // Needs write access DB: deps.Database, // Needs write access
Directory: deps.OAuthApp.Directory(), Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher, Refresher: deps.Refresher,
}, },
).ServeHTTP) ).ServeHTTP)
@@ -106,7 +107,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
router.Delete("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)( router.Delete("/api/stars/{handle}/{repository}", middleware.RequireAuth(deps.SessionStore, deps.Database)(
&uihandlers.UnstarRepositoryHandler{ &uihandlers.UnstarRepositoryHandler{
DB: deps.Database, // Needs write access DB: deps.Database, // Needs write access
Directory: deps.OAuthApp.Directory(), Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher, Refresher: deps.Refresher,
}, },
).ServeHTTP) ).ServeHTTP)
@@ -114,7 +115,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
router.Get("/api/stars/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)( router.Get("/api/stars/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.CheckStarHandler{ &uihandlers.CheckStarHandler{
DB: deps.ReadOnlyDB, // Read-only check DB: deps.ReadOnlyDB, // Read-only check
Directory: deps.OAuthApp.Directory(), Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher, Refresher: deps.Refresher,
}, },
).ServeHTTP) ).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)( router.Get("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.ManifestDetailHandler{ &uihandlers.ManifestDetailHandler{
DB: deps.ReadOnlyDB, DB: deps.ReadOnlyDB,
Directory: deps.OAuthApp.Directory(), Directory: deps.OAuthClientApp.Dir,
}, },
).ServeHTTP) ).ServeHTTP)
@@ -145,7 +146,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
DB: deps.ReadOnlyDB, DB: deps.ReadOnlyDB,
Templates: deps.Templates, Templates: deps.Templates,
RegistryURL: registryURL, RegistryURL: registryURL,
Directory: deps.OAuthApp.Directory(), Directory: deps.OAuthClientApp.Dir,
Refresher: deps.Refresher, Refresher: deps.Refresher,
HealthChecker: deps.HealthChecker, HealthChecker: deps.HealthChecker,
ReadmeCache: deps.ReadmeCache, ReadmeCache: deps.ReadmeCache,
@@ -202,10 +203,10 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
// Logout endpoint (supports both GET and POST) // Logout endpoint (supports both GET and POST)
// Properly revokes OAuth tokens on PDS side before clearing local session // Properly revokes OAuth tokens on PDS side before clearing local session
logoutHandler := &uihandlers.LogoutHandler{ logoutHandler := &uihandlers.LogoutHandler{
OAuthApp: deps.OAuthApp, OAuthClientApp: deps.OAuthClientApp,
Refresher: deps.Refresher, Refresher: deps.Refresher,
SessionStore: deps.SessionStore, SessionStore: deps.SessionStore,
OAuthStore: deps.OAuthStore, OAuthStore: deps.OAuthStore,
} }
router.Get("/auth/logout", logoutHandler.ServeHTTP) router.Get("/auth/logout", logoutHandler.ServeHTTP)
router.Post("/auth/logout", logoutHandler.ServeHTTP) router.Post("/auth/logout", logoutHandler.ServeHTTP)

View File

@@ -1,6 +1,6 @@
// Package oauth provides OAuth client and flow implementation for ATCR. // Package oauth provides OAuth client configuration and helper functions for ATCR.
// It wraps indigo's OAuth library with ATCR-specific configuration, // It provides helpers for setting up indigo's OAuth library with ATCR-specific
// including default scopes, client metadata, token refreshing, and // configuration, including default scopes, confidential client setup, and
// interactive browser-based authentication flows. // interactive browser-based authentication flows.
package oauth package oauth
@@ -8,31 +8,19 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url"
"strings" "strings"
"time"
"atcr.io/pkg/atproto" "atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/atproto/syntax"
) )
// App wraps indigo's ClientApp with ATCR-specific configuration // NewClientApp creates an indigo OAuth 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
// Automatically configures confidential client for production deployments // Automatically configures confidential client for production deployments
// keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost) // keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost)
// clientName is added to OAuth client metadata // clientName is added to OAuth client metadata (currently unused, reserved for future)
func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*App, error) { func NewClientApp(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*oauth.ClientApp, error) {
var config oauth.ClientConfig var config oauth.ClientConfig
redirectURI := RedirectURI(baseURL) redirectURI := RedirectURI(baseURL)
@@ -68,60 +56,7 @@ func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []stri
clientApp := oauth.NewClientApp(&config, store) clientApp := oauth.NewClientApp(&config, store)
clientApp.Dir = atproto.GetDirectory() clientApp.Dir = atproto.GetDirectory()
return &App{ return clientApp, nil
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
} }
// RedirectURI returns the OAuth redirect URI for ATCR // RedirectURI returns the OAuth redirect URI for ATCR
@@ -188,3 +123,111 @@ func ScopesMatch(stored, desired []string) bool {
func isLocalhost(baseURL string) bool { func isLocalhost(baseURL string) bool {
return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost") 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
}

View File

@@ -4,7 +4,7 @@ import (
"testing" "testing"
) )
func TestNewApp(t *testing.T) { func TestNewClientApp(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
storePath := tmpDir + "/oauth-test.json" storePath := tmpDir + "/oauth-test.json"
keyPath := tmpDir + "/oauth-key.bin" keyPath := tmpDir + "/oauth-key.bin"
@@ -15,23 +15,23 @@ func TestNewApp(t *testing.T) {
} }
baseURL := "http://localhost:5000" 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
if app == nil { if clientApp == nil {
t.Fatal("Expected non-nil app") t.Fatal("Expected non-nil clientApp")
} }
if app.baseURL != baseURL { if clientApp.Dir == nil {
t.Errorf("Expected baseURL %q, got %q", baseURL, app.baseURL) t.Error("Expected directory to be set")
} }
} }
func TestNewAppWithScopes(t *testing.T) { func TestNewClientAppWithCustomScopes(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
storePath := tmpDir + "/oauth-test.json" storePath := tmpDir + "/oauth-test.json"
keyPath := tmpDir + "/oauth-key.bin" keyPath := tmpDir + "/oauth-key.bin"
@@ -44,19 +44,20 @@ func TestNewAppWithScopes(t *testing.T) {
baseURL := "http://localhost:5000" baseURL := "http://localhost:5000"
scopes := []string{"atproto", "custom:scope"} 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 { if err != nil {
t.Fatalf("NewAppWithScopes() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
if app == nil { if clientApp == nil {
t.Fatal("Expected non-nil app") t.Fatal("Expected non-nil clientApp")
} }
// Verify scopes are set in config // Verify clientApp was created successfully
config := app.GetConfig() // (Note: indigo's oauth.ClientApp doesn't expose scopes directly,
if len(config.Scopes) != len(scopes) { // but we can verify it was created without error)
t.Errorf("Expected %d scopes, got %d", len(scopes), len(config.Scopes)) 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")
}
}

View File

@@ -13,7 +13,7 @@ import (
type InteractiveResult struct { type InteractiveResult struct {
SessionData *oauth.ClientSessionData SessionData *oauth.ClientSessionData
Session *oauth.ClientSession Session *oauth.ClientSession
App *App ClientApp *oauth.ClientApp
} }
// InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling // 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) 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.) // 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) // For CLI tools, we use an empty keyPath since they're typically localhost (public client)
// or ephemeral sessions // or ephemeral sessions
var app *App if scopes == nil {
if scopes != nil { scopes = GetDefaultScopes("*")
app, err = NewAppWithScopes(baseURL, store, scopes, "", "AT Container Registry")
} else {
app, err = NewApp(baseURL, store, "*", "", "AT Container Registry")
} }
clientApp, err := NewClientApp(baseURL, store, scopes, "", "AT Container Registry")
if err != nil { 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 // Channel to receive callback result
@@ -54,7 +51,7 @@ func InteractiveFlowWithCallback(
// Create callback handler // Create callback handler
callbackHandler := func(w http.ResponseWriter, r *http.Request) { callbackHandler := func(w http.ResponseWriter, r *http.Request) {
// Process callback // Process callback
sessionData, err := app.ProcessCallback(r.Context(), r.URL.Query()) sessionData, err := clientApp.ProcessCallback(r.Context(), r.URL.Query())
if err != nil { if err != nil {
errorChan <- fmt.Errorf("failed to process callback: %w", err) errorChan <- fmt.Errorf("failed to process callback: %w", err)
http.Error(w, "OAuth callback failed", http.StatusInternalServerError) http.Error(w, "OAuth callback failed", http.StatusInternalServerError)
@@ -62,7 +59,7 @@ func InteractiveFlowWithCallback(
} }
// Resume session // 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 { if err != nil {
errorChan <- fmt.Errorf("failed to resume session: %w", err) errorChan <- fmt.Errorf("failed to resume session: %w", err)
http.Error(w, "Failed to resume session", http.StatusInternalServerError) http.Error(w, "Failed to resume session", http.StatusInternalServerError)
@@ -73,7 +70,7 @@ func InteractiveFlowWithCallback(
resultChan <- &InteractiveResult{ resultChan <- &InteractiveResult{
SessionData: sessionData, SessionData: sessionData,
Session: session, Session: session,
App: app, ClientApp: clientApp,
} }
// Return success to browser // Return success to browser
@@ -87,7 +84,7 @@ func InteractiveFlowWithCallback(
} }
// Start auth flow // Start auth flow
authURL, err := app.StartAuthFlow(ctx, handle) authURL, err := clientApp.StartAuthFlow(ctx, handle)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to start auth flow: %w", err) return nil, fmt.Errorf("failed to start auth flow: %w", err)
} }

View File

@@ -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
}

View File

@@ -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

View File

@@ -10,10 +10,11 @@ import (
"time" "time"
"atcr.io/pkg/atproto" "atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
) )
// UISessionStore is the interface for UI session management // 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 // UserStore is the interface for user management
type UserStore interface { type UserStore interface {
@@ -28,16 +29,16 @@ type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, sessio
// Server handles OAuth authorization for the AppView // Server handles OAuth authorization for the AppView
type Server struct { type Server struct {
app *App clientApp *oauth.ClientApp
refresher *Refresher refresher *Refresher
uiSessionStore UISessionStore uiSessionStore UISessionStore
postAuthCallback PostAuthCallback postAuthCallback PostAuthCallback
} }
// NewServer creates a new OAuth server // NewServer creates a new OAuth server
func NewServer(app *App) *Server { func NewServer(clientApp *oauth.ClientApp) *Server {
return &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) slog.Debug("Starting OAuth flow", "handle", handle)
// Start auth flow via indigo // Start auth flow via indigo
authURL, err := s.app.StartAuthFlow(r.Context(), handle) authURL, err := s.clientApp.StartAuthFlow(r.Context(), handle)
if err != nil { if err != nil {
slog.Error("Failed to start auth flow", "error", err, "handle", handle) 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) // 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 { if err != nil {
s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err)) s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err))
return return
@@ -129,7 +130,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
type sessionCleaner interface { type sessionCleaner interface {
DeleteOldSessionsForDID(ctx context.Context, did string, keepSessionID string) error 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 { if err := cleaner.DeleteOldSessionsForDID(r.Context(), did, sessionID); err != nil {
slog.Warn("Failed to clean up old OAuth sessions", "did", did, "error", err) slog.Warn("Failed to clean up old OAuth sessions", "did", did, "error", err)
// Non-fatal - log and continue // 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) // Look up identity (resolve DID to handle)
_, handle, _, err := atproto.ResolveIdentity(r.Context(), did) _, handle, _, err := atproto.ResolveIdentity(r.Context(), did)
if err != nil { if err != nil {

View File

@@ -19,18 +19,19 @@ func TestNewServer(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
server := NewServer(app) server := NewServer(clientApp)
if server == nil { if server == nil {
t.Fatal("Expected non-nil server") t.Fatal("Expected non-nil server")
} }
if server.app == nil { if server.clientApp == nil {
t.Error("Expected app to be set") t.Error("Expected clientApp to be set")
} }
} }
@@ -43,13 +44,14 @@ func TestServer_SetRefresher(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
server := NewServer(app) server := NewServer(clientApp)
refresher := NewRefresher(app) refresher := NewRefresher(clientApp)
server.SetRefresher(refresher) server.SetRefresher(refresher)
if server.refresher == nil { if server.refresher == nil {
@@ -66,12 +68,13 @@ func TestServer_SetPostAuthCallback(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { 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 // Set callback with correct signature
server.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, sessionID string) error { 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) 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
server := NewServer(app) server := NewServer(clientApp)
mockStore := &mockUISessionStore{} mockStore := &mockUISessionStore{}
server.SetUISessionStore(mockStore) server.SetUISessionStore(mockStore)
@@ -155,12 +159,13 @@ func TestServer_ServeAuthorize_MissingHandle(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { 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) req := httptest.NewRequest(http.MethodGet, "/auth/oauth/authorize", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -182,12 +187,13 @@ func TestServer_ServeAuthorize_InvalidMethod(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { 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) req := httptest.NewRequest(http.MethodPost, "/auth/oauth/authorize?handle=alice.bsky.social", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -211,12 +217,13 @@ func TestServer_ServeCallback_InvalidMethod(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { 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) req := httptest.NewRequest(http.MethodPost, "/auth/oauth/callback", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -238,12 +245,13 @@ func TestServer_ServeCallback_OAuthError(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { 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) req := httptest.NewRequest(http.MethodGet, "/auth/oauth/callback?error=access_denied&error_description=User+denied+access", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -270,12 +278,13 @@ func TestServer_ServeCallback_WithPostAuthCallback(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
server := NewServer(app) server := NewServer(clientApp)
callbackInvoked := false callbackInvoked := false
server.SetPostAuthCallback(func(ctx context.Context, d, h, pds, sessionID string) error { 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) 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
server := NewServer(app) server := NewServer(clientApp)
server.SetUISessionStore(uiStore) server.SetUISessionStore(uiStore)
// Verify UI session store is set // Verify UI session store is set
@@ -343,12 +353,13 @@ func TestServer_RenderError(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
server := NewServer(app) server := NewServer(clientApp)
w := httptest.NewRecorder() w := httptest.NewRecorder()
server.renderError(w, "Test error message") server.renderError(w, "Test error message")
@@ -377,12 +388,13 @@ func TestServer_RenderRedirectToSettings(t *testing.T) {
t.Fatalf("NewFileStore() error = %v", err) 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 { if err != nil {
t.Fatalf("NewApp() error = %v", err) t.Fatalf("NewClientApp() error = %v", err)
} }
server := NewServer(app) server := NewServer(clientApp)
w := httptest.NewRecorder() w := httptest.NewRecorder()
server.renderRedirectToSettings(w, "alice.bsky.social") server.renderRedirectToSettings(w, "alice.bsky.social")

View File

@@ -46,8 +46,7 @@ func GetOrFetchServiceToken(
session, err := refresher.GetSession(ctx, did) session, err := refresher.GetSession(ctx, did)
if err != nil { if err != nil {
// OAuth session unavailable - invalidate and fail // OAuth session unavailable - fail
refresher.InvalidateSession(did)
InvalidateServiceToken(did, holdDID) InvalidateServiceToken(did, holdDID)
return "", fmt.Errorf("failed to get OAuth session: %w", err) 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) // Use OAuth session to authenticate to PDS (with DPoP)
resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth") resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
if err != nil { if err != nil {
// Invalidate session on auth errors (may indicate corrupted session or expired tokens) // Auth error - may indicate expired tokens or corrupted session
refresher.InvalidateSession(did)
InvalidateServiceToken(did, holdDID) InvalidateServiceToken(did, holdDID)
return "", fmt.Errorf("OAuth validation failed: %w", err) return "", fmt.Errorf("OAuth validation failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// Invalidate session on auth failures // Service auth failed
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
refresher.InvalidateSession(did)
InvalidateServiceToken(did, holdDID) InvalidateServiceToken(did, holdDID)
return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes)) return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
} }