mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-21 01:34:16 +00:00
try and implement getsession and app-password
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user