cleanup oauth

This commit is contained in:
Evan Jarrett
2025-10-04 13:50:28 -05:00
parent 528bac80de
commit 31e235a2a1
15 changed files with 1712 additions and 304 deletions
+102 -192
View File
@@ -2,39 +2,26 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
const (
callbackPort = "8888"
baseURL = "http://127.0.0.1:" + callbackPort
callbackPath = "/callback"
// Default AppView URL - can be overridden via environment variable
defaultAppViewURL = "http://127.0.0.1:5000"
)
var (
clientID string
redirectURI string
)
func init() {
// Use shared helper to create localhost client ID
cfg := oauth.ClientIDConfig{
BaseURL: baseURL,
CallbackPath: callbackPath,
Scopes: []string{"atproto"},
}
clientID, redirectURI = cfg.MakeClientID()
// SessionStore represents the stored session token
type SessionStore struct {
SessionToken string `json:"session_token"`
Handle string `json:"handle"`
AppViewURL string `json:"appview_url"`
}
// Docker credential helper protocol
@@ -84,72 +71,22 @@ func handleGet() {
os.Exit(1)
}
// Load token from storage
tokenPath := getTokenPath()
token, err := oauth.LoadTokenStore(tokenPath)
// Load session from storage
sessionPath := getSessionPath()
session, err := loadSession(sessionPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err)
fmt.Fprintf(os.Stderr, "Error loading session: %v\n", err)
fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n")
os.Exit(1)
}
// Check if token is expired and refresh if needed
if token.IsExpired() && token.RefreshToken != "" {
// Create OAuth client
client, err := oauth.NewClient(clientID, redirectURI)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating OAuth client: %v\n", err)
os.Exit(1)
}
// Load DPoP key
dpopKey, err := token.GetDPoPKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading DPoP key: %v\n", err)
os.Exit(1)
}
client.SetDPoPKey(dpopKey)
// Initialize for the handle
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.InitializeForHandle(ctx, token.Handle); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing OAuth client: %v\n", err)
os.Exit(1)
}
// Refresh the token
newToken, err := client.RefreshToken(ctx, token.RefreshToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err)
os.Exit(1)
}
// Update token store
token.AccessToken = newToken.AccessToken
token.RefreshToken = newToken.RefreshToken
token.ExpiresAt = newToken.Expiry
// Save updated token
if err := token.Save(tokenPath); err != nil {
fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
os.Exit(1)
}
}
// Exchange ATProto token for registry JWT
fmt.Fprintf(os.Stderr, "[DEBUG] Exchanging token for %s, handle=%s, token_expired=%v\n", serverURL, token.Handle, token.IsExpired())
registryJWT, err := exchangeForRegistryToken(token.AccessToken, serverURL, token.Handle)
if err != nil {
fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err)
os.Exit(1)
}
// Return credentials
// Return session token as credentials
// Docker will call /auth/token with this, and the token handler
// will validate the session token and issue a registry JWT
creds := Credentials{
ServerURL: serverURL,
Username: "oauth2",
Secret: registryJWT,
Username: "oauth2", // Signals token-based auth to Docker
Secret: session.SessionToken, // Return session token directly
}
if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil {
@@ -180,10 +117,10 @@ func handleErase() {
os.Exit(1)
}
// Remove token file
tokenPath := getTokenPath()
if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error removing token: %v\n", err)
// Remove session file
sessionPath := getSessionPath()
if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error removing session: %v\n", err)
os.Exit(1)
}
}
@@ -194,6 +131,13 @@ func handleConfigure(handle string) {
fmt.Println("=====================================")
fmt.Println()
// Get AppView URL from environment or use default
appViewURL := os.Getenv("ATCR_APPVIEW_URL")
if appViewURL == "" {
appViewURL = defaultAppViewURL
}
fmt.Printf("AppView URL: %s\n\n", appViewURL)
// Ask for handle if not provided as argument
if handle == "" {
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
@@ -205,52 +149,96 @@ func handleConfigure(handle string) {
fmt.Printf("Using handle: %s\n", handle)
}
// Run OAuth flow
fmt.Println("\nStarting OAuth flow...")
token, err := runOAuthFlow(handle)
if err != nil {
fmt.Fprintf(os.Stderr, "Error during OAuth flow: %v\n", err)
// Open browser to AppView OAuth authorization
authURL := fmt.Sprintf("%s/auth/oauth/authorize?handle=%s", appViewURL, handle)
fmt.Printf("\nOpening browser to: %s\n", authURL)
fmt.Println("Please complete the authorization in your browser.")
fmt.Println("After authorization, you will receive a session token.")
fmt.Println()
if err := oauth.OpenBrowser(authURL); err != nil {
fmt.Printf("Failed to open browser automatically.\nPlease open this URL manually:\n%s\n\n", authURL)
}
// Prompt user to paste session token
fmt.Print("Enter the session token from the browser: ")
var sessionToken string
if _, err := fmt.Scanln(&sessionToken); err != nil {
fmt.Fprintf(os.Stderr, "Error reading session token: %v\n", err)
os.Exit(1)
}
// Save token
tokenPath := getTokenPath()
if err := token.Save(tokenPath); err != nil {
fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
// Create session store
session := &SessionStore{
SessionToken: sessionToken,
Handle: handle,
AppViewURL: appViewURL,
}
// Save session
sessionPath := getSessionPath()
if err := saveSession(sessionPath, session); err != nil {
fmt.Fprintf(os.Stderr, "Error saving session: %v\n", err)
os.Exit(1)
}
fmt.Println("\nConfiguration complete!")
fmt.Println("\nConfiguration complete!")
fmt.Println("You can now use docker push/pull with atcr.io")
}
// getTokenPath returns the path to the token file
func getTokenPath() string {
// getSessionPath returns the path to the session file
func getSessionPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
return filepath.Join(homeDir, ".atcr", "oauth-token.json")
}
// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT
func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey interface{}) (string, error) {
// Call the registry's /auth/exchange endpoint
// This endpoint validates the ATProto token and returns a registry JWT
// Normalize registry URL - add scheme if missing
if !strings.HasPrefix(registryURL, "http://") && !strings.HasPrefix(registryURL, "https://") {
registryURL = "http://" + registryURL
atcrDir := filepath.Join(homeDir, ".atcr")
if err := os.MkdirAll(atcrDir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err)
os.Exit(1)
}
exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL)
return filepath.Join(atcrDir, "session.json")
}
// loadSession loads the session from disk
func loadSession(path string) (*SessionStore, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read session file: %w", err)
}
var session SessionStore
if err := json.Unmarshal(data, &session); err != nil {
return nil, fmt.Errorf("failed to parse session file: %w", err)
}
return &session, nil
}
// saveSession saves the session to disk
func saveSession(path string, session *SessionStore) error {
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("failed to write session file: %w", err)
}
return nil
}
// exchangeSessionForRegistryToken exchanges the session token for a registry JWT
func exchangeSessionForRegistryToken(sessionToken, appViewURL string) (string, error) {
// Call the AppView's /auth/exchange endpoint
exchangeURL := fmt.Sprintf("%s/auth/exchange", appViewURL)
reqBody := map[string]any{
"access_token": atprotoToken,
"handle": handle, // Required for PDS resolution and token validation
"scope": []string{"repository:*:pull,push"},
"scope": []string{"repository:*:pull,push"},
}
body, err := json.Marshal(reqBody)
@@ -258,23 +246,14 @@ func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey
return "", fmt.Errorf("failed to marshal request: %w", err)
}
fmt.Fprintf(os.Stderr, "[DEBUG] POST %s\n", exchangeURL)
fmt.Fprintf(os.Stderr, "[DEBUG] Request: handle=%s, token_prefix=%s..., scope=%v\n",
handle,
atprotoToken[:min(20, len(atprotoToken))],
reqBody["scope"])
// Create HTTP client with DPoP transport
transport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey)
transport.SetAccessToken(atprotoToken)
client := &http.Client{Transport: transport}
req, err := http.NewRequest("POST", exchangeURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+sessionToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to call exchange endpoint: %w", err)
@@ -284,9 +263,7 @@ func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey
if resp.StatusCode != http.StatusOK {
// Read response body for debugging
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "[DEBUG] Exchange failed with status %d\n", resp.StatusCode)
fmt.Fprintf(os.Stderr, "[DEBUG] Response body: %s\n", string(bodyBytes))
return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode)
return "", fmt.Errorf("exchange failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
@@ -304,70 +281,3 @@ func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey
return result.AccessToken, nil
}
// runOAuthFlow executes the OAuth flow with browser
func runOAuthFlow(handle string) (*oauth.TokenStore, error) {
var server *http.Server
// Run interactive OAuth flow with ephemeral server
result, err := oauth.RunInteractiveFlow(
context.Background(),
oauth.InteractiveFlowConfig{
ClientID: clientID,
RedirectURI: redirectURI,
Handle: handle,
},
func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error {
// First call (authURL empty): start server
if authURL == "" {
var err error
server, err = oauth.StartCallbackServer(handler, metadata)
if err != nil {
return fmt.Errorf("failed to start callback server: %w", err)
}
return nil
}
// Second call (authURL populated): display URL and open browser
fmt.Printf("Opening browser to: %s\n", authURL)
if err := oauth.OpenBrowser(authURL); err != nil {
fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL)
}
return nil
},
)
if err != nil {
return nil, err
}
// Shutdown ephemeral server
if server != nil {
defer server.Shutdown(context.Background())
}
fmt.Println("Authorization successful!")
// Resolve handle to get DID
resolver := atproto.NewResolver()
did, _, err := resolver.ResolveIdentity(context.Background(), handle)
if err != nil {
return nil, fmt.Errorf("failed to resolve DID: %w", err)
}
// Create token store
store := &oauth.TokenStore{
AccessToken: result.Token.AccessToken,
RefreshToken: result.Token.RefreshToken,
TokenType: result.Token.TokenType,
ExpiresAt: result.Token.Expiry,
Handle: handle,
DID: did,
}
// Save DPoP key
if err := store.SetDPoPKey(result.Client.DPoPKey()); err != nil {
return nil, fmt.Errorf("failed to save DPoP key: %w", err)
}
return store, nil
}
+20 -2
View File
@@ -1,7 +1,9 @@
package main
import (
"fmt"
"os"
"time"
"github.com/distribution/distribution/v3/registry"
_ "github.com/distribution/distribution/v3/registry/auth/token"
@@ -11,12 +13,28 @@ import (
// Register our custom middleware
_ "atcr.io/pkg/middleware"
"atcr.io/pkg/auth/exchange"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/session"
"atcr.io/pkg/auth/token"
"atcr.io/pkg/middleware"
)
func main() {
// Use distribution's built-in CLI
// Our middleware will be automatically registered via init()
// The serve command is registered in serve.go via init()
// Just execute the root command
if err := registry.RootCmd.Execute(); err != nil {
os.Exit(1)
}
}
// Suppress unused import warnings
var _ = fmt.Sprint
var _ = os.Stdout
var _ = time.Now
var _ = oauth.NewRefresher
var _ = session.NewManager
var _ = token.NewIssuer
var _ = exchange.NewHandler
var _ = middleware.SetGlobalRefresher
+107 -4
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -15,7 +16,10 @@ import (
"github.com/spf13/cobra"
"atcr.io/pkg/auth/exchange"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/session"
"atcr.io/pkg/auth/token"
"atcr.io/pkg/middleware"
)
var serveCmd = &cobra.Command{
@@ -51,7 +55,86 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to parse configuration: %w", err)
}
// Initialize auth keys if needed
// Initialize OAuth components
fmt.Println("Initializing OAuth components...")
// 1. Create refresh token storage
// Allow override via environment variable for Docker deployments
storagePath := os.Getenv("ATCR_TOKEN_STORAGE_PATH")
if storagePath == "" {
var err error
storagePath, err = oauth.GetDefaultPath()
if err != nil {
return fmt.Errorf("failed to get storage path: %w", err)
}
}
// Ensure directory exists
storageDir := filepath.Dir(storagePath)
if err := os.MkdirAll(storageDir, 0700); err != nil {
return fmt.Errorf("failed to create storage directory: %w", err)
}
fmt.Printf("Using token storage path: %s\n", storagePath)
refreshStorage, err := oauth.NewRefreshTokenStorage(storagePath)
if err != nil {
return fmt.Errorf("failed to create refresh token storage: %w", err)
}
// 2. Create session manager with 30-day TTL
// Use persistent secret so session tokens remain valid across container restarts
secretPath := os.Getenv("ATCR_SESSION_SECRET_PATH")
if secretPath == "" {
// Default to same directory as tokens
secretPath = filepath.Join(filepath.Dir(storagePath), "session-secret.key")
}
sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour)
if err != nil {
return fmt.Errorf("failed to create session manager: %w", err)
}
// 3. Get base URL from config or environment
baseURL := os.Getenv("ATCR_BASE_URL")
if baseURL == "" {
// If addr is just a port (e.g., ":5000"), prepend localhost
addr := config.HTTP.Addr
if addr[0] == ':' {
baseURL = fmt.Sprintf("http://127.0.0.1%s", addr)
} else {
baseURL = fmt.Sprintf("http://%s", addr)
}
}
fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL)
// 4. Get client ID from config
clientIDConfig := oauth.ClientIDConfig{
BaseURL: baseURL,
CallbackPath: "/auth/oauth/callback",
Scopes: []string{"atproto"},
}
clientID, redirectURI := clientIDConfig.MakeClientID()
fmt.Printf("DEBUG: Client ID: %s\n", clientID)
fmt.Printf("DEBUG: Redirect URI: %s\n", redirectURI)
// 5. Create refresher
refresher := oauth.NewRefresher(refreshStorage, clientID, redirectURI)
// Start cleanup routine (runs every hour)
refresher.StartCleanupRoutine(1 * time.Hour)
// 6. Set global refresher for middleware
middleware.SetGlobalRefresher(refresher)
// 7. Create client metadata (only needed for production, not localhost)
// For localhost, client metadata is embedded in the client_id query string
// clientMetadata := oauth.NewClientMetadata(clientID, []string{redirectURI})
// 8. Create OAuth server
oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL)
// 9. Initialize auth keys and create token issuer
var issuer *token.Issuer
if config.Auth["token"] != nil {
if err := initializeAuthKeys(config); err != nil {
@@ -75,17 +158,37 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Mount registry at /v2/
mux.Handle("/v2/", app)
// Mount OAuth endpoints
mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize)
mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback)
// Start OAuth server cleanup routine
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
oauthServer.CleanupExpiredStates()
}
}()
// Mount auth endpoints if enabled
if issuer != nil {
// Extract default hold endpoint from middleware config
defaultHoldEndpoint := extractDefaultHoldEndpoint(config)
tokenHandler := token.NewHandler(issuer, defaultHoldEndpoint)
// Basic Auth token endpoint (also supports session tokens)
tokenHandler := token.NewHandler(issuer, sessionManager, defaultHoldEndpoint)
tokenHandler.RegisterRoutes(mux)
exchangeHandler := exchange.NewHandler(issuer, defaultHoldEndpoint)
// OAuth exchange endpoint (session token → registry JWT)
exchangeHandler := exchange.NewHandler(issuer, sessionManager)
exchangeHandler.RegisterRoutes(mux)
fmt.Println("Auth endpoints enabled at /auth/token and /auth/exchange")
fmt.Printf("Auth endpoints enabled:\n")
fmt.Printf(" - Basic Auth: /auth/token\n")
fmt.Printf(" - OAuth: /auth/oauth/authorize\n")
fmt.Printf(" - OAuth: /auth/oauth/callback\n")
fmt.Printf(" - Exchange: /auth/exchange\n")
}
// Create HTTP server
+7 -1
View File
@@ -7,9 +7,13 @@ services:
container_name: atcr-registry
ports:
- "5000:5000"
environment:
- ATCR_TOKEN_STORAGE_PATH=/var/lib/atcr/tokens/oauth-tokens.json
volumes:
# Only auth keys (could be moved to secrets in production)
# Auth keys (JWT signing keys)
- atcr-auth:/var/lib/atcr/auth
# OAuth refresh tokens (persists user sessions across container restarts)
- atcr-tokens:/var/lib/atcr/tokens
restart: unless-stopped
networks:
atcr-network:
@@ -17,6 +21,7 @@ services:
# The registry should be stateless - all storage is external:
# - Manifests/Tags -> ATProto PDS
# - Blobs/Layers -> Hold service
# - OAuth tokens -> Persistent volume (atcr-tokens)
# Future: Add read_only: true for production deployments
hold:
@@ -51,3 +56,4 @@ networks:
volumes:
atcr-hold:
atcr-auth:
atcr-tokens:
+434
View File
@@ -0,0 +1,434 @@
# AppView-Mediated OAuth Architecture
## Overview
ATCR uses a two-tier authentication model to support OAuth while allowing the AppView to write manifests to users' Personal Data Servers (PDS).
## The Problem
OAuth with DPoP creates cryptographically bound tokens that cannot be delegated:
- **Basic Auth**: App password is a shared secret that can be forwarded from client → AppView → PDS ✅
- **OAuth + DPoP**: Token is bound to client's keypair and cannot be reused by AppView ❌
This creates a challenge: How can the AppView write manifests to the user's PDS on their behalf?
## The Solution: Two-Tier Authentication
```
┌──────────┐ ┌─────────┐ ┌────────────┐
│ Docker │◄───────►│ AppView │◄───────►│ PDS/Auth │
│ Client │ Auth1 │ (ATCR) │ Auth2 │ Server │
└──────────┘ └─────────┘ └────────────┘
```
**Auth Tier 1** (Docker ↔ AppView): Registry authentication
- Client authenticates to AppView using session tokens
- AppView issues short-lived registry JWTs
- Standard Docker registry auth protocol
**Auth Tier 2** (AppView ↔ PDS): Resource access
- AppView acts as OAuth client for each user
- AppView stores refresh tokens per user
- AppView gets access tokens on-demand to write manifests
## Complete Flows
### One-Time Authorization Flow
```
┌────────┐ ┌──────────────┐ ┌─────────┐ ┌─────┐
│ User │ │ Credential │ │ AppView │ │ PDS │
│ │ │ Helper │ │ │ │ │
└───┬────┘ └──────┬───────┘ └────┬────┘ └──┬──┘
│ │ │ │
│ $ docker-credential-atcr configure │ │
│ Enter handle: evan.jarrett.net │ │
│─────────────────────>│ │ │
│ │ │ │
│ │ GET /auth/oauth/authorize?handle=... │
│ │─────────────────────>│ │
│ │ │ │
│ │ 302 Redirect to PDS │ │
│ │<─────────────────────│ │
│ │ │ │
│ [Browser opens] │ │ │
│<─────────────────────│ │ │
│ │ │ │
│ Authorize ATCR? │ │ │
│──────────────────────────────────────────────────────────────>│
│ │ │ │
│ │ │<─code────────────│
│ │ │ │
│ │ │ POST /token │
│ │ │ (exchange code) │
│ │ │ + DPoP proof │
│ │ │─────────────────>│
│ │ │ │
│ │ │<─refresh_token───│
│ │ │ access_token │
│ │ │ │
│ │ │ [Store tokens] │
│ │ │ DID → { │
│ │ │ refresh_token, │
│ │ │ dpop_key, │
│ │ │ pds_endpoint │
│ │ │ } │
│ │ │ │
│ │<─session_token───────│ │
│ │ │ │
│ [Store session] │ │ │
│<─────────────────────│ │ │
│ ~/.atcr/ │ │ │
│ session.json │ │ │
│ │ │ │
│ ✓ Authorization │ │ │
│ complete! │ │ │
│ │ │ │
```
### Docker Push Flow (Every Push)
```
┌────────┐ ┌──────────┐ ┌─────────┐ ┌─────┐
│ Docker │ │ Cred │ │ AppView │ │ PDS │
│ │ │ Helper │ │ │ │ │
└───┬────┘ └────┬─────┘ └────┬────┘ └──┬──┘
│ │ │ │
│ docker push │ │ │
│──────────────>│ │ │
│ │ │ │
│ │ GET /auth/exchange │
│ │ Authorization: Bearer │
│ │ <session_token> │
│ │──────────────>│ │
│ │ │ │
│ │ │ [Validate │
│ │ │ session] │
│ │ │ │
│ │ │ [Issue JWT] │
│ │ │ │
│ │<──registry_jwt─│ │
│ │ │ │
│<─registry_jwt─│ │ │
│ │ │ │
│ PUT /v2/.../manifests/... │ │
│ Authorization: Bearer │ │
│ <registry_jwt> │ │
│──────────────────────────────>│ │
│ │ │
│ │ [Validate │
│ │ JWT] │
│ │ │
│ │ [Get fresh │
│ │ access │
│ │ token] │
│ │ │
│ │ POST /token │
│ │ (refresh) │
│ │ + DPoP │
│ │────────────>│
│ │ │
│ │<access_token│
│ │ │
│ │ PUT record │
│ │ (manifest) │
│ │ + DPoP │
│ │────────────>│
│ │ │
│ │<──201 OK────│
│ │ │
│<──────────201 OK──────────────│ │
│ │ │
```
## Components
### 1. OAuth Authorization Server (AppView)
**File**: `pkg/auth/oauth/server.go`
**Endpoints**:
#### `GET /auth/oauth/authorize`
Initiates OAuth flow for a user.
**Query Parameters**:
- `handle` (required): User's ATProto handle (e.g., `evan.jarrett.net`)
**Flow**:
1. Resolve handle → DID → PDS endpoint
2. Discover PDS OAuth metadata
3. Generate state + PKCE verifier
4. Create PAR request to PDS
5. Redirect user to PDS authorization endpoint
**Response**: `302 Redirect` to PDS authorization page
#### `GET /auth/oauth/callback`
Receives OAuth callback from PDS.
**Query Parameters**:
- `code`: Authorization code
- `state`: State for CSRF protection
**Flow**:
1. Validate state
2. Exchange code for tokens (POST to PDS token endpoint)
3. Use AppView's DPoP key for the exchange
4. Store refresh token + DPoP key for user's DID
5. Generate AppView session token
6. Redirect to success page with session token
**Response**: HTML page with session token (user copies to credential helper)
### 2. Refresh Token Storage
**File**: `pkg/auth/oauth/storage.go`
**Storage Format**:
```json
{
"refresh_tokens": {
"did:plc:abc123": {
"refresh_token": "...",
"dpop_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...",
"pds_endpoint": "https://bsky.social",
"handle": "evan.jarrett.net",
"created_at": "2025-10-04T...",
"last_refreshed": "2025-10-04T..."
}
}
}
```
**Location**:
- Development: `~/.atcr/appview-tokens.json`
- Production: Encrypted database or secret manager
**Security**:
- File permissions: `0600` (owner read/write only)
- Consider encrypting DPoP keys at rest
- Rotate refresh tokens periodically
### 3. Token Refresher
**File**: `pkg/auth/oauth/refresher.go`
**Interface**:
```go
type Refresher interface {
// GetAccessToken gets a fresh access token for a DID
// Returns cached token if still valid, otherwise refreshes
GetAccessToken(ctx context.Context, did string) (token string, dpopKey *ecdsa.PrivateKey, err error)
// RefreshToken forces a token refresh
RefreshToken(ctx context.Context, did string) error
// RevokeToken removes stored refresh token
RevokeToken(did string) error
}
```
**Caching Strategy**:
- Access tokens cached for 14 minutes (expire at 15min)
- Refresh tokens stored persistently
- Cache key: `did → {access_token, dpop_key, expires_at}`
### 4. Session Management
**File**: `pkg/auth/session/handler.go`
**Session Token Format**:
```
Base64(JSON({
"did": "did:plc:abc123",
"handle": "evan.jarrett.net",
"issued_at": "2025-10-04T...",
"expires_at": "2025-11-03T..." // 30 days
})).HMAC-SHA256(secret)
```
**Storage**: Stateless (validated by HMAC signature)
**Endpoints**:
#### `GET /auth/session/validate`
Validates a session token.
**Headers**:
- `Authorization: Bearer <session_token>`
**Response**:
```json
{
"did": "did:plc:abc123",
"handle": "evan.jarrett.net",
"valid": true
}
```
### 5. Updated Exchange Handler
**File**: `pkg/auth/exchange/handler.go`
**Changes**:
- Accept session token instead of OAuth token
- Validate session token → extract DID
- Issue registry JWT with DID
- Remove PDS token validation
**Request**:
```
POST /auth/exchange
Authorization: Bearer <session_token>
{
"scope": ["repository:*:pull,push"]
}
```
**Response**:
```json
{
"token": "<registry-jwt>",
"expires_in": 900
}
```
### 6. Credential Helper Updates
**File**: `cmd/credential-helper/main.go`
**Changes**:
1. **Configure command**:
- Open browser to AppView: `http://127.0.0.1:5000/auth/oauth/authorize?handle=...`
- User authorizes on PDS
- AppView displays session token
- User copies session token to helper
- Helper stores session token
2. **Get command**:
- Load session token from `~/.atcr/session.json`
- Call `/auth/exchange` with session token
- Return registry JWT to Docker
3. **Storage format**:
```json
{
"session_token": "...",
"handle": "evan.jarrett.net",
"appview_url": "http://127.0.0.1:5000"
}
```
**Removed**:
- DPoP key generation
- OAuth client logic
- Refresh token handling
## Security Considerations
### AppView as Trusted Component
The AppView becomes a **trusted intermediary** that:
- Stores refresh tokens for users
- Acts on users' behalf to write manifests
- Issues registry authentication tokens
**Trust model**:
- Users must trust the AppView operator
- Similar to trusting a Docker registry operator
- AppView has write access to manifests (not profile data)
### Scope Limitations
AppView OAuth tokens are requested with minimal scopes:
- `atproto` - Basic ATProto operations
- Only needs: `com.atproto.repo.putRecord`, `com.atproto.repo.getRecord`
- Does NOT need: profile updates, social graph access, etc.
### Token Security
**Refresh Tokens**:
- Stored encrypted at rest
- File permissions: 0600
- Rotated periodically (when used)
- Can be revoked by user on PDS
**Session Tokens**:
- 30-day expiry
- HMAC-signed (stateless validation)
- Can be revoked by clearing storage
**Access Tokens**:
- Cached in-memory only
- 15-minute expiry
- Never stored persistently
### Audit Trail
AppView should log:
- OAuth authorizations (DID, timestamp)
- Token refreshes (DID, timestamp)
- Manifest writes (DID, repository, timestamp)
## Migration from Current OAuth
Users currently using `docker-credential-atcr` with direct PDS OAuth will need to:
1. Run `docker-credential-atcr configure` again
2. Authorize AppView (new OAuth flow)
3. Old PDS tokens are no longer used
## Alternative: Bring Your Own AppView
Users who don't trust a shared AppView can:
1. Run their own ATCR AppView instance
2. Configure credential helper to point at their AppView
3. Their AppView stores their refresh tokens locally
## Future Enhancements
### Multi-AppView Support
Allow users to configure multiple AppViews:
```json
{
"appviews": {
"default": "https://atcr.io",
"personal": "http://localhost:5000"
},
"sessions": {
"https://atcr.io": {"session_token": "...", "handle": "..."},
"http://localhost:5000": {"session_token": "...", "handle": "..."}
}
}
```
### Refresh Token Rotation
Implement automatic refresh token rotation per OAuth best practices:
- PDS issues new refresh token with each use
- AppView updates stored token
- Old refresh token invalidated
### Revocation UI
Add web UI for users to:
- View active sessions
- Revoke AppView access
- See audit log of manifest writes
## References
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
+17 -6
View File
@@ -33,7 +33,8 @@ type SessionInfo struct {
// ValidateToken validates an ATProto OAuth access token by calling getSession
// Returns the user's DID and handle if the token is valid
func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken string) (*SessionInfo, error) {
// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) {
// Call com.atproto.server.getSession with the access token
url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint)
@@ -42,26 +43,35 @@ func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessT
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add bearer token
// Always use Bearer auth for getSession validation
// The DPoP proof from the client is bound to their request to us (POST /auth/exchange),
// not to our request to the PDS (GET /getSession)
req.Header.Set("Authorization", "Bearer "+accessToken)
fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n",
url, accessToken[:min(20, len(accessToken))])
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
defer resp.Body.Close()
// Read body once for both logging and error handling
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes))
return nil, fmt.Errorf("invalid or expired token")
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes))
return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var session SessionInfo
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
if err := json.Unmarshal(bodyBytes, &session); err != nil {
return nil, fmt.Errorf("failed to decode session: %w", err)
}
@@ -77,7 +87,8 @@ func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessT
}
// ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint
func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken string) (*SessionInfo, error) {
// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) {
// Resolve handle to PDS endpoint
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle)
@@ -86,5 +97,5 @@ func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle,
}
// Validate token against the PDS
return v.ValidateToken(ctx, pdsEndpoint, accessToken)
return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof)
}
+39 -57
View File
@@ -4,34 +4,30 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/atproto"
"atcr.io/pkg/auth/session"
"atcr.io/pkg/auth/token"
)
// Handler handles /auth/exchange requests (OAuth token -> JWT token)
// Handler handles /auth/exchange requests (session token -> registry JWT)
type Handler struct {
issuer *token.Issuer
validator *atproto.TokenValidator
defaultHoldEndpoint string
issuer *token.Issuer
sessionManager *session.Manager
}
// NewHandler creates a new exchange handler
func NewHandler(issuer *token.Issuer, defaultHoldEndpoint string) *Handler {
func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewTokenValidator(),
defaultHoldEndpoint: defaultHoldEndpoint,
issuer: issuer,
sessionManager: sessionManager,
}
}
// ExchangeRequest represents the request to exchange an OAuth token
// ExchangeRequest represents the request to exchange a session token for registry JWT
type ExchangeRequest struct {
AccessToken string `json:"access_token"` // ATProto OAuth access token
Handle string `json:"handle"` // User's handle (required for PDS resolution)
Scope []string `json:"scope"` // Requested Docker scopes
Scope []string `json:"scope"` // Requested Docker scopes
}
// ExchangeResponse represents the response from /auth/exchange
@@ -48,34 +44,38 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Extract session token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "authorization header required", http.StatusUnauthorized)
return
}
// Parse Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
return
}
sessionToken := parts[1]
// Validate session token
sessionClaims, err := h.sessionManager.Validate(sessionToken)
if err != nil {
fmt.Printf("DEBUG [exchange]: session validation failed: %v\n", err)
http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusUnauthorized)
return
}
fmt.Printf("DEBUG [exchange]: session validated for DID=%s, handle=%s\n", sessionClaims.DID, sessionClaims.Handle)
// Parse request body for scopes
var req ExchangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
if req.AccessToken == "" {
http.Error(w, "access_token is required", http.StatusBadRequest)
return
}
// Validate the ATProto OAuth token via the PDS
// We need the handle to resolve the PDS endpoint
if req.Handle == "" {
http.Error(w, "handle required to validate token", http.StatusBadRequest)
return
}
session, err := h.validator.ValidateTokenWithResolver(r.Context(), req.Handle, req.AccessToken)
if err != nil {
http.Error(w, fmt.Sprintf("token validation failed: %v", err), http.StatusUnauthorized)
return
}
// Use DID and handle from validated session
did := session.DID
handle := session.Handle
// Parse and validate scopes
access, err := auth.ParseScope(req.Scope)
if err != nil {
@@ -84,31 +84,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Validate access permissions
if err := auth.ValidateAccess(did, handle, access); err != nil {
if err := auth.ValidateAccess(sessionClaims.DID, sessionClaims.Handle, access); err != nil {
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
return
}
// Ensure user profile exists (creates with default hold if needed)
// Resolve PDS endpoint for profile management
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), handle)
if err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
} else {
// Create ATProto client with validated token
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, req.AccessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
}
}
// Issue JWT token
tokenString, err := h.issuer.Issue(did, access)
// Issue registry JWT token
tokenString, err := h.issuer.Issue(sessionClaims.DID, access)
if err != nil {
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
return
+14 -2
View File
@@ -58,8 +58,10 @@ func (c *Client) InitializeForHandle(ctx context.Context, handle string) error {
c.metadata = metadata
// Configure OAuth2 client with default scope
// Can be overridden with SetScopes() before calling AuthorizeURL()
// Configure OAuth2 client
// Note: Both localhost and production need redirect_uri and scopes in the config
// For localhost: client_id contains these (query-based) AND they're sent as params
// For production: client_id is metadata URL, params come from config
c.config = &oauth2.Config{
ClientID: c.clientID,
Endpoint: oauth2.Endpoint{
@@ -115,6 +117,14 @@ func (c *Client) AuthorizeURL(state string) (authURL string, codeVerifier string
// authorizeURLWithPAR uses Pushed Authorization Request
func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) {
fmt.Printf("DEBUG [oauth/client]: Starting PAR request\n")
fmt.Printf("DEBUG [oauth/client]: - client_id: %s\n", c.config.ClientID)
fmt.Printf("DEBUG [oauth/client]: - redirect_uri: %s\n", c.config.RedirectURL)
fmt.Printf("DEBUG [oauth/client]: - scope: %v\n", c.config.Scopes)
fmt.Printf("DEBUG [oauth/client]: - state: %s\n", state)
fmt.Printf("DEBUG [oauth/client]: - code_challenge_method: S256\n")
fmt.Printf("DEBUG [oauth/client]: - PAR endpoint: %s\n", c.config.Endpoint.PushedAuthURL)
// Create HTTP client with DPoP transport
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{
Transport: c.dpopTransport,
@@ -126,9 +136,11 @@ func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
if err != nil {
fmt.Printf("ERROR [oauth/client]: PAR request failed: %v\n", err)
return "", err
}
fmt.Printf("DEBUG [oauth/client]: PAR successful, authURL: %s\n", authURL.String())
return authURL.String(), nil
}
+167
View File
@@ -0,0 +1,167 @@
package oauth
import (
"context"
"crypto/ecdsa"
"fmt"
"net/http"
"sync"
"time"
"authelia.com/client/oauth2"
)
// AccessTokenEntry represents a cached access token
type AccessTokenEntry struct {
Token string
DPoPKey *ecdsa.PrivateKey
ExpiresAt time.Time
}
// Refresher manages OAuth token refresh for AppView
type Refresher struct {
storage *RefreshTokenStorage
accessTokens map[string]*AccessTokenEntry
mu sync.RWMutex
clientID string
redirectURI string
}
// NewRefresher creates a new token refresher
func NewRefresher(storage *RefreshTokenStorage, clientID, redirectURI string) *Refresher {
return &Refresher{
storage: storage,
accessTokens: make(map[string]*AccessTokenEntry),
clientID: clientID,
redirectURI: redirectURI,
}
}
// GetAccessToken gets a fresh access token for a DID
// Returns cached token if still valid, otherwise refreshes
func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) {
// Check cache first
r.mu.RLock()
entry, ok := r.accessTokens[did]
r.mu.RUnlock()
if ok && time.Now().Before(entry.ExpiresAt) {
// Token still valid
return entry.Token, entry.DPoPKey, nil
}
// Token expired or not cached, refresh it
return r.RefreshToken(ctx, did)
}
// RefreshToken forces a token refresh for a DID
func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) {
// Get stored refresh token
entry, err := r.storage.Get(did)
if err != nil {
return "", nil, fmt.Errorf("failed to get stored refresh token: %w", err)
}
// Parse DPoP key
dpopKey, err := r.storage.GetDPoPKey(did)
if err != nil {
return "", nil, fmt.Errorf("failed to get DPoP key: %w", err)
}
// Create OAuth client with DPoP transport
dpopTransport := NewDPoPTransport(http.DefaultTransport, dpopKey)
httpClient := &http.Client{Transport: dpopTransport}
// Discover PDS OAuth metadata
metadata, err := DiscoverAuthServer(ctx, entry.PDS)
if err != nil {
return "", nil, fmt.Errorf("failed to discover auth server: %w", err)
}
// Configure OAuth2 client
config := &oauth2.Config{
ClientID: r.clientID,
Endpoint: oauth2.Endpoint{
AuthURL: metadata.AuthorizationEndpoint,
TokenURL: metadata.TokenEndpoint,
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
},
RedirectURL: r.redirectURI,
Scopes: []string{"atproto"},
}
// Create context with custom HTTP client
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Exchange refresh token for new access token
token, err := config.TokenSource(ctxWithClient, &oauth2.Token{
RefreshToken: entry.RefreshToken,
}).Token()
if err != nil {
return "", nil, fmt.Errorf("failed to refresh token: %w", err)
}
// Update last refresh timestamp
if err := r.storage.UpdateLastRefresh(did); err != nil {
// Log but don't fail - this is not critical
fmt.Printf("WARNING: failed to update last refresh timestamp for %s: %v\n", did, err)
}
// If a new refresh token was issued, update storage
if token.RefreshToken != "" && token.RefreshToken != entry.RefreshToken {
entry.RefreshToken = token.RefreshToken
if err := r.storage.Store(did, entry); err != nil {
// Log but don't fail - we have the access token
fmt.Printf("WARNING: failed to update refresh token for %s: %v\n", did, err)
}
}
// Cache the access token
// Expire 1 minute early to avoid edge cases
expiresAt := token.Expiry.Add(-1 * time.Minute)
r.mu.Lock()
r.accessTokens[did] = &AccessTokenEntry{
Token: token.AccessToken,
DPoPKey: dpopKey,
ExpiresAt: expiresAt,
}
r.mu.Unlock()
return token.AccessToken, dpopKey, nil
}
// RevokeToken removes stored refresh token and cached access token
func (r *Refresher) RevokeToken(did string) error {
r.mu.Lock()
delete(r.accessTokens, did)
r.mu.Unlock()
return r.storage.Delete(did)
}
// CleanupExpiredTokens removes expired access tokens from cache
// Should be called periodically (e.g., every hour)
func (r *Refresher) CleanupExpiredTokens() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
for did, entry := range r.accessTokens {
if now.After(entry.ExpiresAt) {
delete(r.accessTokens, did)
}
}
}
// StartCleanupRoutine starts a background goroutine to cleanup expired tokens
func (r *Refresher) StartCleanupRoutine(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
r.CleanupExpiredTokens()
}
}()
}
+342
View File
@@ -0,0 +1,342 @@
package oauth
import (
"context"
"crypto/ecdsa"
"crypto/rand"
"fmt"
"html/template"
"net/http"
"sync"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/session"
"authelia.com/client/oauth2"
)
// Server handles OAuth authorization for the AppView
type Server struct {
storage *RefreshTokenStorage
sessionManager *session.Manager
resolver *atproto.Resolver
clientID string
redirectURI string
baseURL string
states map[string]*OAuthState
statesMu sync.RWMutex
}
// OAuthState tracks an in-progress OAuth flow
type OAuthState struct {
State string
Handle string
DID string
PDSEndpoint string
CodeVerifier string
DPoPKey *ecdsa.PrivateKey
CreatedAt time.Time
}
// NewServer creates a new OAuth server
func NewServer(storage *RefreshTokenStorage, sessionManager *session.Manager, baseURL string) *Server {
// Create client ID based on AppView's base URL
cfg := ClientIDConfig{
BaseURL: baseURL,
CallbackPath: "/auth/oauth/callback",
Scopes: []string{"atproto"},
}
clientID, redirectURI := cfg.MakeClientID()
return &Server{
storage: storage,
sessionManager: sessionManager,
resolver: atproto.NewResolver(),
clientID: clientID,
redirectURI: redirectURI,
baseURL: baseURL,
states: make(map[string]*OAuthState),
}
}
// ServeAuthorize handles GET /auth/oauth/authorize
func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Get handle from query parameter
handle := r.URL.Query().Get("handle")
if handle == "" {
http.Error(w, "handle parameter required", http.StatusBadRequest)
return
}
fmt.Printf("DEBUG [oauth/server]: Starting OAuth flow for handle=%s\n", handle)
// Resolve handle to DID and PDS
did, pdsEndpoint, err := s.resolver.ResolveIdentity(r.Context(), handle)
if err != nil {
fmt.Printf("ERROR [oauth/server]: Failed to resolve handle: %v\n", err)
http.Error(w, fmt.Sprintf("failed to resolve handle: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("DEBUG [oauth/server]: Resolved handle=%s -> did=%s, pds=%s\n", handle, did, pdsEndpoint)
// Create OAuth client
fmt.Printf("DEBUG [oauth/server]: Creating OAuth client with clientID=%s, redirectURI=%s\n", s.clientID, s.redirectURI)
client, err := NewClient(s.clientID, s.redirectURI)
if err != nil {
fmt.Printf("ERROR [oauth/server]: Failed to create OAuth client: %v\n", err)
http.Error(w, fmt.Sprintf("failed to create OAuth client: %v", err), http.StatusInternalServerError)
return
}
// Initialize for the handle's PDS
fmt.Printf("DEBUG [oauth/server]: Initializing OAuth client for handle=%s\n", handle)
if err := client.InitializeForHandle(r.Context(), handle); err != nil {
fmt.Printf("ERROR [oauth/server]: Failed to initialize OAuth: %v\n", err)
http.Error(w, fmt.Sprintf("failed to initialize OAuth: %v", err), http.StatusInternalServerError)
return
}
// Generate authorization URL
state := generateState()
fmt.Printf("DEBUG [oauth/server]: Generating authorization URL with state=%s\n", state)
authURL, codeVerifier, err := client.AuthorizeURL(state)
if err != nil {
fmt.Printf("ERROR [oauth/server]: Failed to generate auth URL: %v\n", err)
http.Error(w, fmt.Sprintf("failed to generate auth URL: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("DEBUG [oauth/server]: Generated authURL=%s\n", authURL)
// Store state for callback
s.statesMu.Lock()
s.states[state] = &OAuthState{
State: state,
Handle: handle,
DID: did,
PDSEndpoint: pdsEndpoint,
CodeVerifier: codeVerifier,
DPoPKey: client.dpopKey,
CreatedAt: time.Now(),
}
s.statesMu.Unlock()
// Redirect to PDS authorization page
http.Redirect(w, r, authURL, http.StatusFound)
}
// ServeCallback handles GET /auth/oauth/callback
func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Get code and state from query parameters
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
s.renderError(w, "Missing code or state parameter")
return
}
// Retrieve OAuth state
s.statesMu.Lock()
oauthState, ok := s.states[state]
delete(s.states, state) // Consume state
s.statesMu.Unlock()
if !ok {
s.renderError(w, "Invalid or expired state")
return
}
// Exchange code for tokens
sessionToken, err := s.exchangeCodeForSession(r.Context(), code, oauthState)
if err != nil {
s.renderError(w, fmt.Sprintf("Failed to exchange code: %v", err))
return
}
// Render success page with session token
s.renderSuccess(w, sessionToken, oauthState.Handle)
}
// exchangeCodeForSession exchanges authorization code for tokens and creates session
func (s *Server) exchangeCodeForSession(ctx context.Context, code string, state *OAuthState) (string, error) {
// Discover OAuth metadata
metadata, err := DiscoverAuthServer(ctx, state.PDSEndpoint)
if err != nil {
return "", fmt.Errorf("failed to discover auth server: %w", err)
}
// Create DPoP transport
dpopTransport := NewDPoPTransport(http.DefaultTransport, state.DPoPKey)
httpClient := &http.Client{Transport: dpopTransport}
// Configure OAuth2 client
config := &oauth2.Config{
ClientID: s.clientID,
Endpoint: oauth2.Endpoint{
AuthURL: metadata.AuthorizationEndpoint,
TokenURL: metadata.TokenEndpoint,
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
},
RedirectURL: s.redirectURI,
Scopes: []string{"atproto"},
}
// Create context with custom HTTP client
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
// Exchange code for token
token, err := config.Exchange(ctxWithClient, code, oauth2.VerifierOption(state.CodeVerifier))
if err != nil {
return "", fmt.Errorf("failed to exchange code: %w", err)
}
// Encode DPoP key to PEM
dpopKeyPEM, err := EncodeDPoPKey(state.DPoPKey)
if err != nil {
return "", fmt.Errorf("failed to encode DPoP key: %w", err)
}
// Store refresh token
refreshEntry := &RefreshTokenEntry{
RefreshToken: token.RefreshToken,
DPoPKeyPEM: dpopKeyPEM,
PDS: state.PDSEndpoint,
Handle: state.Handle,
CreatedAt: time.Now(),
LastRefresh: time.Now(),
}
if err := s.storage.Store(state.DID, refreshEntry); err != nil {
return "", fmt.Errorf("failed to store refresh token: %w", err)
}
// Create session token for credential helper
sessionToken, err := s.sessionManager.Create(state.DID, state.Handle)
if err != nil {
return "", fmt.Errorf("failed to create session token: %w", err)
}
return sessionToken, nil
}
// renderSuccess renders the success page
func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) {
tmpl := template.Must(template.New("success").Parse(successTemplate))
data := struct {
SessionToken string
Handle string
}{
SessionToken: sessionToken,
Handle: handle,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, "failed to render template", http.StatusInternalServerError)
}
}
// renderError renders an error page
func (s *Server) renderError(w http.ResponseWriter, message string) {
tmpl := template.Must(template.New("error").Parse(errorTemplate))
data := struct {
Message string
}{
Message: message,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, "failed to render template", http.StatusInternalServerError)
}
}
// CleanupExpiredStates removes expired OAuth states
// Should be called periodically
func (s *Server) CleanupExpiredStates() {
s.statesMu.Lock()
defer s.statesMu.Unlock()
now := time.Now()
for state, oauthState := range s.states {
// States expire after 10 minutes
if now.Sub(oauthState.CreatedAt) > 10*time.Minute {
delete(s.states, state)
}
}
}
// generateState generates a random state parameter
func generateState() string {
b := make([]byte, 32)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
// HTML templates
const successTemplate = `
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful - ATCR</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 5px; }
code { background: #f5f5f5; padding: 10px; display: block; margin: 10px 0; word-break: break-all; }
.copy-btn { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 5px; }
.copy-btn:hover { background: #0056b3; }
</style>
</head>
<body>
<div class="success">
<h1>✓ Authorization Successful!</h1>
<p>You have successfully authorized ATCR to access your ATProto account: <strong>{{.Handle}}</strong></p>
<p>Copy the session token below and paste it into your credential helper:</p>
<code id="token">{{.SessionToken}}</code>
<button class="copy-btn" onclick="copyToken()">Copy Token</button>
</div>
<script>
function copyToken() {
const token = document.getElementById('token').textContent;
navigator.clipboard.writeText(token).then(() => {
alert('Token copied to clipboard!');
});
}
</script>
</body>
</html>
`
const errorTemplate = `
<!DOCTYPE html>
<html>
<head>
<title>Authorization Failed - ATCR</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 5px; }
</style>
</head>
<body>
<div class="error">
<h1>✗ Authorization Failed</h1>
<p>{{.Message}}</p>
<p><a href="/">Return to home</a></p>
</div>
</body>
</html>
`
+200
View File
@@ -0,0 +1,200 @@
package oauth
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// RefreshTokenEntry represents a stored refresh token for a user
type RefreshTokenEntry struct {
RefreshToken string `json:"refresh_token"`
DPoPKeyPEM string `json:"dpop_key_pem"`
PDS string `json:"pds_endpoint"`
Handle string `json:"handle"`
CreatedAt time.Time `json:"created_at"`
LastRefresh time.Time `json:"last_refreshed"`
}
// RefreshTokenStorage manages persistent storage of refresh tokens
type RefreshTokenStorage struct {
path string
tokens map[string]*RefreshTokenEntry
mu sync.RWMutex
}
// StorageData represents the JSON structure stored on disk
type StorageData struct {
RefreshTokens map[string]*RefreshTokenEntry `json:"refresh_tokens"`
}
// NewRefreshTokenStorage creates a new refresh token storage
func NewRefreshTokenStorage(path string) (*RefreshTokenStorage, error) {
storage := &RefreshTokenStorage{
path: path,
tokens: make(map[string]*RefreshTokenEntry),
}
// Load existing tokens if file exists
if err := storage.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load tokens: %w", err)
}
// File doesn't exist yet, that's ok
}
return storage, nil
}
// GetDefaultPath returns the default storage path
func GetDefaultPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
atcrDir := filepath.Join(homeDir, ".atcr")
if err := os.MkdirAll(atcrDir, 0700); err != nil {
return "", fmt.Errorf("failed to create .atcr directory: %w", err)
}
return filepath.Join(atcrDir, "appview-tokens.json"), nil
}
// Store saves a refresh token for a DID
func (s *RefreshTokenStorage) Store(did string, entry *RefreshTokenEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
s.tokens[did] = entry
return s.save()
}
// Get retrieves a refresh token for a DID
func (s *RefreshTokenStorage) Get(did string) (*RefreshTokenEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.tokens[did]
if !ok {
return nil, fmt.Errorf("no refresh token found for DID: %s", did)
}
return entry, nil
}
// Delete removes a refresh token for a DID
func (s *RefreshTokenStorage) Delete(did string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.tokens, did)
return s.save()
}
// List returns all stored DIDs
func (s *RefreshTokenStorage) List() []string {
s.mu.RLock()
defer s.mu.RUnlock()
dids := make([]string, 0, len(s.tokens))
for did := range s.tokens {
dids = append(dids, did)
}
return dids
}
// GetDPoPKey retrieves and parses the DPoP private key for a DID
func (s *RefreshTokenStorage) GetDPoPKey(did string) (*ecdsa.PrivateKey, error) {
entry, err := s.Get(did)
if err != nil {
return nil, err
}
// Parse PEM encoded private key
block, _ := pem.Decode([]byte(entry.DPoPKeyPEM))
if block == nil {
return nil, fmt.Errorf("failed to parse PEM block")
}
// Parse EC private key
key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse EC private key: %w", err)
}
return key, nil
}
// UpdateLastRefresh updates the last refresh timestamp for a DID
func (s *RefreshTokenStorage) UpdateLastRefresh(did string) error {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.tokens[did]
if !ok {
return fmt.Errorf("no refresh token found for DID: %s", did)
}
entry.LastRefresh = time.Now()
return s.save()
}
// load reads tokens from disk
func (s *RefreshTokenStorage) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
var storageData StorageData
if err := json.Unmarshal(data, &storageData); err != nil {
return fmt.Errorf("failed to parse token storage: %w", err)
}
if storageData.RefreshTokens != nil {
s.tokens = storageData.RefreshTokens
}
return nil
}
// save writes tokens to disk
func (s *RefreshTokenStorage) save() error {
storageData := StorageData{
RefreshTokens: s.tokens,
}
data, err := json.MarshalIndent(storageData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal tokens: %w", err)
}
// Write with restrictive permissions
if err := os.WriteFile(s.path, data, 0600); err != nil {
return fmt.Errorf("failed to write tokens: %w", err)
}
return nil
}
// EncodeDPoPKey encodes an ECDSA private key to PEM format
func EncodeDPoPKey(key *ecdsa.PrivateKey) (string, error) {
keyBytes, err := x509.MarshalECPrivateKey(key)
if err != nil {
return "", fmt.Errorf("failed to marshal private key: %w", err)
}
block := &pem.Block{
Type: "EC PRIVATE KEY",
Bytes: keyBytes,
}
return string(pem.EncodeToMemory(block)), nil
}
+7
View File
@@ -57,6 +57,13 @@ func ValidateAccess(userDID, userHandle string, access []AccessEntry) error {
continue
}
// Allow wildcard scope (e.g., "repository:*:pull,push")
// This is used by Docker credential helpers to request broad permissions
// Actual authorization happens later when accessing specific repositories
if entry.Name == "*" {
continue
}
// Extract the owner from repository name (e.g., "alice/myapp" -> "alice")
parts := strings.SplitN(entry.Name, "/", 2)
if len(parts) < 1 {
+170
View File
@@ -0,0 +1,170 @@
package session
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// SessionClaims represents the data stored in a session token
type SessionClaims struct {
DID string `json:"did"`
Handle string `json:"handle"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// Manager handles session token creation and validation
type Manager struct {
secret []byte
ttl time.Duration
}
// NewManager creates a new session manager
func NewManager(secret []byte, ttl time.Duration) *Manager {
return &Manager{
secret: secret,
ttl: ttl,
}
}
// NewManagerWithRandomSecret creates a session manager with a random secret
func NewManagerWithRandomSecret(ttl time.Duration) (*Manager, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("failed to generate secret: %w", err)
}
return NewManager(secret, ttl), nil
}
// NewManagerWithPersistentSecret creates a session manager with a persistent secret
// The secret is stored at secretPath and reused across restarts
func NewManagerWithPersistentSecret(secretPath string, ttl time.Duration) (*Manager, error) {
var secret []byte
// Try to load existing secret
if data, err := os.ReadFile(secretPath); err == nil {
secret = data
fmt.Printf("Loaded existing session secret from %s\n", secretPath)
} else if os.IsNotExist(err) {
// Generate new secret
secret = make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("failed to generate secret: %w", err)
}
// Save secret for future restarts
if err := os.WriteFile(secretPath, secret, 0600); err != nil {
return nil, fmt.Errorf("failed to save secret: %w", err)
}
fmt.Printf("Generated and saved new session secret to %s\n", secretPath)
} else {
return nil, fmt.Errorf("failed to read secret file: %w", err)
}
return NewManager(secret, ttl), nil
}
// Create generates a new session token for a DID
func (m *Manager) Create(did, handle string) (string, error) {
now := time.Now()
claims := SessionClaims{
DID: did,
Handle: handle,
IssuedAt: now,
ExpiresAt: now.Add(m.ttl),
}
// Marshal claims to JSON
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("failed to marshal claims: %w", err)
}
// Base64 encode claims
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Generate HMAC signature
sig := m.sign(claimsB64)
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
// Token format: <claims>.<signature>
token := claimsB64 + "." + sigB64
return token, nil
}
// Validate validates a session token and returns the claims
func (m *Manager) Validate(token string) (*SessionClaims, error) {
// Split token into claims and signature
parts := strings.Split(token, ".")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid token format")
}
claimsB64 := parts[0]
sigB64 := parts[1]
// Verify signature
expectedSig := m.sign(claimsB64)
providedSig, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return nil, fmt.Errorf("invalid signature encoding: %w", err)
}
if !hmac.Equal(expectedSig, providedSig) {
return nil, fmt.Errorf("invalid signature")
}
// Decode claims
claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsB64)
if err != nil {
return nil, fmt.Errorf("invalid claims encoding: %w", err)
}
var claims SessionClaims
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
return nil, fmt.Errorf("invalid claims format: %w", err)
}
// Check expiration
if time.Now().After(claims.ExpiresAt) {
return nil, fmt.Errorf("token expired")
}
return &claims, nil
}
// sign generates HMAC-SHA256 signature for data
func (m *Manager) sign(data string) []byte {
h := hmac.New(sha256.New, m.secret)
h.Write([]byte(data))
return h.Sum(nil)
}
// GetDID extracts the DID from a token without full validation
// Useful for logging/debugging
func (m *Manager) GetDID(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return "", fmt.Errorf("invalid token format")
}
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("invalid claims encoding: %w", err)
}
var claims SessionClaims
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
return "", fmt.Errorf("invalid claims format: %w", err)
}
return claims.DID, nil
}
+50 -30
View File
@@ -10,20 +10,23 @@ import (
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/atproto"
"atcr.io/pkg/auth/session"
)
// Handler handles /auth/token requests
type Handler struct {
issuer *Issuer
validator *atproto.SessionValidator
sessionManager *session.Manager // For validating session tokens
defaultHoldEndpoint string
}
// NewHandler creates a new token handler
func NewHandler(issuer *Issuer, defaultHoldEndpoint string) *Handler {
func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewSessionValidator(),
sessionManager: sessionManager,
defaultHoldEndpoint: defaultHoldEndpoint,
}
}
@@ -73,43 +76,60 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Validate credentials against ATProto and get access token
fmt.Printf("DEBUG [token/handler]: Validating credentials for %s\n", username)
did, _, accessToken, err := h.validator.CreateSessionAndGetToken(r.Context(), username, password)
if err != nil {
fmt.Printf("DEBUG [token/handler]: Credential validation failed: %v\n", err)
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
var did string
var handle string
var accessToken string
fmt.Printf("DEBUG [token/handler]: Credentials validated successfully, DID=%s, AccessToken length=%d\n", did, len(accessToken))
// Cache the access token for later use (e.g., when pushing manifests)
// TTL of 2 hours (ATProto tokens typically last longer)
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did)
// Ensure user profile exists (creates with default hold if needed)
// Resolve PDS endpoint for profile management
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username)
if err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
// Try to validate as session token first (our OAuth flow)
// Session tokens have format: <base64_claims>.<base64_signature>
sessionClaims, sessionErr := h.sessionManager.Validate(password)
if sessionErr == nil {
// Successfully validated as session token
did = sessionClaims.DID
handle = sessionClaims.Handle
fmt.Printf("DEBUG [token/handler]: Session token validated for DID=%s, handle=%s\n", did, handle)
// For session tokens, we don't have a PDS access token here
// The registry will use OAuth refresh tokens to get one when needed
} else {
// Create ATProto client with validated token
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
// Not a session token, try app password (Basic Auth flow)
fmt.Printf("DEBUG [token/handler]: Not a session token, trying app password for %s\n", username)
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
if err != nil {
fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
fmt.Printf("DEBUG [token/handler]: App password validated successfully, DID=%s, handle=%s, AccessToken length=%d\n", did, handle, len(accessToken))
// Cache the access token for later use (e.g., when pushing manifests)
// TTL of 2 hours (ATProto tokens typically last longer)
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did)
// Ensure user profile exists (creates with default hold if needed)
// Resolve PDS endpoint for profile management
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username)
if err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
} else {
// Create ATProto client with validated token
atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
}
}
}
// Validate that the user has permission for the requested access
if err := auth.ValidateAccess(did, username, access); err != nil {
// Use the actual handle from the validated credentials, not the Basic Auth username
if err := auth.ValidateAccess(did, handle, access); err != nil {
fmt.Printf("DEBUG [token/handler]: Access validation failed: %v\n", err)
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
return
+36 -10
View File
@@ -13,9 +13,18 @@ import (
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/storage"
)
// Global refresher instance (set by main.go)
var globalRefresher *oauth.Refresher
// SetGlobalRefresher sets the global OAuth refresher instance
func SetGlobalRefresher(refresher *oauth.Refresher) {
globalRefresher = refresher
}
func init() {
// Register the name resolution middleware
registrymw.Register("atproto-resolver", initATProtoResolver)
@@ -99,18 +108,35 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
return nil, err
}
// Wrap the repository with our routing repository
// Get the cached access token for this DID
accessToken, ok := auth.GetGlobalTokenCache().Get(did)
if !ok {
fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s\n", did)
accessToken = "" // Will fail on manifest push, but let it try
} else {
fmt.Printf("DEBUG [registry/middleware]: Using cached access token for DID=%s (length=%d)\n", did, len(accessToken))
// Get access token for PDS operations
// Try OAuth refresher first (for users who authorized via AppView OAuth)
// Fall back to Basic Auth token cache (for users who used app passwords)
var atprotoClient *atproto.Client
if globalRefresher != nil {
// Try OAuth flow first
accessToken, dpopKey, err := globalRefresher.GetAccessToken(ctx, did)
if err == nil {
// OAuth token available - create client with DPoP support
fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s\n", did)
dpopTransport := oauth.NewDPoPTransport(nil, dpopKey)
atprotoClient = atproto.NewClientWithDPoP(pdsEndpoint, did, accessToken, dpopKey, dpopTransport)
} else {
fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err)
}
}
// This is where we inject ATProto + storage routing
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
// Fall back to Basic Auth token cache if OAuth not available
if atprotoClient == nil {
accessToken, ok := auth.GetGlobalTokenCache().Get(did)
if !ok {
fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s (neither OAuth nor Basic Auth)\n", did)
accessToken = "" // Will fail on manifest push, but let it try
} else {
fmt.Printf("DEBUG [registry/middleware]: Using Basic Auth access token for DID=%s (length=%d)\n", did, len(accessToken))
}
atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
}
// IMPORTANT: Use only the image name (not identity/image) for ATProto storage
// ATProto records are scoped to the user's DID, so we don't need the identity prefix