try and implement getsession and app-password

This commit is contained in:
Evan Jarrett
2025-10-22 21:20:40 -05:00
parent 3809bcab25
commit aff5d7248c
11 changed files with 1318 additions and 23 deletions
+5
View File
@@ -100,6 +100,11 @@ func main() {
// Add logging middleware to log all HTTP requests
r.Use(middleware.Logger)
// Add CORS middleware (must be before routes)
if xrpcHandler != nil {
r.Use(xrpcHandler.CORSMiddleware())
}
// Root page
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
+1
View File
@@ -46,6 +46,7 @@ require (
github.com/docker/go-metrics v0.0.1 // indirect
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/cors v1.2.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+2
View File
@@ -68,6 +68,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+23
View File
@@ -120,6 +120,12 @@ const (
// Response: {"accessJwt": "...", "refreshJwt": "...", "did": "...", "handle": "..."}
ServerCreateSession = "/xrpc/com.atproto.server.createSession"
// ServerRefreshSession refreshes an existing session using a refresh token.
// Method: POST
// Headers: Authorization (Bearer <refreshJwt>)
// Response: {"accessJwt": "...", "refreshJwt": "...", "did": "...", "handle": "..."}
ServerRefreshSession = "/xrpc/com.atproto.server.refreshSession"
// ServerGetSession validates a session and returns the current session info.
// Method: GET
// Headers: Authorization (Bearer or DPoP), DPoP (if using DPoP)
@@ -179,3 +185,20 @@ const (
// Response: {"did": "did:plc:..."}
IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle"
)
// Bluesky app endpoints (app.bsky.actor.*)
//
// Bluesky-specific actor/profile endpoints.
const (
// ActorGetProfile retrieves an aggregated profile for an actor.
// Method: GET
// Query: actor={did|handle}
// Response: {"did": "...", "handle": "...", "displayName": "...", "postsCount": ...}
ActorGetProfile = "/xrpc/app.bsky.actor.getProfile"
// ActorGetProfiles retrieves aggregated profiles for multiple actors.
// Method: GET
// Query: actors={did|handle}&actors={did|handle}...
// Response: {"profiles": [{...}, {...}]}
ActorGetProfiles = "/xrpc/app.bsky.actor.getProfiles"
)
+138
View File
@@ -0,0 +1,138 @@
package pds
import (
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
// GenerateAppPassword creates a random app password in the format: abcd-efgh-ijkl-mnop
// Uses base32 encoding for readable characters (no ambiguous chars like 0/O, 1/l)
func GenerateAppPassword() (string, error) {
// Generate 20 random bytes (160 bits of entropy)
// Base32 encoding gives us 32 characters, we'll format as 4 groups of 4
randomBytes := make([]byte, 20)
if _, err := rand.Read(randomBytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encode as base32 and lowercase (base32 alphabet: a-z, 2-7)
encoded := base32.StdEncoding.EncodeToString(randomBytes)
encoded = strings.ToLower(encoded)
// Remove padding and take first 16 characters
encoded = strings.TrimRight(encoded, "=")
if len(encoded) > 16 {
encoded = encoded[:16]
}
// Format as: xxxx-xxxx-xxxx-xxxx
parts := []string{
encoded[0:4],
encoded[4:8],
encoded[8:12],
encoded[12:16],
}
return strings.Join(parts, "-"), nil
}
// HashAppPassword hashes an app password using bcrypt
// Cost is set to 12 for good security without excessive CPU usage
func HashAppPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
return string(hash), nil
}
// ValidateAppPassword compares a plaintext password with a bcrypt hash
func ValidateAppPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// CreateAppPassword generates and stores a new app password
func (p *HoldPDS) CreateAppPassword(name string) (string, error) {
// Generate random password
password, err := GenerateAppPassword()
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
// Hash password
hash, err := HashAppPassword(password)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
// Store in database
if err := p.authDB.CreateAppPassword(name, hash); err != nil {
return "", fmt.Errorf("failed to store password: %w", err)
}
return password, nil
}
// ValidateAppPasswordByName checks if a password matches the stored hash for a given name
func (p *HoldPDS) ValidateAppPasswordByName(name, password string) error {
// Get app password from database
ap, err := p.authDB.GetAppPassword(name)
if err != nil {
return fmt.Errorf("app password not found: %w", err)
}
// Validate password
if !ValidateAppPassword(password, ap.PasswordHash) {
return fmt.Errorf("invalid password")
}
// Update last used timestamp
if err := p.authDB.UpdateLastUsed(name); err != nil {
// Log but don't fail - this is not critical
fmt.Printf("Warning: failed to update last used timestamp: %v\n", err)
}
return nil
}
// ValidateAnyAppPassword checks if a password matches any stored app password
// Returns the name of the matching app password, or error if none match
func (p *HoldPDS) ValidateAnyAppPassword(password string) (string, error) {
// List all app passwords
passwords, err := p.authDB.ListAppPasswords()
if err != nil {
return "", fmt.Errorf("failed to list app passwords: %w", err)
}
// Try each one
for _, ap := range passwords {
// Get full record with hash
fullAP, err := p.authDB.GetAppPassword(ap.Name)
if err != nil {
continue
}
if ValidateAppPassword(password, fullAP.PasswordHash) {
// Update last used
p.authDB.UpdateLastUsed(ap.Name)
return ap.Name, nil
}
}
return "", fmt.Errorf("invalid app password")
}
// ListAppPasswords returns a list of app password names (without hashes)
func (p *HoldPDS) ListAppPasswords() ([]AppPassword, error) {
return p.authDB.ListAppPasswords()
}
// RevokeAppPassword deletes an app password
func (p *HoldPDS) RevokeAppPassword(name string) error {
return p.authDB.DeleteAppPassword(name)
}
+30
View File
@@ -528,3 +528,33 @@ func fetchPublicKeyFromDID(ctx context.Context, did string, httpClient HTTPClien
return publicKey, nil
}
// ValidateJWTAuth validates a request with a JWT access token from createSession
// This is used for authenticated repo operations (createRecord, etc.)
// Returns the validated user DID
func ValidateJWTAuth(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, fmt.Errorf("missing Authorization header")
}
// Remove "Bearer " prefix
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
if accessToken == authHeader {
return nil, fmt.Errorf("invalid authorization header format (expected Bearer)")
}
// Validate access token
claims, err := pds.ValidateAccessToken(accessToken)
if err != nil {
return nil, fmt.Errorf("invalid access token: %w", err)
}
return &ValidatedUser{
DID: claims.DID,
Handle: claims.Handle,
PDS: "",
Authorized: true,
}, nil
}
+232
View File
@@ -0,0 +1,232 @@
package pds
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Database manages app passwords and sessions for the hold PDS
type Database struct {
db *sql.DB
}
// NewDatabase creates or opens a database for app passwords and sessions
// dbPath should be the directory path (same as carstore)
// It creates a separate "auth.db" file for authentication data
func NewDatabase(dbPath string) (*Database, error) {
// Ensure directory exists
if err := os.MkdirAll(dbPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
// Create auth database file alongside carstore database
authDBFile := filepath.Join(dbPath, "auth.db")
db, err := sql.Open("sqlite3", authDBFile)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Create tables
if err := createTables(db); err != nil {
db.Close()
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return &Database{db: db}, nil
}
// createTables creates the database schema
func createTables(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS app_passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_app_passwords_name ON app_passwords(name);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
last_used_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at);
`
_, err := db.Exec(schema)
return err
}
// Close closes the database connection
func (d *Database) Close() error {
return d.db.Close()
}
// AppPassword represents an app password record
type AppPassword struct {
ID int64
Name string
PasswordHash string
CreatedAt time.Time
LastUsedAt *time.Time
}
// CreateAppPassword stores a new app password
func (d *Database) CreateAppPassword(name, passwordHash string) error {
query := `INSERT INTO app_passwords (name, password_hash) VALUES (?, ?)`
_, err := d.db.Exec(query, name, passwordHash)
if err != nil {
return fmt.Errorf("failed to create app password: %w", err)
}
return nil
}
// GetAppPassword retrieves an app password by name
func (d *Database) GetAppPassword(name string) (*AppPassword, error) {
query := `SELECT id, name, password_hash, created_at, last_used_at FROM app_passwords WHERE name = ?`
var ap AppPassword
var lastUsedAt sql.NullTime
err := d.db.QueryRow(query, name).Scan(
&ap.ID,
&ap.Name,
&ap.PasswordHash,
&ap.CreatedAt,
&lastUsedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("app password not found")
}
if err != nil {
return nil, fmt.Errorf("failed to get app password: %w", err)
}
if lastUsedAt.Valid {
ap.LastUsedAt = &lastUsedAt.Time
}
return &ap, nil
}
// ListAppPasswords returns all app passwords (without hashes)
func (d *Database) ListAppPasswords() ([]AppPassword, error) {
query := `SELECT id, name, created_at, last_used_at FROM app_passwords ORDER BY created_at DESC`
rows, err := d.db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to list app passwords: %w", err)
}
defer rows.Close()
var passwords []AppPassword
for rows.Next() {
var ap AppPassword
var lastUsedAt sql.NullTime
if err := rows.Scan(&ap.ID, &ap.Name, &ap.CreatedAt, &lastUsedAt); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
if lastUsedAt.Valid {
ap.LastUsedAt = &lastUsedAt.Time
}
passwords = append(passwords, ap)
}
return passwords, rows.Err()
}
// UpdateLastUsed updates the last used timestamp for an app password
func (d *Database) UpdateLastUsed(name string) error {
query := `UPDATE app_passwords SET last_used_at = CURRENT_TIMESTAMP WHERE name = ?`
_, err := d.db.Exec(query, name)
return err
}
// DeleteAppPassword removes an app password
func (d *Database) DeleteAppPassword(name string) error {
query := `DELETE FROM app_passwords WHERE name = ?`
result, err := d.db.Exec(query, name)
if err != nil {
return fmt.Errorf("failed to delete app password: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to check rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("app password not found")
}
return nil
}
// CreateRefreshToken stores a refresh token
func (d *Database) CreateRefreshToken(tokenHash string, expiresAt time.Time) error {
query := `INSERT INTO refresh_tokens (token_hash, expires_at) VALUES (?, ?)`
_, err := d.db.Exec(query, tokenHash, expiresAt)
if err != nil {
return fmt.Errorf("failed to create refresh token: %w", err)
}
return nil
}
// ValidateRefreshToken checks if a refresh token exists and is not expired
func (d *Database) ValidateRefreshToken(tokenHash string) (bool, error) {
query := `SELECT expires_at FROM refresh_tokens WHERE token_hash = ?`
var expiresAt time.Time
err := d.db.QueryRow(query, tokenHash).Scan(&expiresAt)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to validate refresh token: %w", err)
}
// Check if expired
if time.Now().After(expiresAt) {
// Delete expired token
d.DeleteRefreshToken(tokenHash)
return false, nil
}
// Update last used
updateQuery := `UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?`
d.db.Exec(updateQuery, tokenHash)
return true, nil
}
// DeleteRefreshToken removes a refresh token
func (d *Database) DeleteRefreshToken(tokenHash string) error {
query := `DELETE FROM refresh_tokens WHERE token_hash = ?`
_, err := d.db.Exec(query, tokenHash)
return err
}
// CleanupExpiredTokens removes all expired refresh tokens
func (d *Database) CleanupExpiredTokens() error {
query := `DELETE FROM refresh_tokens WHERE expires_at < CURRENT_TIMESTAMP`
_, err := d.db.Exec(query)
return err
}
+249
View File
@@ -0,0 +1,249 @@
package pds
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
// Session token types
const (
TokenTypeAccess = "access"
TokenTypeRefresh = "refresh"
)
// Token expiration durations
const (
AccessTokenDuration = 2 * time.Hour // Short-lived access token
RefreshTokenDuration = 90 * 24 * time.Hour // Long-lived refresh token (90 days)
)
// SessionClaims represents JWT claims for ATProto sessions
type SessionClaims struct {
DID string `json:"sub"` // Subject (DID)
Issuer string `json:"iss"` // Issuer (PDS DID)
Handle string `json:"handle,omitempty"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
IssuedAt int64 `json:"iat"` // Unix timestamp
ExpiresAt int64 `json:"exp"` // Unix timestamp
}
// IssueAccessToken creates a new access JWT for a session
func (p *HoldPDS) IssueAccessToken(did, handle string) (string, error) {
now := time.Now()
claims := &SessionClaims{
DID: did,
Issuer: p.did,
Handle: handle,
Scope: "com.atproto.access",
TokenType: TokenTypeAccess,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(AccessTokenDuration).Unix(),
}
return p.signJWT(claims)
}
// IssueRefreshToken creates a new refresh JWT for a session
func (p *HoldPDS) IssueRefreshToken(did, handle string) (string, error) {
now := time.Now()
claims := &SessionClaims{
DID: did,
Issuer: p.did,
Handle: handle,
Scope: "com.atproto.refresh",
TokenType: TokenTypeRefresh,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(RefreshTokenDuration).Unix(),
}
signedToken, err := p.signJWT(claims)
if err != nil {
return "", err
}
// Store refresh token hash in database for validation/revocation
tokenHash := hashToken(signedToken)
expiresAt := now.Add(RefreshTokenDuration)
if err := p.authDB.CreateRefreshToken(tokenHash, expiresAt); err != nil {
return "", fmt.Errorf("failed to store refresh token: %w", err)
}
return signedToken, nil
}
// ValidateAccessToken validates an access JWT and returns the claims
func (p *HoldPDS) ValidateAccessToken(tokenString string) (*SessionClaims, error) {
return p.validateToken(tokenString, TokenTypeAccess)
}
// ValidateRefreshToken validates a refresh JWT and returns the claims
// Also checks the database to ensure the token hasn't been revoked
func (p *HoldPDS) ValidateRefreshToken(tokenString string) (*SessionClaims, error) {
// First validate signature and claims
claims, err := p.validateToken(tokenString, TokenTypeRefresh)
if err != nil {
return nil, err
}
// Check if token is in database (not revoked)
tokenHash := hashToken(tokenString)
valid, err := p.authDB.ValidateRefreshToken(tokenHash)
if err != nil {
return nil, fmt.Errorf("failed to validate refresh token in database: %w", err)
}
if !valid {
return nil, fmt.Errorf("refresh token has been revoked or expired")
}
return claims, nil
}
// validateToken validates a JWT token and returns the claims
func (p *HoldPDS) validateToken(tokenString, expectedType string) (*SessionClaims, error) {
// Split token into parts
parts := splitJWT(tokenString)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
// Decode header
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("failed to decode header: %w", err)
}
var header map[string]interface{}
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, fmt.Errorf("failed to parse header: %w", err)
}
// Verify algorithm
alg, ok := header["alg"].(string)
if !ok || alg != "ES256K" {
return nil, fmt.Errorf("unsupported algorithm: %v", alg)
}
// Decode claims
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode claims: %w", err)
}
var claims SessionClaims
if err := json.Unmarshal(claimsBytes, &claims); err != nil {
return nil, fmt.Errorf("failed to parse claims: %w", err)
}
// Verify token type
if claims.TokenType != expectedType {
return nil, fmt.Errorf("invalid token type: expected %s, got %s", expectedType, claims.TokenType)
}
// Verify issuer
if claims.Issuer != p.did {
return nil, fmt.Errorf("invalid issuer: expected %s, got %s", p.did, claims.Issuer)
}
// Verify subject matches this hold
if claims.DID != p.did {
return nil, fmt.Errorf("invalid subject: expected %s, got %s", p.did, claims.DID)
}
// Verify expiration
if time.Now().Unix() > claims.ExpiresAt {
return nil, fmt.Errorf("token has expired")
}
// Verify signature
signedData := []byte(parts[0] + "." + parts[1])
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("failed to decode signature: %w", err)
}
publicKey, err := p.signingKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
if err := publicKey.HashAndVerify(signedData, signature); err != nil {
return nil, fmt.Errorf("signature verification failed: %w", err)
}
return &claims, nil
}
// RevokeRefreshToken revokes a refresh token by removing it from the database
func (p *HoldPDS) RevokeRefreshToken(tokenString string) error {
tokenHash := hashToken(tokenString)
return p.authDB.DeleteRefreshToken(tokenHash)
}
// hashToken creates a SHA-256 hash of a token for storage
func hashToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
// signJWT creates and signs a JWT using the hold's private key
func (p *HoldPDS) signJWT(claims *SessionClaims) (string, error) {
// Create header
header := map[string]interface{}{
"typ": "JWT",
"alg": "ES256K",
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("failed to marshal header: %w", err)
}
// Create payload
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("failed to marshal claims: %w", err)
}
// Base64url encode header and payload
headerEncoded := base64.RawURLEncoding.EncodeToString(headerJSON)
payloadEncoded := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Create signing input
signingInput := headerEncoded + "." + payloadEncoded
// Sign with private key
signature, err := p.signingKey.HashAndSign([]byte(signingInput))
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
// Base64url encode signature
signatureEncoded := base64.RawURLEncoding.EncodeToString(signature)
// Combine into final JWT
jwt := signingInput + "." + signatureEncoded
return jwt, nil
}
// splitJWT splits a JWT string into its three parts
func splitJWT(token string) []string {
// JWT format: header.payload.signature
parts := make([]string, 0, 3)
start := 0
for i := 0; i < len(token); i++ {
if token[i] == '.' {
parts = append(parts, token[start:i])
start = i + 1
}
}
if start < len(token) {
parts = append(parts, token[start:])
}
return parts
}
+40
View File
@@ -36,6 +36,7 @@ type HoldPDS struct {
dbPath string
uid models.Uid
signingKey *atcrypto.PrivateKeyK256
authDB *Database // Authentication database for app passwords and sessions
}
// NewHoldPDS creates or opens a hold PDS with SQLite carstore
@@ -83,6 +84,12 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*H
fmt.Printf("New hold repo - will be initialized in Bootstrap\n")
}
// Create or open authentication database
authDB, err := NewDatabase(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to create auth database: %w", err)
}
return &HoldPDS{
did: did,
PublicURL: publicURL,
@@ -91,6 +98,7 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*H
dbPath: dbPath,
uid: uid,
signingKey: signingKey,
authDB: authDB,
}, nil
}
@@ -194,6 +202,38 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
}
}
// Create bootstrap app password if none exist (one-time setup)
passwords, err := p.authDB.ListAppPasswords()
if err != nil {
return fmt.Errorf("failed to list app passwords: %w", err)
}
if len(passwords) == 0 {
// No app passwords exist, create one
password, err := p.CreateAppPassword("bootstrap")
if err != nil {
return fmt.Errorf("failed to create bootstrap app password: %w", err)
}
fmt.Printf("\n")
fmt.Printf("╔════════════════════════════════════════════════════════════════╗\n")
fmt.Printf("║ 🔑 APP PASSWORD CREATED ║\n")
fmt.Printf("╠════════════════════════════════════════════════════════════════╣\n")
fmt.Printf("║ ║\n")
fmt.Printf("║ Password: %-51s ║\n", password)
fmt.Printf("║ ║\n")
fmt.Printf("║ ⚠️ SAVE THIS PASSWORD - it will not be shown again ║\n")
fmt.Printf("║ ║\n")
fmt.Printf("║ Use this password to log into Bluesky app or CLI tools ║\n")
fmt.Printf("║ PDS URL: %-51s ║\n", p.PublicURL)
fmt.Printf("║ Username: %-50s ║\n", p.did)
fmt.Printf("║ ║\n")
fmt.Printf("╚════════════════════════════════════════════════════════════════╝\n")
fmt.Printf("\n")
} else {
fmt.Printf("✅ App passwords already exist (count: %d), skipping auto-generation\n", len(passwords))
}
return nil
}
+381
View File
@@ -0,0 +1,381 @@
package pds
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
cbg "github.com/whyrusleeping/cbor-gen"
"github.com/ipfs/go-cid"
)
// CreateSessionRequest represents a session creation request
type CreateSessionRequest struct {
Identifier string `json:"identifier"` // DID or handle
Password string `json:"password"` // App password
}
// CreateSessionResponse represents a successful session creation
type CreateSessionResponse struct {
AccessJwt string `json:"accessJwt"`
RefreshJwt string `json:"refreshJwt"`
Handle string `json:"handle"`
DID string `json:"did"`
DIDDoc map[string]interface{} `json:"didDoc,omitempty"` // Optional DID document
Email string `json:"email,omitempty"` // Optional, not used for holds
Active *bool `json:"active,omitempty"` // Optional account status
Status string `json:"status,omitempty"` // Optional account status
}
// SessionInfo represents session information
type SessionInfo struct {
Handle string `json:"handle"`
DID string `json:"did"`
Email string `json:"email,omitempty"`
}
// HandleCreateSession handles com.atproto.server.createSession
func (h *XRPCHandler) HandleCreateSession(w http.ResponseWriter, r *http.Request) {
// Parse request
var req CreateSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
// Validate required fields
if req.Identifier == "" || req.Password == "" {
http.Error(w, "identifier and password are required", http.StatusBadRequest)
return
}
// Validate identifier matches this hold's DID or handle
holdDID := h.pds.DID()
// For did:web, handle is the domain part without "did:web:" prefix
// e.g., "did:web:hold01.atcr.io" -> "hold01.atcr.io"
holdHandle := strings.TrimPrefix(holdDID, "did:web:")
// Normalize the identifier (strip "at://" prefix if present)
identifier := strings.TrimPrefix(req.Identifier, "at://")
// Accept any of:
// 1. Full DID: "did:web:hold01.atcr.io"
// 2. Handle (domain): "hold01.atcr.io"
// 3. Either with "at://" prefix
isValidIdentifier := identifier == holdDID || identifier == holdHandle
if !isValidIdentifier {
fmt.Printf("Invalid identifier: got %q, expected DID %q or handle %q\n", req.Identifier, holdDID, holdHandle)
http.Error(w, "invalid identifier", http.StatusUnauthorized)
return
}
// Validate app password
_, err := h.pds.ValidateAnyAppPassword(req.Password)
if err != nil {
http.Error(w, "invalid password", http.StatusUnauthorized)
return
}
// Issue access and refresh tokens
accessToken, err := h.pds.IssueAccessToken(holdDID, holdHandle)
if err != nil {
http.Error(w, fmt.Sprintf("failed to issue access token: %v", err), http.StatusInternalServerError)
return
}
refreshToken, err := h.pds.IssueRefreshToken(holdDID, holdHandle)
if err != nil {
http.Error(w, fmt.Sprintf("failed to issue refresh token: %v", err), http.StatusInternalServerError)
return
}
// Return session response
active := true
response := CreateSessionResponse{
AccessJwt: accessToken,
RefreshJwt: refreshToken,
Handle: holdHandle,
DID: holdDID,
Active: &active, // Account is active
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleRefreshSession handles com.atproto.server.refreshSession
func (h *XRPCHandler) HandleRefreshSession(w http.ResponseWriter, r *http.Request) {
// Extract refresh token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "authorization header required", http.StatusUnauthorized)
return
}
// Remove "Bearer " prefix
refreshToken := strings.TrimPrefix(authHeader, "Bearer ")
if refreshToken == authHeader {
http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
return
}
// Validate refresh token
claims, err := h.pds.ValidateRefreshToken(refreshToken)
if err != nil {
http.Error(w, fmt.Sprintf("invalid refresh token: %v", err), http.StatusUnauthorized)
return
}
// Issue new access token (and optionally new refresh token)
accessToken, err := h.pds.IssueAccessToken(claims.DID, claims.Handle)
if err != nil {
http.Error(w, fmt.Sprintf("failed to issue access token: %v", err), http.StatusInternalServerError)
return
}
// Issue new refresh token (rotate refresh tokens for security)
newRefreshToken, err := h.pds.IssueRefreshToken(claims.DID, claims.Handle)
if err != nil {
http.Error(w, fmt.Sprintf("failed to issue refresh token: %v", err), http.StatusInternalServerError)
return
}
// Revoke old refresh token
if err := h.pds.RevokeRefreshToken(refreshToken); err != nil {
// Log but don't fail - new tokens are already issued
fmt.Printf("Warning: failed to revoke old refresh token: %v\n", err)
}
// Return new tokens
active := true
response := CreateSessionResponse{
AccessJwt: accessToken,
RefreshJwt: newRefreshToken,
Handle: claims.Handle,
DID: claims.DID,
Active: &active, // Account is active
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGetSession handles com.atproto.server.getSession
func (h *XRPCHandler) HandleGetSession(w http.ResponseWriter, r *http.Request) {
// Extract access token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "authorization header required", http.StatusUnauthorized)
return
}
// Remove "Bearer " prefix
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
if accessToken == authHeader {
http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
return
}
// Validate access token
claims, err := h.pds.ValidateAccessToken(accessToken)
if err != nil {
http.Error(w, fmt.Sprintf("invalid access token: %v", err), http.StatusUnauthorized)
return
}
// Return session info
response := SessionInfo{
Handle: claims.Handle,
DID: claims.DID,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// CreateRecordRequest represents a record creation request
type CreateRecordRequest struct {
Repo string `json:"repo"` // DID of the repository
Collection string `json:"collection"` // Collection name (e.g., "app.bsky.feed.post")
Rkey string `json:"rkey,omitempty"` // Optional record key (TID generated if not provided)
Validate *bool `json:"validate,omitempty"` // Optional validation flag
Record interface{} `json:"record"` // The record value (JSON object)
}
// CreateRecordResponse represents a successful record creation
type CreateRecordResponse struct {
URI string `json:"uri"` // at://did/collection/rkey
CID string `json:"cid"` // Record CID
}
// RawRecord wraps a record value and implements CBORMarshaler
// This allows us to accept any JSON record and marshal it to CBOR
type RawRecord struct {
Value map[string]interface{}
}
// MarshalCBOR implements CBORMarshaler for RawRecord
func (r *RawRecord) MarshalCBOR(w io.Writer) error {
// Write CBOR map header
if err := cbg.WriteMajorTypeHeader(w, cbg.MajMap, uint64(len(r.Value))); err != nil {
return err
}
// Write each key-value pair
for key, val := range r.Value {
// Write key as text string
if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(key))); err != nil {
return err
}
if _, err := w.Write([]byte(key)); err != nil {
return err
}
// Write value (simplified - handles common types)
if err := writeValue(w, val); err != nil {
return err
}
}
return nil
}
// writeValue writes a value to CBOR (helper for RawRecord)
func writeValue(w io.Writer, val interface{}) error {
switch v := val.(type) {
case string:
if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(v))); err != nil {
return err
}
_, err := w.Write([]byte(v))
return err
case int64:
return cbg.CborWriteHeader(w, cbg.MajUnsignedInt, uint64(v))
case float64:
// Write as unsigned int for now (simplified)
return cbg.CborWriteHeader(w, cbg.MajUnsignedInt, uint64(v))
case bool:
if v {
return cbg.WriteBool(w, true)
}
return cbg.WriteBool(w, false)
case map[string]interface{}:
rec := &RawRecord{Value: v}
return rec.MarshalCBOR(w)
case []interface{}:
if err := cbg.WriteMajorTypeHeader(w, cbg.MajArray, uint64(len(v))); err != nil {
return err
}
for _, item := range v {
if err := writeValue(w, item); err != nil {
return err
}
}
return nil
default:
// For unknown types, convert to JSON then write as string
jsonBytes, err := json.Marshal(v)
if err != nil {
return err
}
if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(jsonBytes))); err != nil {
return err
}
_, err = w.Write(jsonBytes)
return err
}
}
// HandleCreateRecord handles com.atproto.repo.createRecord
func (h *XRPCHandler) HandleCreateRecord(w http.ResponseWriter, r *http.Request) {
// Validate JWT authentication
user, err := ValidateJWTAuth(r, h.pds)
if err != nil {
http.Error(w, fmt.Sprintf("authentication required: %v", err), http.StatusUnauthorized)
return
}
// Parse request
var req CreateRecordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
// Validate required fields
if req.Repo == "" || req.Collection == "" || req.Record == nil {
http.Error(w, "repo, collection, and record are required", http.StatusBadRequest)
return
}
// Verify repo matches authenticated user
if req.Repo != user.DID {
http.Error(w, "repo must match authenticated user DID", http.StatusForbidden)
return
}
// Verify repo matches this hold's DID
if req.Repo != h.pds.DID() {
http.Error(w, "invalid repo (must be this hold's DID)", http.StatusBadRequest)
return
}
// Convert record from JSON to CBOR-marshalable format
recordMap, ok := req.Record.(map[string]interface{})
if !ok {
http.Error(w, "record must be a JSON object", http.StatusBadRequest)
return
}
// Wrap in RawRecord which implements CBORMarshaler
recordValue := &RawRecord{Value: recordMap}
// Create record using repomgr
var recordPath string
var recordCID cid.Cid
if req.Rkey != "" {
// Use PutRecord if rkey is specified
recordPath, recordCID, err = h.pds.repomgr.PutRecord(
r.Context(),
h.pds.uid,
req.Collection,
req.Rkey,
recordValue,
)
} else {
// Use CreateRecord if no rkey (auto-generates TID)
recordPath, recordCID, err = h.pds.repomgr.CreateRecord(
r.Context(),
h.pds.uid,
req.Collection,
recordValue,
)
}
if err != nil {
http.Error(w, fmt.Sprintf("failed to create record: %v", err), http.StatusInternalServerError)
return
}
// Extract rkey from path (format: "collection/rkey")
parts := strings.Split(recordPath, "/")
if len(parts) < 2 {
http.Error(w, "invalid record path returned", http.StatusInternalServerError)
return
}
actualRkey := parts[len(parts)-1]
// Return success response
response := CreateRecordResponse{
URI: fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), req.Collection, actualRkey),
CID: recordCID.String(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
+217 -23
View File
@@ -8,6 +8,7 @@ import (
"atcr.io/pkg/atproto"
"atcr.io/pkg/s3"
"github.com/bluesky-social/indigo/api/bsky"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
"github.com/distribution/distribution/v3/registry/storage/driver"
@@ -73,21 +74,31 @@ func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, storageDriver driver.S
}
}
// corsMiddleware is chi-compatible middleware that adds CORS headers
func (h *XRPCHandler) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, DPoP, X-Upload-Id, X-Part-Number, X-ATCR-DID")
// CORSMiddleware returns a simple CORS middleware configured for ATProto
// This should be applied in the main router before registering any routes
func (h *XRPCHandler) CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers for all requests
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*"
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Expose-Headers", "*")
w.Header().Set("Access-Control-Max-Age", "300")
// Handle preflight OPTIONS requests
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Handle OPTIONS preflight
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
next.ServeHTTP(w, r)
})
}
}
// requireOwnerOrCrewAdmin middleware - validates owner or crew admin access
@@ -131,15 +142,19 @@ func getUserFromContext(r *http.Request) *ValidatedUser {
}
// RegisterHandlers registers all XRPC endpoints using chi router
// Note: CORS middleware must be applied in the main router before calling this
func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// Public read-only endpoints (CORS only, no auth)
// Public read-only endpoints (no auth)
r.Group(func(r chi.Router) {
r.Use(h.corsMiddleware)
// Health and server info
r.Get("/xrpc/_health", h.HandleHealth)
r.Get(atproto.ServerDescribeServer, h.HandleDescribeServer)
// Session management (public - creates sessions)
r.Post(atproto.ServerCreateSession, h.HandleCreateSession)
r.Post(atproto.ServerRefreshSession, h.HandleRefreshSession)
r.Get(atproto.ServerGetSession, h.HandleGetSession)
// Repository metadata
r.Get(atproto.RepoDescribeRepo, h.HandleDescribeRepo)
r.Get(atproto.RepoGetRecord, h.HandleGetRecord)
@@ -154,33 +169,40 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// DID document and handle resolution
r.Get("/.well-known/did.json", h.HandleDIDDocument)
r.Get("/.well-known/atproto-did", h.HandleAtprotoDID)
// Identity and profile endpoints
r.Get(atproto.IdentityResolveHandle, h.HandleResolveHandle)
r.Get(atproto.ActorGetProfile, h.HandleGetProfile)
r.Get(atproto.ActorGetProfiles, h.HandleGetProfiles)
})
// Blob read endpoints (CORS + conditional auth based on captain.public)
// Blob read endpoints (conditional auth based on captain.public)
// Auth is handled inside HandleGetBlob
r.Group(func(r chi.Router) {
r.Use(h.corsMiddleware)
r.Get(atproto.SyncGetBlob, h.HandleGetBlob)
r.Head(atproto.SyncGetBlob, h.HandleGetBlob)
})
// Write endpoints (CORS + owner/crew admin auth)
// Write endpoints (owner/crew admin auth)
r.Group(func(r chi.Router) {
r.Use(h.corsMiddleware)
r.Use(h.requireOwnerOrCrewAdmin)
r.Post(atproto.RepoDeleteRecord, h.HandleDeleteRecord)
r.Post(atproto.RepoUploadBlob, h.HandleUploadBlob)
})
// Auth-only endpoints (CORS + DPoP auth)
// Auth-only endpoints (DPoP auth)
r.Group(func(r chi.Router) {
r.Use(h.corsMiddleware)
r.Use(h.requireAuth)
r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
})
// JWT-authenticated endpoints (JWT auth from createSession)
// Note: JWT auth is validated inside each handler
r.Group(func(r chi.Router) {
r.Post("/xrpc/com.atproto.repo.createRecord", h.HandleCreateRecord)
})
}
// HandleHealth returns health check information
@@ -213,6 +235,178 @@ func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Reques
json.NewEncoder(w).Encode(response)
}
// HandleResolveHandle resolves a handle to a DID
func (h *XRPCHandler) HandleResolveHandle(w http.ResponseWriter, r *http.Request) {
// Get handle parameter
handle := r.URL.Query().Get("handle")
if handle == "" {
http.Error(w, "handle parameter required", http.StatusBadRequest)
return
}
// For this hold PDS, the handle is the domain part of the DID
// e.g., "hold01.atcr.io did:web:hold01.atcr.io"
expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:")
// Check if the handle matches
if handle != expectedHandle {
http.Error(w, "handle not found", http.StatusNotFound)
return
}
// Return the DID
response := map[string]string{
"did": h.pds.DID(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGetProfile returns aggregated profile information
func (h *XRPCHandler) HandleGetProfile(w http.ResponseWriter, r *http.Request) {
// Get actor parameter (can be DID or handle)
actor := r.URL.Query().Get("actor")
if actor == "" {
http.Error(w, "actor parameter required", http.StatusBadRequest)
return
}
// Normalize actor to DID
actorDID := actor
if !strings.HasPrefix(actor, "did:") {
// It's a handle, resolve to DID
expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:")
if actor == expectedHandle {
actorDID = h.pds.DID()
} else {
http.Error(w, "actor not found", http.StatusNotFound)
return
}
}
// Verify it's this hold's DID
if actorDID != h.pds.DID() {
http.Error(w, "actor not found", http.StatusNotFound)
return
}
// Build profile response using shared function
response := h.buildProfileResponse(r.Context())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGetProfiles returns aggregated profile information for multiple actors
func (h *XRPCHandler) HandleGetProfiles(w http.ResponseWriter, r *http.Request) {
// Get actors parameters (can be multiple)
actors := r.URL.Query()["actors"]
if len(actors) == 0 {
http.Error(w, "actors parameter required", http.StatusBadRequest)
return
}
// Initialize profiles array (always return array, even if empty)
profiles := []map[string]any{}
// Expected handle for this hold
expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:")
// Check each actor to see if it matches this hold's DID
for _, actor := range actors {
// Normalize actor to DID
actorDID := actor
if !strings.HasPrefix(actor, "did:") {
// It's a handle, check if it matches
if actor == expectedHandle {
actorDID = h.pds.DID()
} else {
// Not this hold's handle, skip
continue
}
}
// Check if it's this hold's DID
if actorDID != h.pds.DID() {
// Not this hold, skip
continue
}
// Build profile for this hold
profile := h.buildProfileResponse(r.Context())
if profile != nil {
profiles = append(profiles, profile)
}
}
// Return profiles array
response := map[string]any{
"profiles": profiles,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles)
func (h *XRPCHandler) buildProfileResponse(ctx context.Context) map[string]any {
// Get profile record from repo
_, profileVal, err := h.pds.repomgr.GetRecord(
ctx,
h.pds.uid,
"app.bsky.actor.profile",
"self",
cid.Undef,
)
// Base response with minimal info
response := map[string]any{
"did": h.pds.DID(),
"handle": strings.TrimPrefix(h.pds.DID(), "did:web:"),
"postsCount": 0,
"followersCount": 0,
"followsCount": 0,
}
// Count posts
session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
if err == nil {
head, err := h.pds.carstore.GetUserRepoHead(ctx, h.pds.uid)
if err == nil && head.Defined() {
repoHandle, err := repo.OpenRepo(ctx, session, head)
if err == nil {
postCount := 0
_ = repoHandle.ForEach(ctx, "app.bsky.feed.post", func(k string, v cid.Cid) error {
postCount++
return nil
})
response["postsCount"] = postCount
}
}
}
// Add profile fields if profile record exists
if err == nil {
profileRecord, ok := profileVal.(*bsky.ActorProfile)
if ok {
if profileRecord.DisplayName != nil && *profileRecord.DisplayName != "" {
response["displayName"] = *profileRecord.DisplayName
}
if profileRecord.Description != nil && *profileRecord.Description != "" {
response["description"] = *profileRecord.Description
}
if profileRecord.Avatar != nil {
avatarURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
h.pds.PublicURL, h.pds.DID(), profileRecord.Avatar.Ref.String())
response["avatar"] = avatarURL
}
}
}
return response
}
// HandleDescribeRepo returns repository information
func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) {
// Get repo parameter