mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
big scary refactor. sync enable_bluesky_posts with captain record. implement oauth logout handler. implement crew assignment to hold. this caused a lot of circular dependencies and needed to move functions around in order to fix
This commit is contained in:
+38
-31
@@ -1,6 +1,7 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -11,30 +12,38 @@ import (
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
mainAtproto "atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
)
|
||||
|
||||
// PostAuthCallback is called after successful Basic Auth authentication.
|
||||
// Parameters: ctx, did, handle, pdsEndpoint, accessToken
|
||||
// This allows AppView to perform business logic (profile creation, etc.)
|
||||
// without coupling the token package to AppView-specific dependencies.
|
||||
type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
|
||||
|
||||
// Handler handles /auth/token requests
|
||||
type Handler struct {
|
||||
issuer *Issuer
|
||||
validator *auth.SessionValidator
|
||||
deviceStore *db.DeviceStore // For validating device secrets
|
||||
defaultHoldDID string
|
||||
issuer *Issuer
|
||||
validator *auth.SessionValidator
|
||||
deviceStore *db.DeviceStore // For validating device secrets
|
||||
postAuthCallback PostAuthCallback
|
||||
}
|
||||
|
||||
// NewHandler creates a new token handler
|
||||
// defaultHoldDID should be in format "did:web:hold01.atcr.io"
|
||||
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
|
||||
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldDID string) *Handler {
|
||||
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler {
|
||||
return &Handler{
|
||||
issuer: issuer,
|
||||
validator: auth.NewSessionValidator(),
|
||||
deviceStore: deviceStore,
|
||||
defaultHoldDID: defaultHoldDID,
|
||||
issuer: issuer,
|
||||
validator: auth.NewSessionValidator(),
|
||||
deviceStore: deviceStore,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication
|
||||
// This allows AppView to inject business logic without coupling the token package
|
||||
func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) {
|
||||
h.postAuthCallback = callback
|
||||
}
|
||||
|
||||
// TokenResponse represents the response from /auth/token
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token,omitempty"` // Legacy field
|
||||
@@ -142,25 +151,23 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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.defaultHoldDID); 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)
|
||||
// Call post-auth callback for AppView business logic (profile management, etc.)
|
||||
if h.postAuthCallback != nil {
|
||||
// Resolve PDS endpoint for callback
|
||||
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 callback: %v\n", err)
|
||||
} else {
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
if pdsEndpoint != "" {
|
||||
if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil {
|
||||
// Log error but don't fail auth - business logic is non-critical
|
||||
fmt.Printf("WARNING: post-auth callback failed for DID=%s: %v\n", did, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
|
||||
// GetOrFetchServiceToken gets a service token for hold authentication.
|
||||
// Checks cache first, then fetches from PDS with OAuth/DPoP if needed.
|
||||
// This is the canonical implementation used by both middleware and crew registration.
|
||||
func GetOrFetchServiceToken(
|
||||
ctx context.Context,
|
||||
refresher *oauth.Refresher,
|
||||
did, holdDID, pdsEndpoint string,
|
||||
) (string, error) {
|
||||
if refresher == nil {
|
||||
return "", fmt.Errorf("refresher is nil (OAuth session required for service tokens)")
|
||||
}
|
||||
|
||||
// Check cache first to avoid unnecessary PDS calls on every request
|
||||
cachedToken, expiresAt := GetServiceToken(did, holdDID)
|
||||
|
||||
// Use cached token if it exists and has > 10s remaining
|
||||
if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
|
||||
fmt.Printf("DEBUG [atproto/servicetoken]: Using cached service token for DID=%s (expires in %v)\n",
|
||||
did, time.Until(expiresAt).Round(time.Second))
|
||||
return cachedToken, nil
|
||||
}
|
||||
|
||||
// Cache miss or expiring soon - validate OAuth and get new service token
|
||||
if cachedToken == "" {
|
||||
fmt.Printf("DEBUG [atproto/servicetoken]: Cache miss, fetching service token for DID=%s\n", did)
|
||||
} else {
|
||||
fmt.Printf("DEBUG [atproto/servicetoken]: Token expiring soon, proactively renewing for DID=%s\n", did)
|
||||
}
|
||||
|
||||
session, err := refresher.GetSession(ctx, did)
|
||||
if err != nil {
|
||||
// OAuth session unavailable - invalidate and fail
|
||||
refresher.InvalidateSession(did)
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
return "", fmt.Errorf("failed to get OAuth session: %w", err)
|
||||
}
|
||||
|
||||
// Call com.atproto.server.getServiceAuth on the user's PDS
|
||||
// Request 5-minute expiry (PDS may grant less)
|
||||
// exp must be absolute Unix timestamp, not relative duration
|
||||
// Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
|
||||
expiryTime := time.Now().Unix() + 300 // 5 minutes from now
|
||||
serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
|
||||
pdsEndpoint,
|
||||
atproto.ServerGetServiceAuth,
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape("com.atproto.repo.getRecord"),
|
||||
expiryTime,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create service auth request: %w", err)
|
||||
}
|
||||
|
||||
// Use OAuth session to authenticate to PDS (with DPoP)
|
||||
resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
|
||||
if err != nil {
|
||||
// Invalidate session on auth errors (may indicate corrupted session or expired tokens)
|
||||
refresher.InvalidateSession(did)
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
return "", fmt.Errorf("OAuth validation failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Invalidate session on auth failures
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
refresher.InvalidateSession(did)
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Parse response to get service token
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode service auth response: %w", err)
|
||||
}
|
||||
|
||||
if result.Token == "" {
|
||||
return "", fmt.Errorf("empty token in service auth response")
|
||||
}
|
||||
|
||||
serviceToken := result.Token
|
||||
|
||||
// Cache the token (parses JWT to extract actual expiry)
|
||||
if err := SetServiceToken(did, holdDID, serviceToken); err != nil {
|
||||
fmt.Printf("WARN [atproto/servicetoken]: Failed to cache service token: %v\n", err)
|
||||
// Non-fatal - we have the token, just won't be cached
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG [atproto/servicetoken]: OAuth validation succeeded for DID=%s\n", did)
|
||||
return serviceToken, nil
|
||||
}
|
||||
Reference in New Issue
Block a user