cleanup more auth

This commit is contained in:
Evan Jarrett
2025-10-07 10:58:11 -05:00
parent 5b18538a8b
commit 2d16bbfee3
31 changed files with 2524 additions and 918 deletions
+28 -5
View File
@@ -12,7 +12,8 @@ import (
"sync"
"time"
atprotoclient "atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// CachedSession represents a cached session
@@ -25,7 +26,7 @@ type CachedSession struct {
// SessionValidator validates ATProto credentials
type SessionValidator struct {
resolver *atprotoclient.Resolver
directory identity.Directory
httpClient *http.Client
cache map[string]*CachedSession
cacheMu sync.RWMutex
@@ -34,7 +35,7 @@ type SessionValidator struct {
// NewSessionValidator creates a new ATProto session validator
func NewSessionValidator() *SessionValidator {
return &SessionValidator{
resolver: atprotoclient.NewResolver(),
directory: identity.DefaultDirectory(),
httpClient: &http.Client{},
cache: make(map[string]*CachedSession),
}
@@ -86,11 +87,22 @@ type SessionResponse struct {
// Returns the user's DID and PDS endpoint if valid
func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) {
// Resolve identifier (handle or DID) to PDS endpoint
resolvedDID, pds, err := v.resolver.ResolveIdentity(ctx, identifier)
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
}
ident, err := v.directory.Lookup(ctx, *atID)
if err != nil {
return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
}
resolvedDID := ident.DID.String()
pds := ident.PDSEndpoint()
if pds == "" {
return "", "", fmt.Errorf("no PDS endpoint found for %q", identifier)
}
fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds)
// Create session with the PDS
@@ -119,11 +131,22 @@ func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identif
fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier)
// Resolve identifier to PDS endpoint
did, pds, err := v.resolver.ResolveIdentity(ctx, identifier)
atID, err := syntax.ParseAtIdentifier(identifier)
if err != nil {
return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
}
ident, err := v.directory.Lookup(ctx, *atID)
if err != nil {
return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
}
did = ident.DID.String()
pds := ident.PDSEndpoint()
if pds == "" {
return "", "", "", fmt.Errorf("no PDS endpoint found for %q", identifier)
}
// Create session
sessionResp, err := v.createSession(ctx, pds, identifier, password)
if err != nil {
+14 -3
View File
@@ -7,7 +7,8 @@ import (
"io"
"net/http"
mainAtproto "atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// TokenValidator validates ATProto OAuth access tokens
@@ -90,12 +91,22 @@ func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessT
// 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)
directory := identity.DefaultDirectory()
atID, err := syntax.ParseAtIdentifier(handle)
if err != nil {
return nil, fmt.Errorf("invalid identifier %q: %w", handle, err)
}
ident, err := directory.Lookup(ctx, *atID)
if err != nil {
return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return nil, fmt.Errorf("no PDS endpoint found for %q", handle)
}
// Validate token against the PDS
return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof)
}
-116
View File
@@ -1,116 +0,0 @@
package exchange
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/session"
"atcr.io/pkg/auth/token"
)
// Handler handles /auth/exchange requests (session token -> registry JWT)
type Handler struct {
issuer *token.Issuer
sessionManager *session.Manager
}
// NewHandler creates a new exchange handler
func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler {
return &Handler{
issuer: issuer,
sessionManager: sessionManager,
}
}
// ExchangeRequest represents the request to exchange a session token for registry JWT
type ExchangeRequest struct {
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
}
// 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
}
// 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(sessionClaims.DID, sessionClaims.Handle, access); err != nil {
http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
return
}
// 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
}
// 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)
}
+25
View File
@@ -0,0 +1,25 @@
package oauth
import (
"fmt"
"os/exec"
"runtime"
)
// OpenBrowser opens the default browser to the given URL
func OpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
return cmd.Start()
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
@@ -15,7 +16,7 @@ import (
type App struct {
clientApp *oauth.ClientApp
baseURL string
resolver *atproto.Resolver
directory identity.Directory
}
// NewApp creates a new OAuth app for ATCR
@@ -26,7 +27,7 @@ func NewApp(baseURL string, store oauth.ClientAuthStore) (*App, error) {
return &App{
clientApp: clientApp,
baseURL: baseURL,
resolver: atproto.NewResolver(),
directory: identity.DefaultDirectory(),
}, nil
}
+187
View File
@@ -0,0 +1,187 @@
package oauth
import (
"context"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
)
// InteractiveResult contains the result of an interactive OAuth flow
type InteractiveResult struct {
SessionData *oauth.ClientSessionData
Session *oauth.ClientSession
App *App
}
// RunInteractiveFlow runs an interactive OAuth flow for CLI tools
// This is a simplified wrapper around indigo's OAuth flow
func RunInteractiveFlow(
ctx context.Context,
baseURL string,
handle string,
scopes []string,
onAuthURL func(string) error,
) (*InteractiveResult, error) {
// Create temporary file store for this flow
store, err := NewFileStore("/tmp/atcr-oauth-temp.json")
if err != nil {
return nil, fmt.Errorf("failed to create OAuth store: %w", err)
}
// Create OAuth app
app, err := NewApp(baseURL, store)
if err != nil {
return nil, fmt.Errorf("failed to create OAuth app: %w", err)
}
// Set custom scopes if provided
if len(scopes) > 0 {
// Note: indigo's ClientApp doesn't expose SetScopes, so we need to use default scopes
// This is a limitation of the current implementation
// TODO: Enhance if custom scopes are needed
}
// Start auth flow
authURL, err := app.StartAuthFlow(ctx, handle)
if err != nil {
return nil, fmt.Errorf("failed to start auth flow: %w", err)
}
// Call the callback to display the auth URL
if err := onAuthURL(authURL); err != nil {
return nil, fmt.Errorf("auth URL callback failed: %w", err)
}
// Wait for OAuth callback
// The callback will be handled by the http.HandleFunc registered by the caller
// We need to wait for ProcessCallback to be called
// This is a bit awkward, but matches the old pattern
// Setup a channel to receive callback params
callbackChan := make(chan url.Values, 1)
var setupOnce sync.Once
// Return a function that the caller can use to process the callback
// This is called from the HTTP handler
processCallback := func(params url.Values) (*oauth.ClientSessionData, error) {
setupOnce.Do(func() {
callbackChan <- params
})
sessionData, err := app.ProcessCallback(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to process callback: %w", err)
}
return sessionData, nil
}
// Wait for callback with timeout
select {
case params := <-callbackChan:
sessionData, err := processCallback(params)
if err != nil {
return nil, err
}
// Resume session to get ClientSession
session, err := app.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID)
if err != nil {
return nil, fmt.Errorf("failed to resume session: %w", err)
}
return &InteractiveResult{
SessionData: sessionData,
Session: session,
App: app,
}, nil
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("OAuth flow timed out after 5 minutes")
}
}
// InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling
// This version allows the caller to register the callback handler before starting the flow
func InteractiveFlowWithCallback(
ctx context.Context,
baseURL string,
handle string,
scopes []string,
registerCallback func(handler http.HandlerFunc) error,
displayAuthURL func(string) error,
) (*InteractiveResult, error) {
// Create temporary file store for this flow
store, err := NewFileStore("/tmp/atcr-oauth-temp.json")
if err != nil {
return nil, fmt.Errorf("failed to create OAuth store: %w", err)
}
// Create OAuth app
app, err := NewApp(baseURL, store)
if err != nil {
return nil, fmt.Errorf("failed to create OAuth app: %w", err)
}
// Channel to receive callback result
resultChan := make(chan *InteractiveResult, 1)
errorChan := make(chan error, 1)
// Create callback handler
callbackHandler := func(w http.ResponseWriter, r *http.Request) {
// Process callback
sessionData, err := app.ProcessCallback(r.Context(), r.URL.Query())
if err != nil {
errorChan <- fmt.Errorf("failed to process callback: %w", err)
http.Error(w, "OAuth callback failed", http.StatusInternalServerError)
return
}
// Resume session
session, err := app.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID)
if err != nil {
errorChan <- fmt.Errorf("failed to resume session: %w", err)
http.Error(w, "Failed to resume session", http.StatusInternalServerError)
return
}
// Send result
resultChan <- &InteractiveResult{
SessionData: sessionData,
Session: session,
App: app,
}
// Return success to browser
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>")
}
// Register callback handler
if err := registerCallback(callbackHandler); err != nil {
return nil, fmt.Errorf("failed to register callback: %w", err)
}
// Start auth flow
authURL, err := app.StartAuthFlow(ctx, handle)
if err != nil {
return nil, fmt.Errorf("failed to start auth flow: %w", err)
}
// Display auth URL
if err := displayAuthURL(authURL); err != nil {
return nil, fmt.Errorf("failed to display auth URL: %w", err)
}
// Wait for callback result
select {
case result := <-resultChan:
return result, nil
case err := <-errorChan:
return nil, err
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("OAuth flow timed out after 5 minutes")
}
}
+40 -52
View File
@@ -7,7 +7,7 @@ import (
"net/http"
"time"
"atcr.io/pkg/auth/session"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// UISessionStore is the interface for UI session management
@@ -18,16 +18,14 @@ type UISessionStore interface {
// Server handles OAuth authorization for the AppView
type Server struct {
app *App
sessionManager *session.Manager
refresher *Refresher
uiSessionStore UISessionStore
}
// NewServer creates a new OAuth server
func NewServer(app *App, sessionManager *session.Manager) *Server {
func NewServer(app *App) *Server {
return &Server{
app: app,
sessionManager: sessionManager,
app: app,
}
}
@@ -104,7 +102,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did)
}
// We need to get the handle for the session token
// We need to get the handle for UI sessions and settings redirect
// Resolve DID to handle using our resolver
handle, err := s.resolveHandle(r.Context(), did)
if err != nil {
@@ -112,17 +110,10 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
handle = did // Fallback to DID if resolution fails
}
// Create session token for credential helper
sessionToken, err := s.sessionManager.Create(did, handle)
if err != nil {
s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err))
return
}
// Check if this is a UI login (has oauth_return_to cookie)
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
// Create UI session
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour)
// Create UI session (30 days to match OAuth refresh token lifetime)
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour)
if err != nil {
s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err))
return
@@ -133,7 +124,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
Name: "atcr_session",
Value: uiSessionID,
Path: "/",
MaxAge: 86400, // 24 hours
MaxAge: 30 * 86400, // 30 days
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
@@ -157,39 +148,36 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
return
}
// Render success page with session token (for credential helper)
s.renderSuccess(w, sessionToken, handle)
// Non-UI flow: redirect to settings to get API key
s.renderRedirectToSettings(w, handle)
}
// resolveHandle attempts to resolve a DID to a handle
// This is a best-effort helper - we use the resolver to look up the handle
func (s *Server) resolveHandle(ctx context.Context, did string) (string, error) {
// Parse the DID document to get the handle
// Note: This is a simple implementation - in production we might want to cache this
doc, err := s.app.resolver.ResolveDIDDocument(ctx, did)
// This is a best-effort helper - we use the directory to look up the handle
func (s *Server) resolveHandle(ctx context.Context, didStr string) (string, error) {
// Parse DID
did, err := syntax.ParseDID(didStr)
if err != nil {
return "", fmt.Errorf("failed to resolve DID document: %w", err)
return "", fmt.Errorf("invalid DID: %w", err)
}
// Try to find a handle in the alsoKnownAs field
for _, aka := range doc.AlsoKnownAs {
if len(aka) > 5 && aka[:5] == "at://" {
return aka[5:], nil
}
// Look up identity
ident, err := s.app.directory.LookupDID(ctx, did)
if err != nil {
return "", fmt.Errorf("failed to lookup DID: %w", err)
}
return "", fmt.Errorf("no handle found in DID document")
// Return handle (may be handle.invalid if verification failed)
return ident.Handle.String(), nil
}
// renderSuccess renders the success page
func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) {
tmpl := template.Must(template.New("success").Parse(successTemplate))
// renderRedirectToSettings redirects to the settings page to generate an API key
func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) {
tmpl := template.Must(template.New("redirect").Parse(redirectToSettingsTemplate))
data := struct {
SessionToken string
Handle string
Handle string
}{
SessionToken: sessionToken,
Handle: handle,
Handle: handle,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -216,35 +204,35 @@ func (s *Server) renderError(w http.ResponseWriter, message string) {
// HTML templates
const successTemplate = `
const redirectToSettingsTemplate = `
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful - ATCR</title>
<meta http-equiv="refresh" content="3;url=/settings">
<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; }
.info { background: #d1ecf1; border: 1px solid #bee5eb; padding: 15px; border-radius: 5px; margin-top: 15px; }
a { color: #007bff; text-decoration: none; }
a:hover { text-decoration: underline; }
</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>
<p>Redirecting to settings page to generate your API key...</p>
<p>If not redirected, <a href="/settings">click here</a>.</p>
</div>
<div class="info">
<h3>Next Steps:</h3>
<ol>
<li>Generate an API key on the settings page</li>
<li>Copy the API key (shown once!)</li>
<li>Use it with: <code>docker login atcr.io -u {{.Handle}} -p [your-api-key]</code></li>
</ol>
</div>
<script>
function copyToken() {
const token = document.getElementById('token').textContent;
navigator.clipboard.writeText(token).then(() => {
alert('Token copied to clipboard!');
});
}
</script>
</body>
</html>
`
+237
View File
@@ -0,0 +1,237 @@
package oauth
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// FileStore implements oauth.ClientAuthStore with file-based persistence
type FileStore struct {
path string
sessions map[string]*oauth.ClientSessionData // Key: "did:sessionID"
requests map[string]*oauth.AuthRequestData // Key: state
mu sync.RWMutex
}
// FileStoreData represents the JSON structure stored on disk
type FileStoreData struct {
Sessions map[string]*oauth.ClientSessionData `json:"sessions"`
Requests map[string]*oauth.AuthRequestData `json:"requests"`
}
// NewFileStore creates a new file-based OAuth store
func NewFileStore(path string) (*FileStore, error) {
store := &FileStore{
path: path,
sessions: make(map[string]*oauth.ClientSessionData),
requests: make(map[string]*oauth.AuthRequestData),
}
// Load existing data if file exists
if err := store.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load store: %w", err)
}
// File doesn't exist yet, that's ok
}
return store, nil
}
// GetDefaultStorePath returns the default storage path for OAuth data
func GetDefaultStorePath() (string, error) {
// For AppView: /var/lib/atcr/oauth-sessions.json
// For CLI tools: ~/.atcr/oauth-sessions.json
// Check if running as a service (has write access to /var/lib)
servicePath := "/var/lib/atcr/oauth-sessions.json"
if err := os.MkdirAll(filepath.Dir(servicePath), 0700); err == nil {
// Can write to /var/lib, use service path
return servicePath, nil
}
// Fall back to user home directory
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, "oauth-sessions.json"), nil
}
// GetSession retrieves a session by DID and session ID
func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
key := makeSessionKey(did.String(), sessionID)
session, ok := s.sessions[key]
if !ok {
return nil, fmt.Errorf("session not found: %s/%s", did, sessionID)
}
return session, nil
}
// SaveSession saves or updates a session (upsert)
func (s *FileStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
s.mu.Lock()
defer s.mu.Unlock()
key := makeSessionKey(sess.AccountDID.String(), sess.SessionID)
s.sessions[key] = &sess
return s.save()
}
// DeleteSession removes a session
func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
key := makeSessionKey(did.String(), sessionID)
delete(s.sessions, key)
return s.save()
}
// GetAuthRequestInfo retrieves authentication request data by state
func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
request, ok := s.requests[state]
if !ok {
return nil, fmt.Errorf("auth request not found: %s", state)
}
return request, nil
}
// SaveAuthRequestInfo saves authentication request data
func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
s.mu.Lock()
defer s.mu.Unlock()
s.requests[info.State] = &info
return s.save()
}
// DeleteAuthRequestInfo removes authentication request data
func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.requests, state)
return s.save()
}
// CleanupExpired removes expired sessions and auth requests
// Should be called periodically (e.g., every hour)
func (s *FileStore) CleanupExpired() error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
modified := false
// Clean up auth requests older than 10 minutes
// (OAuth flows should complete quickly)
for state := range s.requests {
// Note: AuthRequestData doesn't have a timestamp in indigo's implementation
// For now, we'll rely on the OAuth server's cleanup routine
// or we could extend AuthRequestData with metadata
_ = state // Placeholder for future expiration logic
}
// Sessions don't have expiry in the data structure
// Cleanup would need to be token-based (check token expiry)
// For now, manual cleanup via DeleteSession
_ = now
if modified {
return s.save()
}
return nil
}
// ListSessions returns all stored sessions for debugging/management
func (s *FileStore) ListSessions() map[string]*oauth.ClientSessionData {
s.mu.RLock()
defer s.mu.RUnlock()
// Return a copy to prevent external modification
result := make(map[string]*oauth.ClientSessionData)
for k, v := range s.sessions {
result[k] = v
}
return result
}
// load reads data from disk
func (s *FileStore) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
var storeData FileStoreData
if err := json.Unmarshal(data, &storeData); err != nil {
return fmt.Errorf("failed to parse store: %w", err)
}
if storeData.Sessions != nil {
s.sessions = storeData.Sessions
}
if storeData.Requests != nil {
s.requests = storeData.Requests
}
return nil
}
// save writes data to disk
func (s *FileStore) save() error {
storeData := FileStoreData{
Sessions: s.sessions,
Requests: s.requests,
}
data, err := json.MarshalIndent(storeData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal store: %w", err)
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Write with restrictive permissions
if err := os.WriteFile(s.path, data, 0600); err != nil {
return fmt.Errorf("failed to write store: %w", err)
}
return nil
}
// makeSessionKey creates a composite key for session storage
func makeSessionKey(did, sessionID string) string {
return fmt.Sprintf("%s:%s", did, sessionID)
}
-170
View File
@@ -1,170 +0,0 @@
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
}
+43 -28
View File
@@ -7,26 +7,29 @@ import (
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/apikey"
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
apiKeyStore *apikey.Store // For validating API keys
defaultHoldEndpoint string
}
// NewHandler creates a new token handler
func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler {
func NewHandler(issuer *Issuer, apiKeyStore *apikey.Store, defaultHoldEndpoint string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewSessionValidator(),
sessionManager: sessionManager,
apiKeyStore: apiKeyStore,
defaultHoldEndpoint: defaultHoldEndpoint,
}
}
@@ -80,19 +83,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var handle string
var accessToken string
// 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
// 1. Check if it's an API key (starts with "atcr_")
if strings.HasPrefix(password, "atcr_") {
apiKey, err := h.apiKeyStore.Validate(password)
if err != nil {
fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err)
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
did = apiKey.DID
handle = apiKey.Handle
fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle)
// API key is linked to OAuth session
// OAuth refresher will provide access token when needed via middleware
} else {
// 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)
// 2. Try app password (direct PDS authentication)
fmt.Printf("DEBUG [token/handler]: Not an API key, 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)
@@ -110,19 +119,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 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)
} 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 {
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 ensure profile for %s: %v\n", did, err)
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)
}
}
}
}
}