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
+249
View File
@@ -0,0 +1,249 @@
package apikey
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// APIKey represents a user's API key
type APIKey struct {
ID string `json:"id"` // UUID
KeyHash string `json:"key_hash"` // bcrypt hash
DID string `json:"did"` // Owner's DID
Handle string `json:"handle"` // Owner's handle
Name string `json:"name"` // User-provided name
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
}
// Store manages API keys
type Store struct {
mu sync.RWMutex
keys map[string]*APIKey // keyHash -> APIKey
byDID map[string][]string // DID -> []keyHash
filePath string // /var/lib/atcr/api-keys.json
}
// persistentData is the structure saved to disk
type persistentData struct {
Keys []*APIKey `json:"keys"`
}
// NewStore creates a new API key store
func NewStore(filePath string) (*Store, error) {
s := &Store{
keys: make(map[string]*APIKey),
byDID: make(map[string][]string),
filePath: filePath,
}
// Load existing keys from file
if err := s.load(); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load API keys: %w", err)
}
return s, nil
}
// Generate creates a new API key and returns the plaintext key (shown once)
func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error) {
// Generate 32 random bytes
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Format: atcr_<base64>
key = "atcr_" + base64.RawURLEncoding.EncodeToString(b)
// Hash for storage
keyHashBytes, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
if err != nil {
return "", "", fmt.Errorf("failed to hash key: %w", err)
}
keyHash := string(keyHashBytes)
// Generate ID
keyID = uuid.New().String()
apiKey := &APIKey{
ID: keyID,
KeyHash: keyHash,
DID: did,
Handle: handle,
Name: name,
CreatedAt: time.Now(),
LastUsed: time.Time{}, // Never used yet
}
s.mu.Lock()
s.keys[keyHash] = apiKey
s.byDID[did] = append(s.byDID[did], keyHash)
s.mu.Unlock()
if err := s.save(); err != nil {
return "", "", fmt.Errorf("failed to save keys: %w", err)
}
// Return plaintext key (only time it's available)
return key, keyID, nil
}
// Validate checks if an API key is valid and returns the associated data
func (s *Store) Validate(key string) (*APIKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Try to match against all stored hashes
for hash, apiKey := range s.keys {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil {
// Update last used asynchronously
go s.UpdateLastUsed(hash)
// Return a copy to prevent external modifications
keyCopy := *apiKey
return &keyCopy, nil
}
}
return nil, fmt.Errorf("invalid API key")
}
// List returns all API keys for a DID (without plaintext keys)
func (s *Store) List(did string) []*APIKey {
s.mu.RLock()
defer s.mu.RUnlock()
keyHashes, ok := s.byDID[did]
if !ok {
return []*APIKey{}
}
result := make([]*APIKey, 0, len(keyHashes))
for _, hash := range keyHashes {
if apiKey, ok := s.keys[hash]; ok {
// Return copy without hash
keyCopy := *apiKey
keyCopy.KeyHash = "" // Don't expose hash
result = append(result, &keyCopy)
}
}
return result
}
// Delete removes an API key
func (s *Store) Delete(did, keyID string) error {
s.mu.Lock()
defer s.mu.Unlock()
// Find the key by DID and ID
keyHashes, ok := s.byDID[did]
if !ok {
return fmt.Errorf("no keys found for DID: %s", did)
}
var foundHash string
for _, hash := range keyHashes {
if apiKey, ok := s.keys[hash]; ok && apiKey.ID == keyID {
foundHash = hash
break
}
}
if foundHash == "" {
return fmt.Errorf("key not found: %s", keyID)
}
// Remove from keys map
delete(s.keys, foundHash)
// Remove from byDID index
newHashes := make([]string, 0, len(keyHashes)-1)
for _, hash := range keyHashes {
if hash != foundHash {
newHashes = append(newHashes, hash)
}
}
if len(newHashes) == 0 {
delete(s.byDID, did)
} else {
s.byDID[did] = newHashes
}
return s.save()
}
// UpdateLastUsed updates the last used timestamp
func (s *Store) UpdateLastUsed(keyHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
apiKey, ok := s.keys[keyHash]
if !ok {
return fmt.Errorf("key not found")
}
apiKey.LastUsed = time.Now()
return s.save()
}
// load reads keys from disk
func (s *Store) load() error {
data, err := os.ReadFile(s.filePath)
if err != nil {
return err
}
var pd persistentData
if err := json.Unmarshal(data, &pd); err != nil {
return fmt.Errorf("failed to unmarshal keys: %w", err)
}
// Rebuild in-memory structures
for _, apiKey := range pd.Keys {
s.keys[apiKey.KeyHash] = apiKey
s.byDID[apiKey.DID] = append(s.byDID[apiKey.DID], apiKey.KeyHash)
}
return nil
}
// save writes keys to disk
func (s *Store) save() error {
// Collect all keys
allKeys := make([]*APIKey, 0, len(s.keys))
for _, apiKey := range s.keys {
allKeys = append(allKeys, apiKey)
}
pd := persistentData{
Keys: allKeys,
}
data, err := json.MarshalIndent(pd, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal keys: %w", err)
}
// Write atomically with temp file + rename
tmpPath := s.filePath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, s.filePath); err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
+3 -3
View File
@@ -16,7 +16,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
`
args := []interface{}{}
args := []any{}
if userFilter != "" {
query += " WHERE u.handle = ? OR u.did = ?"
@@ -43,7 +43,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
// Get total count
countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did"
countArgs := []interface{}{}
countArgs := []any{}
if userFilter != "" {
countQuery += " WHERE u.handle = ? OR u.did = ?"
@@ -228,7 +228,7 @@ func DeleteManifestsNotInList(db *sql.DB, did string, keepDigests []string) erro
// Build placeholders for IN clause
placeholders := make([]string, len(keepDigests))
args := []interface{}{did}
args := []any{did}
for i, digest := range keepDigests {
placeholders[i] = "?"
args = append(args, digest)
+91
View File
@@ -0,0 +1,91 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"atcr.io/pkg/appview/apikey"
"atcr.io/pkg/appview/middleware"
"github.com/gorilla/mux"
)
// GenerateAPIKeyHandler handles POST /api/keys
type GenerateAPIKeyHandler struct {
Store *apikey.Store
}
func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
name := r.FormValue("name")
if name == "" {
name = "Unnamed Key"
}
key, keyID, err := h.Store.Generate(user.DID, user.Handle, name)
if err != nil {
fmt.Printf("ERROR [apikeys]: Failed to generate key for DID=%s: %v\n", user.DID, err)
http.Error(w, "Failed to generate key", http.StatusInternalServerError)
return
}
fmt.Printf("INFO [apikeys]: Generated API key for DID=%s, handle=%s, name=%s, keyID=%s\n",
user.DID, user.Handle, name, keyID)
// Return key (shown once!)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"id": keyID,
"key": key,
})
}
// ListAPIKeysHandler handles GET /api/keys
type ListAPIKeysHandler struct {
Store *apikey.Store
}
func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
keys := h.Store.List(user.DID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(keys)
}
// DeleteAPIKeyHandler handles DELETE /api/keys/{id}
type DeleteAPIKeyHandler struct {
Store *apikey.Store
}
func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
keyID := vars["id"]
if err := h.Store.Delete(user.DID, keyID); err != nil {
fmt.Printf("ERROR [apikeys]: Failed to delete key for DID=%s, keyID=%s: %v\n",
user.DID, keyID, err)
http.Error(w, "Failed to delete key", http.StatusInternalServerError)
return
}
fmt.Printf("INFO [apikeys]: Deleted API key for DID=%s, keyID=%s\n", user.DID, keyID)
w.WriteHeader(http.StatusNoContent)
}
+14 -12
View File
@@ -28,16 +28,17 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get OAuth session for the user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
// OAuth session not found or expired - redirect to re-authenticate
fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err)
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
return
}
// Extract access token and HTTP client from session
accessToken, _ := session.GetHostAccessData()
httpClient := session.APIClient().Client
// Use indigo's API client directly - it handles all auth automatically
apiClient := session.APIClient()
// Create ATProto client with indigo's DPoP-configured HTTP client
client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
// Create ATProto client with indigo's XRPC client
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Fetch sailor profile
profile, err := atproto.GetProfile(r.Context(), client)
@@ -93,16 +94,17 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
// Get OAuth session for the user
session, err := h.Refresher.GetSession(r.Context(), user.DID)
if err != nil {
http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
// OAuth session not found or expired - redirect to re-authenticate
fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err)
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
return
}
// Extract access token and HTTP client from session
accessToken, _ := session.GetHostAccessData()
httpClient := session.APIClient().Client
// Use indigo's API client directly - it handles all auth automatically
apiClient := session.APIClient()
// Create ATProto client with indigo's DPoP-configured HTTP client
client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
// Create ATProto client with indigo's XRPC client
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
// Fetch existing profile or create new one
profile, err := atproto.GetProfile(r.Context(), client)
+49 -13
View File
@@ -8,15 +8,18 @@ import (
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
)
// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
type BackfillWorker struct {
db *sql.DB
client *atproto.Client
resolver *atproto.Resolver
db *sql.DB
client *atproto.Client
directory identity.Directory
}
// BackfillState tracks backfill progress
@@ -36,9 +39,9 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker,
client := atproto.NewClient(relayEndpoint, "", "")
return &BackfillWorker{
db: database,
client: client, // This points to the relay
resolver: atproto.NewResolver(),
db: database,
client: client, // This points to the relay
directory: identity.DefaultDirectory(),
}, nil
}
@@ -117,11 +120,21 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
}
// Resolve DID to get user's PDS endpoint
_, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did)
didParsed, err := syntax.ParseDID(did)
if err != nil {
return 0, fmt.Errorf("invalid DID %s: %w", did, err)
}
ident, err := b.directory.LookupDID(ctx, didParsed)
if err != nil {
return 0, fmt.Errorf("failed to resolve DID to PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return 0, fmt.Errorf("no PDS endpoint found for DID %s", did)
}
// Create a client for this user's PDS
pdsClient := atproto.NewClient(pdsEndpoint, "", "")
@@ -314,17 +327,40 @@ func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error {
}
// Resolve DID to get handle and PDS endpoint
resolvedDID, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did)
didParsed, err := syntax.ParseDID(did)
if err != nil {
// Fallback: use DID as handle
resolvedDID = did
pdsEndpoint = "https://bsky.social"
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social",
LastSeen: time.Now(),
}
return db.UpsertUser(b.db, user)
}
// Get handle from DID document
handle, err := b.resolver.ResolveHandleFromDID(ctx, resolvedDID)
ident, err := b.directory.LookupDID(ctx, didParsed)
if err != nil {
handle = resolvedDID // Fallback to DID
// Fallback: use DID as handle
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social",
LastSeen: time.Now(),
}
return db.UpsertUser(b.db, user)
}
resolvedDID := ident.DID.String()
handle := ident.Handle.String()
pdsEndpoint := ident.PDSEndpoint()
// If handle is invalid or PDS is missing, use defaults
if handle == "handle.invalid" || handle == "" {
handle = resolvedDID
}
if pdsEndpoint == "" {
pdsEndpoint = "https://bsky.social"
}
// Upsert to database
+45 -17
View File
@@ -9,6 +9,9 @@ import (
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
"github.com/gorilla/websocket"
@@ -31,7 +34,7 @@ type Worker struct {
wantedCollections []string
debugCollectionCount int
userCache *UserCache
resolver *atproto.Resolver
directory identity.Directory
eventCallback EventCallback
}
@@ -53,7 +56,7 @@ func NewWorker(database *sql.DB, jetstreamURL string, startCursor int64) *Worker
userCache: &UserCache{
cache: make(map[string]*db.User),
},
resolver: atproto.NewResolver(),
directory: identity.DefaultDirectory(),
}
}
@@ -198,19 +201,44 @@ func (w *Worker) ensureUser(ctx context.Context, did string) error {
}
// Resolve DID to get handle and PDS endpoint
resolvedDID, pdsEndpoint, err := w.resolver.ResolveIdentity(ctx, did)
didParsed, err := syntax.ParseDID(did)
if err != nil {
fmt.Printf("WARNING: Invalid DID %s: %v (using DID as handle)\n", did, err)
// Fallback: use DID as handle
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
LastSeen: time.Now(),
}
w.userCache.cache[did] = user
return db.UpsertUser(w.db, user)
}
ident, err := w.directory.LookupDID(ctx, didParsed)
if err != nil {
fmt.Printf("WARNING: Failed to resolve DID %s: %v (using DID as handle)\n", did, err)
// Fallback: use DID as handle
resolvedDID = did
pdsEndpoint = "https://bsky.social" // Default PDS endpoint as fallback
user := &db.User{
DID: did,
Handle: did,
PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
LastSeen: time.Now(),
}
w.userCache.cache[did] = user
return db.UpsertUser(w.db, user)
}
// Get handle from DID document
handle, err := w.resolver.ResolveHandleFromDID(ctx, resolvedDID)
if err != nil {
fmt.Printf("WARNING: Failed to get handle for DID %s: %v (using DID as handle)\n", resolvedDID, err)
handle = resolvedDID // Fallback to DID
resolvedDID := ident.DID.String()
handle := ident.Handle.String()
pdsEndpoint := ident.PDSEndpoint()
// If handle is invalid or PDS is missing, use defaults
if handle == "handle.invalid" || handle == "" {
handle = resolvedDID
}
if pdsEndpoint == "" {
pdsEndpoint = "https://bsky.social"
}
// Cache the user
@@ -349,13 +377,13 @@ type JetstreamEvent struct {
// CommitEvent represents a commit event (create/update/delete)
type CommitEvent struct {
Rev string `json:"rev"`
Operation string `json:"operation"` // "create", "update", "delete"
Collection string `json:"collection"`
RKey string `json:"rkey"`
Record map[string]interface{} `json:"record,omitempty"`
CID string `json:"cid,omitempty"`
DID string `json:"-"` // Set from parent event
Rev string `json:"rev"`
Operation string `json:"operation"` // "create", "update", "delete"
Collection string `json:"collection"`
RKey string `json:"rkey"`
Record map[string]any `json:"record,omitempty"`
CID string `json:"cid,omitempty"`
DID string `json:"-"` // Set from parent event
}
// IdentityInfo represents an identity event
+21
View File
@@ -129,6 +129,27 @@ func (s *Store) Get(id string) (*Session, bool) {
return sess, true
}
// Extend extends a session's expiration time
func (s *Store) Extend(id string, duration time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
if !ok {
return fmt.Errorf("session not found: %s", id)
}
// Extend the expiration
sess.ExpiresAt = time.Now().Add(duration)
// Save to disk
if err := s.save(); err != nil {
fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err)
}
return nil
}
// Delete removes a session
func (s *Store) Delete(id string) {
s.mu.Lock()
+275
View File
@@ -57,6 +57,42 @@
<div id="hold-status"></div>
</section>
<!-- API Keys Section -->
<section class="settings-section api-keys-section">
<h2>API Keys</h2>
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
<!-- Generate New Key -->
<div class="generate-key">
<h3>Generate New API Key</h3>
<form id="generate-key-form">
<div class="form-group">
<label for="key-name">Key Name:</label>
<input type="text" id="key-name" name="key-name" placeholder="e.g., My Laptop, CI/CD" required>
</div>
<button type="submit" class="btn-primary">Generate Key</button>
</form>
</div>
<!-- Existing Keys List -->
<div class="keys-list">
<h3>Your API Keys</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="keys-table">
<tr><td colspan="4">Loading...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- OAuth Session Section -->
<section class="settings-section">
<h2>OAuth Session</h2>
@@ -78,7 +114,246 @@
<!-- Modal container for HTMX -->
<div id="modal"></div>
<!-- API Key Modal (shown once after generation) -->
<div id="key-modal" class="modal hidden">
<div class="modal-backdrop" onclick="closeKeyModal()"></div>
<div class="modal-content">
<h3>✓ API Key Generated!</h3>
<p><strong>Copy this key now - it won't be shown again:</strong></p>
<div class="key-display">
<code id="generated-key"></code>
<button class="btn-secondary" onclick="copyKey()">Copy to Clipboard</button>
</div>
<div class="usage-instructions">
<h4>Using with Docker:</h4>
<p><strong>Direct login (quick start)</strong></p>
<pre><code>docker login atcr.io -u {{ .Profile.Handle }} -p [paste key here]</code></pre>
<p><strong>Credential helper (if you opened this from configure)</strong></p>
<p>Just paste your handle and this key when prompted in the terminal.</p>
</div>
<button class="btn-primary" onclick="closeKeyModal()">Done</button>
</div>
</div>
<script src="/static/js/app.js"></script>
<script>
// API Key Management JavaScript
(function() {
// Generate key
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('key-name').value;
try {
const resp = await fetch('/api/keys', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `name=${encodeURIComponent(name)}`
});
if (!resp.ok) {
throw new Error('Failed to generate key');
}
const data = await resp.json();
// Show key in modal (only time it's available)
document.getElementById('generated-key').textContent = data.key;
document.getElementById('key-modal').classList.remove('hidden');
// Clear form
document.getElementById('key-name').value = '';
// Refresh keys list
loadKeys();
} catch (err) {
alert('Error generating key: ' + err.message);
}
});
// Copy key to clipboard
window.copyKey = function() {
const key = document.getElementById('generated-key').textContent;
navigator.clipboard.writeText(key).then(() => {
alert('Copied to clipboard!');
}).catch(err => {
alert('Failed to copy: ' + err.message);
});
};
// Close modal
window.closeKeyModal = function() {
document.getElementById('key-modal').classList.add('hidden');
};
// Load existing keys
async function loadKeys() {
try {
const resp = await fetch('/api/keys');
if (!resp.ok) {
throw new Error('Failed to load keys');
}
const keys = await resp.json();
const tbody = document.getElementById('keys-table');
if (keys.length === 0) {
tbody.innerHTML = '<tr><td colspan="4">No API keys yet. Generate one above!</td></tr>';
return;
}
tbody.innerHTML = keys.map(key => {
const createdDate = new Date(key.created_at).toLocaleDateString();
const lastUsed = key.last_used && key.last_used !== '0001-01-01T00:00:00Z'
? new Date(key.last_used).toLocaleDateString()
: 'Never';
return `
<tr>
<td>${escapeHtml(key.name)}</td>
<td>${createdDate}</td>
<td>${lastUsed}</td>
<td><button class="btn-danger" onclick="deleteKey('${key.id}')">Revoke</button></td>
</tr>
`;
}).join('');
} catch (err) {
console.error('Error loading keys:', err);
document.getElementById('keys-table').innerHTML =
'<tr><td colspan="4">Error loading keys</td></tr>';
}
}
// Delete key
window.deleteKey = async function(id) {
if (!confirm('Are you sure you want to revoke this key? This cannot be undone.')) {
return;
}
try {
const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
if (!resp.ok) {
throw new Error('Failed to delete key');
}
loadKeys();
} catch (err) {
alert('Error revoking key: ' + err.message);
}
};
// Escape HTML helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load keys on page load
loadKeys();
})();
</script>
<style>
/* API Key Modal Styles */
.modal.hidden { display: none; }
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
}
.modal-content {
position: relative;
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
width: 90%;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 1001;
}
.key-display {
background: #f5f5f5;
padding: 1rem;
margin: 1rem 0;
border-radius: 4px;
border: 1px solid #ddd;
}
.key-display code {
word-break: break-all;
font-size: 14px;
display: block;
margin-bottom: 1rem;
}
.usage-instructions {
margin-top: 1rem;
padding: 1rem;
background: #e3f2fd;
border-radius: 4px;
}
.usage-instructions h4 {
margin-top: 0;
}
.usage-instructions pre {
background: #263238;
color: #aed581;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0 0 0;
}
.usage-instructions code {
font-family: monospace;
}
/* API Keys Section Styles */
.api-keys-section table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.api-keys-section th,
.api-keys-section td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
.api-keys-section th {
background: #f5f5f5;
font-weight: bold;
}
.api-keys-section .btn-danger {
background: #dc3545;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.api-keys-section .btn-danger:hover {
background: #c82333;
}
.generate-key {
margin: 1rem 0;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
</style>
</body>
</html>
{{ end }}
+81 -60
View File
@@ -3,69 +3,47 @@ package atproto
import (
"bytes"
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/bluesky-social/indigo/atproto/client"
)
// Client wraps ATProto operations for the registry
type Client struct {
pdsEndpoint string
did string
accessToken string
httpClient *http.Client
useDPoP bool // true if using DPoP-bound tokens (OAuth)
pdsEndpoint string
did string
accessToken string // For Basic Auth only
httpClient *http.Client
useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically)
indigoClient *client.APIClient // indigo's API client for OAuth requests
}
// NewClient creates a new ATProto client for Basic Auth tokens
// NewClient creates a new ATProto client for Basic Auth tokens (app passwords)
func NewClient(pdsEndpoint, did, accessToken string) *Client {
return &Client{
pdsEndpoint: pdsEndpoint,
did: did,
accessToken: accessToken,
httpClient: &http.Client{},
useDPoP: false, // Basic Auth uses Bearer tokens
}
}
// NewClientWithDPoP creates a new ATProto client with DPoP support
// This is required for OAuth tokens
func NewClientWithDPoP(pdsEndpoint, did, accessToken string, dpopKey *ecdsa.PrivateKey, transport http.RoundTripper) *Client {
// NewClientWithIndigoClient creates an ATProto client using indigo's API client
// This uses indigo's native XRPC methods with automatic DPoP handling
func NewClientWithIndigoClient(pdsEndpoint, did string, indigoClient *client.APIClient) *Client {
return &Client{
pdsEndpoint: pdsEndpoint,
did: did,
accessToken: accessToken,
httpClient: &http.Client{
Transport: transport,
},
useDPoP: true, // OAuth uses DPoP tokens
pdsEndpoint: pdsEndpoint,
did: did,
useIndigoClient: true,
indigoClient: indigoClient,
httpClient: indigoClient.Client, // Keep for any fallback cases
}
}
// NewClientWithHTTPClient creates a new ATProto client with a pre-configured HTTP client
// This is useful when using indigo's OAuth session which provides a DPoP-configured client
// The access token will be used for Authorization headers, while the HTTP client
// handles transport-level concerns (like DPoP proofs)
func NewClientWithHTTPClient(pdsEndpoint, did, accessToken string, httpClient *http.Client) *Client {
return &Client{
pdsEndpoint: pdsEndpoint,
did: did,
accessToken: accessToken,
httpClient: httpClient,
useDPoP: true, // Assume DPoP when using custom client
}
}
// authHeader returns the appropriate Authorization header value
func (c *Client) authHeader() string {
if c.useDPoP {
return "DPoP " + c.accessToken
}
return "Bearer " + c.accessToken
}
// Record represents a generic ATProto record
type Record struct {
URI string `json:"uri"`
@@ -75,9 +53,6 @@ type Record struct {
// PutRecord stores a record in the ATProto repository
func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*Record, error) {
// Construct the record URI
// Format: at://<did>/<collection>/<rkey>
payload := map[string]any{
"repo": c.did,
"collection": collection,
@@ -85,6 +60,17 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record
"record": record,
}
// Use indigo API client (OAuth with DPoP)
if c.useIndigoClient && c.indigoClient != nil {
var result Record
err := c.indigoClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result)
if err != nil {
return nil, fmt.Errorf("putRecord failed: %w", err)
}
return &result, nil
}
// Basic Auth (app passwords)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal record: %w", err)
@@ -96,7 +82,7 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record
return nil, err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
@@ -120,6 +106,26 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record
// GetRecord retrieves a record from the ATProto repository
func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) {
// Use indigo API client (OAuth with DPoP)
if c.useIndigoClient && c.indigoClient != nil {
params := map[string]any{
"repo": c.did,
"collection": collection,
"rkey": rkey,
}
var result Record
err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
if err != nil {
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
return nil, fmt.Errorf("record not found")
}
return nil, fmt.Errorf("getRecord failed: %w", err)
}
return &result, nil
}
// Basic Auth (app passwords)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
c.pdsEndpoint, c.did, collection, rkey)
@@ -128,7 +134,7 @@ func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Recor
return nil, err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -172,7 +178,7 @@ func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) erro
return err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
@@ -199,7 +205,7 @@ func (c *Client) ListRecords(ctx context.Context, collection string, limit int)
return nil, err
}
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -238,22 +244,35 @@ type Link struct {
// UploadBlob uploads binary data to the PDS and returns a blob reference
func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) {
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint)
// Use indigo API client (OAuth with DPoP)
if c.useIndigoClient && c.indigoClient != nil {
var result struct {
Blob ATProtoBlobRef `json:"blob"`
}
err := c.indigoClient.LexDo(ctx,
"POST",
mimeType,
"com.atproto.repo.uploadBlob",
nil,
data,
&result,
)
if err != nil {
return nil, fmt.Errorf("uploadBlob failed: %w", err)
}
return &result.Blob, nil
}
// Basic Auth (app passwords)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
if err != nil {
return nil, err
}
// Only set Authorization header if we have an access token
if c.accessToken != "" {
authHeader := c.authHeader()
fmt.Printf("DEBUG [atproto/client]: UploadBlob Authorization header: %q (useDPoP=%v, token_length=%d)\n", authHeader, c.useDPoP, len(c.accessToken))
req.Header.Set("Authorization", authHeader)
} else {
fmt.Printf("DEBUG [atproto/client]: UploadBlob: No access token available, sending unauthenticated request\n")
return nil, fmt.Errorf("no access token available for authenticated PDS operation - please complete OAuth flow at: http://127.0.0.1:5000/auth/oauth/authorize?handle=<your-handle>")
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
req.Header.Set("Content-Type", mimeType)
resp, err := c.httpClient.Do(req)
@@ -288,7 +307,9 @@ func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) {
}
// Note: getBlob may not require auth for public repos, but we include it anyway
req.Header.Set("Authorization", c.authHeader())
if c.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -346,7 +367,7 @@ func (c *Client) ListReposByCollection(ctx context.Context, collection string, l
// This endpoint typically doesn't require auth for public data
// but we include it if available
if c.accessToken != "" {
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.httpClient.Do(req)
@@ -388,7 +409,7 @@ func (c *Client) ListRecordsForRepo(ctx context.Context, repoDID, collection str
// This endpoint typically doesn't require auth for public records
if c.accessToken != "" {
req.Header.Set("Authorization", c.authHeader())
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.httpClient.Do(req)
-243
View File
@@ -1,243 +0,0 @@
package atproto
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
)
// Resolver handles DID/handle resolution for ATProto
type Resolver struct {
httpClient *http.Client
}
// NewResolver creates a new DID/handle resolver
func NewResolver() *Resolver {
return &Resolver{
httpClient: &http.Client{},
}
}
// ResolveIdentity resolves a handle or DID to a DID and PDS endpoint
// Input can be:
// - Handle: "alice.bsky.social" or "alice"
// - DID: "did:plc:xyz123abc"
func (r *Resolver) ResolveIdentity(ctx context.Context, identity string) (did string, pdsEndpoint string, err error) {
// Check if it's already a DID
if strings.HasPrefix(identity, "did:") {
did = identity
pdsEndpoint, err = r.ResolvePDS(ctx, did)
return did, pdsEndpoint, err
}
// Otherwise, resolve handle to DID
did, err = r.ResolveHandle(ctx, identity)
if err != nil {
return "", "", fmt.Errorf("failed to resolve handle %s: %w", identity, err)
}
// Then resolve DID to PDS
pdsEndpoint, err = r.ResolvePDS(ctx, did)
if err != nil {
return "", "", fmt.Errorf("failed to resolve PDS for DID %s: %w", did, err)
}
return did, pdsEndpoint, nil
}
// ResolveHandle resolves a handle to a DID using DNS TXT records or .well-known
func (r *Resolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
// Normalize handle
if !strings.Contains(handle, ".") {
// Default to .bsky.social if no domain provided
handle = handle + ".bsky.social"
}
// Try DNS TXT record first (faster)
if did, err := r.resolveHandleViaDNS(handle); err == nil && did != "" {
return did, nil
}
// Fall back to HTTPS .well-known method
url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch .well-known: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
did := strings.TrimSpace(string(body))
if strings.HasPrefix(did, "did:") {
return did, nil
}
}
return "", fmt.Errorf("could not resolve handle %s to DID", handle)
}
// resolveHandleViaDNS attempts to resolve handle via DNS TXT record at _atproto.<handle>
func (r *Resolver) resolveHandleViaDNS(handle string) (string, error) {
txtRecords, err := net.LookupTXT("_atproto." + handle)
if err != nil {
return "", err
}
// Look for a TXT record that starts with "did="
for _, record := range txtRecords {
if strings.HasPrefix(record, "did=") {
did := strings.TrimPrefix(record, "did=")
if strings.HasPrefix(did, "did:") {
return did, nil
}
}
}
return "", fmt.Errorf("no valid DID found in DNS TXT records")
}
// DIDDocument represents a simplified ATProto DID document
type DIDDocument struct {
ID string `json:"id"`
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
Service []struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
} `json:"service"`
}
// ResolvePDS resolves a DID to its PDS endpoint
func (r *Resolver) ResolvePDS(ctx context.Context, did string) (string, error) {
if !strings.HasPrefix(did, "did:") {
return "", fmt.Errorf("invalid DID format: %s", did)
}
// Parse DID method
parts := strings.Split(did, ":")
if len(parts) < 3 {
return "", fmt.Errorf("invalid DID format: %s", did)
}
method := parts[1]
var resolverURL string
switch method {
case "plc":
// Use PLC directory
resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
case "web":
// For did:web, convert to HTTPS URL
domain := parts[2]
resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
default:
return "", fmt.Errorf("unsupported DID method: %s", method)
}
req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
if err != nil {
return "", err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
}
var didDoc DIDDocument
if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
return "", fmt.Errorf("failed to parse DID document: %w", err)
}
// Find PDS service endpoint
for _, service := range didDoc.Service {
if service.Type == "AtprotoPersonalDataServer" {
return service.ServiceEndpoint, nil
}
}
return "", fmt.Errorf("no PDS endpoint found in DID document")
}
// ResolveDIDDocument fetches the full DID document for a DID
func (r *Resolver) ResolveDIDDocument(ctx context.Context, did string) (*DIDDocument, error) {
if !strings.HasPrefix(did, "did:") {
return nil, fmt.Errorf("invalid DID format: %s", did)
}
parts := strings.Split(did, ":")
if len(parts) < 3 {
return nil, fmt.Errorf("invalid DID format: %s", did)
}
method := parts[1]
var resolverURL string
switch method {
case "plc":
resolverURL = fmt.Sprintf("https://plc.directory/%s", did)
case "web":
domain := parts[2]
resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
default:
return nil, fmt.Errorf("unsupported DID method: %s", method)
}
req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil)
if err != nil {
return nil, err
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch DID document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DID resolution failed with status %d", resp.StatusCode)
}
var didDoc DIDDocument
if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil {
return nil, fmt.Errorf("failed to parse DID document: %w", err)
}
return &didDoc, nil
}
// ResolveHandle extracts the handle from a DID's alsoKnownAs field
func (r *Resolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) {
didDoc, err := r.ResolveDIDDocument(ctx, did)
if err != nil {
return "", err
}
// Look for handle in alsoKnownAs (format: "at://handle.bsky.social")
for _, aka := range didDoc.AlsoKnownAs {
if strings.HasPrefix(aka, "at://") {
handle := strings.TrimPrefix(aka, "at://")
return handle, nil
}
}
return "", fmt.Errorf("no handle found in DID document")
}
+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)
}
}
}
}
}
+27 -18
View File
@@ -7,6 +7,8 @@ import (
"strings"
"sync"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/distribution/distribution/v3"
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
"github.com/distribution/distribution/v3/registry/storage/driver"
@@ -34,14 +36,15 @@ func init() {
// NamespaceResolver wraps a namespace and resolves names
type NamespaceResolver struct {
distribution.Namespace
resolver *atproto.Resolver
directory identity.Directory
defaultStorageEndpoint string
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
}
// initATProtoResolver initializes the name resolution middleware
func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]any) (distribution.Namespace, error) {
resolver := atproto.NewResolver()
// Use indigo's default directory (includes caching)
directory := identity.DefaultDirectory()
// Get default storage endpoint from config (optional)
defaultStorageEndpoint := ""
@@ -51,7 +54,7 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
return &NamespaceResolver{
Namespace: ns,
resolver: resolver,
directory: directory,
defaultStorageEndpoint: defaultStorageEndpoint,
}, nil
}
@@ -70,21 +73,28 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
return nil, fmt.Errorf("repository name must include user: %s", repoPath)
}
identity := parts[0]
identityStr := parts[0]
imageName := parts[1]
// Resolve identity to DID and PDS
did, pdsEndpoint, err := nr.resolver.ResolveIdentity(ctx, identity)
// Parse identity (handle or DID)
atID, err := syntax.ParseAtIdentifier(identityStr)
if err != nil {
return nil, fmt.Errorf("failed to resolve identity %s: %w", identity, err)
return nil, fmt.Errorf("invalid identity %s: %w", identityStr, err)
}
// Store resolved DID and PDS in context for downstream use
ctx = context.WithValue(ctx, "atproto.did", did)
ctx = context.WithValue(ctx, "atproto.pds", pdsEndpoint)
ctx = context.WithValue(ctx, "atproto.identity", identity)
// Resolve identity to DID and PDS using indigo's directory
ident, err := nr.directory.Lookup(ctx, *atID)
if err != nil {
return nil, fmt.Errorf("failed to resolve identity %s: %w", identityStr, err)
}
fmt.Printf("DEBUG [registry/middleware]: Set context values: did=%s, pds=%s, identity=%s\n", did, pdsEndpoint, identity)
did := ident.DID.String()
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return nil, fmt.Errorf("no PDS endpoint found for %s", identityStr)
}
fmt.Printf("DEBUG [registry/middleware]: Resolved identity: did=%s, pds=%s, handle=%s\n", did, pdsEndpoint, ident.Handle.String())
// Query for storage endpoint - either user's hold or default hold service
storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint)
@@ -98,7 +108,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
// Create a new reference with identity/image format
// Use the identity (or DID) as the namespace to ensure canonical format
// This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path)
canonicalName := fmt.Sprintf("%s/%s", identity, imageName)
canonicalName := fmt.Sprintf("%s/%s", identityStr, imageName)
ref, err := reference.ParseNamed(canonicalName)
if err != nil {
return nil, fmt.Errorf("invalid image name %s: %w", imageName, err)
@@ -119,11 +129,10 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
// Try OAuth flow first
session, err := globalRefresher.GetSession(ctx, did)
if err == nil {
// OAuth session available
accessToken, _ := session.GetHostAccessData()
httpClient := session.APIClient().Client
fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s (length=%d, first_20=%q)\n", did, len(accessToken), accessToken[:min(20, len(accessToken))])
atprotoClient = atproto.NewClientWithHTTPClient(pdsEndpoint, did, accessToken, httpClient)
// OAuth session available - use indigo's API client (handles DPoP automatically)
apiClient := session.APIClient()
fmt.Printf("DEBUG [registry/middleware]: Using OAuth session with indigo API client for DID=%s\n", did)
atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
} else {
fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err)
}
-57
View File
@@ -1,57 +0,0 @@
package middleware
import (
"context"
"fmt"
"github.com/distribution/distribution/v3"
repositorymw "github.com/distribution/distribution/v3/registry/middleware/repository"
"atcr.io/pkg/atproto"
"atcr.io/pkg/storage"
)
func init() {
// Register the ATProto routing middleware
repositorymw.Register("atproto-router", initATProtoRouter)
}
// initATProtoRouter initializes the ATProto routing middleware
func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]any) (distribution.Repository, error) {
fmt.Printf("DEBUG [repository/middleware]: Initializing atproto-router for repo=%s\n", repo.Named().Name())
fmt.Printf("DEBUG [repository/middleware]: Context values: atproto.did=%v, atproto.pds=%v\n",
ctx.Value("atproto.did"), ctx.Value("atproto.pds"))
// Extract DID and PDS from context (set by registry middleware)
did, ok := ctx.Value("atproto.did").(string)
if !ok || did == "" {
fmt.Printf("DEBUG [repository/middleware]: DID not found in context, ok=%v, did=%q\n", ok, did)
return nil, fmt.Errorf("did is required for atproto-router middleware")
}
pdsEndpoint, ok := ctx.Value("atproto.pds").(string)
if !ok || pdsEndpoint == "" {
return nil, fmt.Errorf("pds is required for atproto-router middleware")
}
// For now, use empty access token (we'll add auth later)
accessToken := ""
// Create ATProto client
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
// Get repository name
repoName := repo.Named().Name()
// Get storage endpoint from context
storageEndpoint, ok := ctx.Value("storage.endpoint").(string)
if !ok || storageEndpoint == "" {
return nil, fmt.Errorf("storage.endpoint not found in context")
}
// Create routing repository - no longer uses storage driver
// All blobs are routed through hold service
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repoName, storageEndpoint, did)
return routingRepo, nil
}
+5 -5
View File
@@ -4,21 +4,21 @@ import (
"net/http"
"strings"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
)
// ATProtoHandler wraps an HTTP handler to provide name resolution
// This is an optional layer if middleware doesn't provide enough control
type ATProtoHandler struct {
handler http.Handler
resolver *atproto.Resolver
handler http.Handler
directory identity.Directory
}
// NewATProtoHandler creates a new HTTP handler wrapper
func NewATProtoHandler(handler http.Handler) *ATProtoHandler {
return &ATProtoHandler{
handler: handler,
resolver: atproto.NewResolver(),
handler: handler,
directory: identity.DefaultDirectory(),
}
}