Files
at-container-registry/pkg/auth/token/handler.go
2025-10-12 22:45:44 -05:00

209 lines
6.9 KiB
Go

package token
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/db"
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/atproto"
)
// Handler handles /auth/token requests
type Handler struct {
issuer *Issuer
validator *atproto.SessionValidator
deviceStore *db.DeviceStore // For validating device secrets
defaultHoldEndpoint string
}
// NewHandler creates a new token handler
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldEndpoint string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewSessionValidator(),
deviceStore: deviceStore,
defaultHoldEndpoint: defaultHoldEndpoint,
}
}
// TokenResponse represents the response from /auth/token
type TokenResponse struct {
Token string `json:"token,omitempty"` // Legacy field
AccessToken string `json:"access_token,omitempty"` // Standard field
ExpiresIn int `json:"expires_in,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
}
// getBaseURL extracts the base URL from the request, handling proxies
func getBaseURL(r *http.Request) string {
baseURL := r.Header.Get("X-Forwarded-Host")
if baseURL == "" {
baseURL = r.Host
}
if !strings.HasPrefix(baseURL, "http") {
// Add scheme
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
baseURL = "https://" + baseURL
} else {
baseURL = "http://" + baseURL
}
}
return baseURL
}
// sendAuthError sends a formatted authentication error response
func sendAuthError(w http.ResponseWriter, r *http.Request, message string) {
baseURL := getBaseURL(r)
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
http.Error(w, fmt.Sprintf(`%s
To authenticate:
1. Install credential helper: %s/install
2. Or run: docker login %s
(use your ATProto handle + app-password)`, message, baseURL, r.Host), http.StatusUnauthorized)
}
// ServeHTTP handles the token request
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Printf("DEBUG [token/handler]: Received %s request to %s\n", r.Method, r.URL.Path)
// Only accept GET requests (per Docker spec)
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract Basic auth credentials
username, password, ok := r.BasicAuth()
if !ok {
fmt.Printf("DEBUG [token/handler]: No Basic auth credentials provided\n")
sendAuthError(w, r, "authentication required")
return
}
fmt.Printf("DEBUG [token/handler]: Got Basic auth for username=%s, password length=%d\n", username, len(password))
// Parse query parameters
_ = r.URL.Query().Get("service") // service parameter - validated by issuer
scopeParam := r.URL.Query().Get("scope")
// Parse scopes
var scopes []string
if scopeParam != "" {
scopes = strings.Split(scopeParam, " ")
}
access, err := auth.ParseScope(scopes)
if err != nil {
http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest)
return
}
var did string
var handle string
var accessToken string
// 1. Check if it's a device secret (starts with "atcr_device_")
if strings.HasPrefix(password, "atcr_device_") {
device, err := h.deviceStore.ValidateDeviceSecret(password)
if err != nil {
fmt.Printf("DEBUG [token/handler]: Device secret validation failed: %v\n", err)
sendAuthError(w, r, "authentication failed")
return
}
did = device.DID
handle = device.Handle
// Device is linked to OAuth session via DID
// OAuth refresher will provide access token when needed via middleware
} else {
// 2. Try app password (direct PDS authentication)
fmt.Printf("DEBUG [token/handler]: Not a device secret, 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)
sendAuthError(w, r, "authentication failed")
return
}
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
directory := identity.DefaultDirectory()
atID, err := syntax.ParseAtIdentifier(username)
if err == nil {
ident, err := directory.Lookup(r.Context(), *atID)
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 {
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint != "" {
// 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
// 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
}
// Issue JWT token
tokenString, err := h.issuer.Issue(did, access)
if err != nil {
fmt.Printf("DEBUG [token/handler]: Failed to issue token: %v\n", err)
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("DEBUG [token/handler]: Issued JWT token (length=%d) for DID=%s\n", len(tokenString), did)
// Return token response
now := time.Now()
expiresIn := int(h.issuer.expiration.Seconds())
resp := TokenResponse{
Token: tokenString,
AccessToken: tokenString,
ExpiresIn: expiresIn,
IssuedAt: now.Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError)
return
}
}
// RegisterRoutes registers the token handler with the provided mux
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("/auth/token", h)
}