Files
at-container-registry/pkg/auth/exchange/handler.go
T
2025-10-02 11:03:59 -05:00

135 lines
4.1 KiB
Go

package exchange
import (
"encoding/json"
"fmt"
"net/http"
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/atproto"
"atcr.io/pkg/auth/token"
)
// Handler handles /auth/exchange requests (OAuth token -> JWT token)
type Handler struct {
issuer *token.Issuer
validator *atproto.TokenValidator
defaultHoldEndpoint string
}
// NewHandler creates a new exchange handler
func NewHandler(issuer *token.Issuer, defaultHoldEndpoint string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewTokenValidator(),
defaultHoldEndpoint: defaultHoldEndpoint,
}
}
// ExchangeRequest represents the request to exchange an OAuth token
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
}
// ExchangeResponse represents the response from /auth/exchange
type ExchangeResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
// ServeHTTP handles the exchange request
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
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 {
http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest)
return
}
// Validate access permissions
if err := auth.ValidateAccess(did, 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)
if err != nil {
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
return
}
// Return response
resp := ExchangeResponse{
Token: tokenString,
AccessToken: tokenString,
ExpiresIn: int(h.issuer.Expiration().Seconds()),
}
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 exchange handler with the provided mux
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("/auth/exchange", h)
}