mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
cleanup oauth
This commit is contained in:
+102
-192
@@ -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("\n✓ Configuration 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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user