Do not use in production without auditing by a human!
A vibe-guided Go library for implementing Bluesky OAuth authentication with DPoP (Demonstrating Proof-of-Possession) support.
- Complete OAuth 2.0 authorization flow for Bluesky/AT Protocol
- DPoP token binding for enhanced security
- Automatic nonce handling and retry logic
- Flexible session storage interface
- Helper HTTP handlers for web applications
- Support for posting to Bluesky
- Clean, modular API design
go get github.com/shindakun/bskyoauthFor contributors, install the pre-commit hooks for automatic security checks:
./scripts/install-hooks.shThis installs a git hook that runs before each commit:
gofmt- Code formatting checkgolangci-lint- Code quality and style (20+ linters)govulncheck- Vulnerability scanninggo test -race- Tests with race detectiongo mod verify- Dependency verification
To bypass: git commit --no-verify
package main
import (
"log"
"net/http"
"github.com/shindakun/bskyoauth"
)
func main() {
// Create a new OAuth client
client := bskyoauth.NewClient("http://localhost:8181")
// Set up HTTP handlers
http.HandleFunc("/client-metadata.json", client.ClientMetadataHandler())
http.HandleFunc("/login", client.LoginHandler())
http.HandleFunc("/callback", client.CallbackHandler(handleSuccess))
log.Fatal(http.ListenAndServe(":8181", nil))
}
func handleSuccess(w http.ResponseWriter, r *http.Request, sessionID string) {
// Set session cookie and redirect
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
HttpOnly: true,
})
http.Redirect(w, r, "/", http.StatusFound)
}client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{
BaseURL: "https://myapp.com",
ClientName: "My Bluesky App",
Scopes: []string{"atproto", "transition:generic"},
SessionStore: myCustomStore, // Implement bskyoauth.SessionStore interface
})Configure the OAuth client application type based on your deployment:
// Web application (default) - for web-based applications
client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{
BaseURL: "https://myapp.com",
ApplicationType: bskyoauth.ApplicationTypeWeb, // or omit for default
})
// Native application - for desktop/mobile applications
client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{
BaseURL: "myapp://oauth",
ApplicationType: bskyoauth.ApplicationTypeNative,
})Application Type Values:
ApplicationTypeWeb("web") - Default. For web-based applications- Must use HTTPS redirect URIs (except
localhostfor development) - Suitable for server-side web applications
- Must use HTTPS redirect URIs (except
ApplicationTypeNative("native") - For native/desktop applications- May use custom URI schemes (e.g.,
myapp://oauth) - May use
http://localhostredirect URIs - Suitable for desktop, mobile, or CLI applications
- May use custom URI schemes (e.g.,
Redirect URI Constraints:
Web applications should use HTTPS redirect URIs:
// ✓ Valid for web applications
https://myapp.com/callback
https://localhost:8181/callback // OK for development
// ✗ Invalid for web applications
http://myapp.com/callback // HTTP not allowed (except localhost)
myapp://callback // Custom schemes not allowedNative applications have more flexibility:
// ✓ Valid for native applications
myapp://oauth/callback // Custom URI scheme
http://localhost:8181 // Localhost with HTTP
http://127.0.0.1:8181 // Loopback with HTTP
// ✗ May be rejected by authorization servers
http://myapp.com/callback // HTTP to non-localhostThe application_type is included in the OAuth client metadata and affects how the authorization server validates your redirect URIs according to OpenID Connect Dynamic Client Registration and the AT Protocol OAuth specification.
For more control over the authentication flow:
// Start the OAuth flow
flowState, err := client.StartAuthFlow(ctx, "user.bsky.social")
if err != nil {
log.Fatal(err)
}
// Redirect user to flowState.AuthURL
// After callback with code, state, and issuer:
session, err := client.CompleteAuthFlow(ctx, code, state, issuer)
if err != nil {
log.Fatal(err)
}
// Store the session
sessionID := bskyoauth.GenerateSessionID()
client.SessionStore.Set(sessionID, session)// Retrieve session
session, err := client.GetSession(sessionID)
if err != nil {
log.Fatal(err)
}
// Create a post
err = client.CreatePost(ctx, session, "Hello from bskyoauth!")
if err != nil {
log.Fatal(err)
}Implement the SessionStore interface for custom storage backends:
type SessionStore interface {
Get(sessionID string) (*Session, error)
Set(sessionID string, session *Session) error
Delete(sessionID string) error
}Example with Redis:
⚠️ Security Warning: This example marshals the entireSessionstruct including the DPoP private key in plaintext. This is suitable for development only. For production deployments, see DPoP Key Persistence and Security Considerations below for secure key handling.
type RedisSessionStore struct {
client *redis.Client
}
func (r *RedisSessionStore) Get(sessionID string) (*bskyoauth.Session, error) {
data, err := r.client.Get(ctx, "session:"+sessionID).Bytes()
if err != nil {
return nil, err
}
var session bskyoauth.Session
err = json.Unmarshal(data, &session)
return &session, err
}
func (r *RedisSessionStore) Set(sessionID string, session *bskyoauth.Session) error {
data, err := json.Marshal(session)
if err != nil {
return err
}
return r.client.Set(ctx, "session:"+sessionID, data, 24*time.Hour).Err()
}
func (r *RedisSessionStore) Delete(sessionID string) error {
return r.client.Del(ctx, "session:"+sessionID).Err()
}The built-in MemorySessionStore automatically expires sessions after 30 days (matching cookie lifetime) and cleans them up every 5 minutes. This prevents:
- Memory leaks in long-running applications
- Extended exposure from stolen sessions
- Unbounded memory growth
Custom Session Lifetime:
// 7-day sessions
store := bskyoauth.NewMemorySessionStoreWithTTL(
7*24*time.Hour, // TTL: 7 days
1*time.Hour, // Cleanup interval
)
client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{
BaseURL: "https://example.com",
SessionStore: store,
})When customizing session lifetime, ensure your cookie MaxAge matches:
sessionTTL := 7 * 24 * time.Hour
// Configure session store
store := bskyoauth.NewMemorySessionStoreWithTTL(sessionTTL, 1*time.Hour)
// Configure cookie to match
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
MaxAge: int(sessionTTL.Seconds()), // Must match session TTL
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})Graceful Shutdown:
defer store.Stop() // Stop cleanup goroutine when shutting downThe Session struct includes a DPoPKey field containing an ECDSA P-256 private key used for DPoP (Demonstrating Proof-of-Possession) token binding. By default, this key is ephemeral (exists only in memory) and is lost on application restart. This is a security feature that limits the blast radius if storage is compromised.
| Approach | Security | User Experience | Use Case |
|---|---|---|---|
| Ephemeral Keys (Default) | ✅ High - keys never persisted | Recommended for most applications | |
| Persisted Keys | ✅ Users stay logged in after restart | Long-running mobile/desktop apps | |
| Hybrid | ✅ Good - keys temporary, tokens refreshed | ✅ Good - tokens extend sessions | Recommended for production |
With MemorySessionStore, DPoP keys are ephemeral:
// Keys generated fresh for each OAuth flow
client := bskyoauth.NewClient("https://example.com")
flowState, _ := client.StartAuthFlow(ctx, handle) // New DPoP key created
session, _ := client.CompleteAuthFlow(ctx, code, state, issuer)
// session.DPoPKey exists only in memory
// Lost on application restartBenefits:
- ✅ Maximum security - keys can't be stolen from storage
- ✅ Automatic key rotation on each authentication
- ✅ Zero risk from compromised Redis/database
- ✅ Complies with OAuth 2.0 security best practices
Drawback:
⚠️ Users must re-authenticate after application restart
Recommended for:
- Web applications
- API services
- Short-lived sessions
- Security-critical applications
Best of both worlds - keep keys ephemeral but extend sessions using token refresh:
// Start with ephemeral key
session, _ := client.CompleteAuthFlow(ctx, code, state, issuer)
// Token refresh extends the session WITHOUT persisting keys
if session.IsAccessTokenExpired(5 * time.Minute) {
newSession, err := client.RefreshToken(ctx, session)
if err != nil {
// Refresh failed - redirect to login
http.Redirect(w, r, "/login", http.StatusFound)
return
}
// newSession has SAME DPoPKey (still in memory only)
client.UpdateSession(sessionID, newSession)
}Benefits:
- ✅ Keys remain ephemeral (secure)
- ✅ Token refresh extends sessions (good UX)
- ✅ Re-authentication only needed after restart (acceptable)
- ✅ No encryption key management required
This approach:
- Keeps DPoP keys in memory only
- Uses OAuth token refresh to maintain sessions
- Only requires re-auth if application restarts (rare event)
- Provides good security AND user experience balance
Recommended for:
- Production web applications
- Applications with infrequent restarts
- Teams without encryption infrastructure
If you absolutely need keys to survive restarts (e.g., mobile apps), you MUST encrypt them:
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/x509"
"encoding/json"
"io"
)
type SecureRedisSessionStore struct {
client *redis.Client
gcm cipher.AEAD // AES-256-GCM cipher
}
func NewSecureRedisSessionStore(redisClient *redis.Client, encryptionKey []byte) (*SecureRedisSessionStore, error) {
// encryptionKey must be 32 bytes (AES-256)
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &SecureRedisSessionStore{
client: redisClient,
gcm: gcm,
}, nil
}
func (s *SecureRedisSessionStore) Set(sessionID string, session *bskyoauth.Session) error {
// Serialize DPoP key to DER format
keyBytes, err := x509.MarshalECPrivateKey(session.DPoPKey)
if err != nil {
return fmt.Errorf("failed to marshal key: %w", err)
}
// Generate random nonce
nonce := make([]byte, s.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt the key
encryptedKey := s.gcm.Seal(nonce, nonce, keyBytes, nil)
// Create storage-safe session struct (without private key)
storageSession := struct {
DID string `json:"did"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
EncryptedDPoPKey []byte `json:"encrypted_dpop_key"`
PDS string `json:"pds"`
DPoPNonce string `json:"dpop_nonce"`
AccessTokenExpiresAt time.Time `json:"access_token_expires_at"`
RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"`
}{
DID: session.DID,
AccessToken: session.AccessToken,
RefreshToken: session.RefreshToken,
EncryptedDPoPKey: encryptedKey,
PDS: session.PDS,
DPoPNonce: session.DPoPNonce,
AccessTokenExpiresAt: session.AccessTokenExpiresAt,
RefreshTokenExpiresAt: session.RefreshTokenExpiresAt,
}
data, err := json.Marshal(storageSession)
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
return s.client.Set(context.Background(), "session:"+sessionID, data, 24*time.Hour).Err()
}
func (s *SecureRedisSessionStore) Get(sessionID string) (*bskyoauth.Session, error) {
data, err := s.client.Get(context.Background(), "session:"+sessionID).Bytes()
if err != nil {
return nil, err
}
var storageSession struct {
DID string `json:"did"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
EncryptedDPoPKey []byte `json:"encrypted_dpop_key"`
PDS string `json:"pds"`
DPoPNonce string `json:"dpop_nonce"`
AccessTokenExpiresAt time.Time `json:"access_token_expires_at"`
RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"`
}
if err := json.Unmarshal(data, &storageSession); err != nil {
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
}
// Decrypt the DPoP key
if len(storageSession.EncryptedDPoPKey) < s.gcm.NonceSize() {
return nil, fmt.Errorf("encrypted key too short")
}
nonce := storageSession.EncryptedDPoPKey[:s.gcm.NonceSize()]
ciphertext := storageSession.EncryptedDPoPKey[s.gcm.NonceSize():]
keyBytes, err := s.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key: %w", err)
}
// Parse the decrypted key
dpopKey, err := x509.ParseECPrivateKey(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
return &bskyoauth.Session{
DID: storageSession.DID,
AccessToken: storageSession.AccessToken,
RefreshToken: storageSession.RefreshToken,
DPoPKey: dpopKey,
PDS: storageSession.PDS,
DPoPNonce: storageSession.DPoPNonce,
AccessTokenExpiresAt: storageSession.AccessTokenExpiresAt,
RefreshTokenExpiresAt: storageSession.RefreshTokenExpiresAt,
}, nil
}
func (s *SecureRedisSessionStore) Delete(sessionID string) error {
return s.client.Del(context.Background(), "session:"+sessionID).Err()
}-
Encryption:
- Use AES-256-GCM (or similar AEAD cipher)
- Generate unique nonce for each encryption
- Never reuse nonces
-
Key Management:
- Store encryption key in secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
- NEVER commit encryption keys to version control
- Rotate encryption keys periodically (e.g., every 90 days)
- Use separate keys per environment (dev/staging/prod)
-
Storage Security:
- Enable Redis encryption at rest
- Use TLS for Redis connections
- Restrict Redis network access (firewall rules)
- Enable Redis AUTH
-
Monitoring:
- Log encryption key access
- Monitor for decryption failures (possible tampering)
- Alert on unusual session access patterns
Usage:
// Get encryption key from environment/secrets manager
encryptionKey := getEncryptionKeyFromSecretsManager() // 32 bytes
store, err := NewSecureRedisSessionStore(redisClient, encryptionKey)
if err != nil {
log.Fatal(err)
}
client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{
BaseURL: "https://example.com",
SessionStore: store,
})Recommended for:
- Mobile applications
- Desktop applications
- Applications with very infrequent deployment/restart
NOT recommended for:
- Web applications (use hybrid approach instead)
- Teams without encryption infrastructure
- Applications with frequent deployments
DPoP keys are automatically rotated on each new authentication:
- New OAuth flow = new DPoP key
- Keys are bound to access token lifetime
- No manual rotation needed for ephemeral keys
For persisted keys, implement a rotation policy:
- Rotate encryption keys every 90 days
- Rotate on security events
- Force re-authentication periodically (e.g., every 30 days)
| Scenario | Recommendation |
|---|---|
| Web application | Hybrid approach - ephemeral keys + token refresh |
| API service | Ephemeral keys (default) |
| Mobile app | Encrypted persistence with secrets manager |
| Desktop app | Encrypted persistence with secure key storage |
| High security | Ephemeral keys - maximum security |
| Development | Simple Redis (from example above) is fine |
Default behavior (ephemeral keys) is secure and recommended for most applications.
The library uses Go's standard log/slog for structured logging with environment-based configuration.
By default, the logger is silent (logs to io.Discard). No logging output unless explicitly configured.
The library automatically detects your environment and sets appropriate log levels:
// Development (localhost) - Info level, text format
// Set BASE_URL=http://localhost:8181
logger := bskyoauth.NewLoggerFromEnv("http://localhost:8181")
bskyoauth.SetLogger(logger)
// Production - Error level, JSON format
// Set BASE_URL=https://myapp.com
logger := bskyoauth.NewLoggerFromEnv("https://myapp.com")
bskyoauth.SetLogger(logger)For full control over logging:
// JSON logging at Error level
logger := bskyoauth.NewDefaultLogger(slog.LevelError)
bskyoauth.SetLogger(logger)
// Text logging at Debug level
logger := bskyoauth.NewTextLogger(slog.LevelDebug)
bskyoauth.SetLogger(logger)Add request and session IDs for request correlation:
// In your HTTP handler
func handler(w http.ResponseWriter, r *http.Request) {
// Generate request ID
requestID := bskyoauth.GenerateRequestID()
ctx := bskyoauth.WithRequestID(r.Context(), requestID)
// Add session ID if available
if sessionID, _ := r.Cookie("session_id"); sessionID != nil {
ctx = bskyoauth.WithSessionID(ctx, sessionID.Value)
}
// Pass context to library methods
session, err := client.CompleteAuthFlow(ctx, code, state, iss)
// Logs will include request_id and session_id fields
}The library logs key security and operational events:
OAuth Flow:
- Auth flow initiation and completion
- Token exchange requests and responses
- DPoP nonce retries
- Security events (issuer mismatches, invalid states)
Session Management:
- Session creation, retrieval, and deletion
- Session expiration
- Periodic cleanup operations
API Operations:
- Post creation, record operations
- PDS endpoint lookups
- API request failures
Rate Limiting:
- Rate limit exceeded events
- Limiter cleanup operations
Development (Text Format):
time=2025-01-15T10:30:00.000-07:00 level=INFO msg="starting OAuth flow" handle=alice.bsky.social client_id=http://localhost:8181/oauth-client-metadata.json
time=2025-01-15T10:30:01.234-07:00 level=INFO msg="OAuth flow completed successfully" did=did:plc:abcd1234 issuer=https://bsky.social has_refresh_token=true
Production (JSON Format):
{"time":"2025-01-15T17:30:00.000Z","level":"ERROR","msg":"SECURITY: issuer mismatch detected","expected_issuer":"https://bsky.social","received_issuer":"https://evil.com","did":"did:plc:abcd1234"}Critical security events are always logged at ERROR level:
- Issuer mismatch attacks
- Invalid OAuth states
- Token exchange failures
- Rate limit violations
Access tokens expire after a period of time (typically 1-2 hours). Use refresh tokens to obtain new access tokens without requiring the user to re-authenticate.
session, err := client.GetSession(sessionID)
if err != nil {
log.Fatal(err)
}
// Check if token needs refresh (5 minute safety buffer)
if session.IsAccessTokenExpired(5 * time.Minute) {
newSession, err := client.RefreshToken(ctx, session)
if err != nil {
// Refresh failed - user needs to re-authenticate
return redirectToLogin(w, r)
}
// Update session in store
err = client.UpdateSession(sessionID, newSession)
if err != nil {
log.Fatal(err)
}
session = newSession
}
// Use refreshed session for API calls
err = client.CreatePost(ctx, session, "Hello from refreshed token!")Check token status before making API calls:
// Check if access token is expired or will expire soon
if session.IsAccessTokenExpired(5 * time.Minute) {
// Token needs refresh
}
// Check if refresh token is expired
if session.IsRefreshTokenExpired() {
// Need full re-authentication - redirect to login
}
// Get time remaining until expiration
remaining := session.TimeUntilAccessTokenExpiry()
log.Printf("Token expires in: %v", remaining)Refresh tokens can expire or become invalid. Handle refresh failures gracefully:
newSession, err := client.RefreshToken(ctx, session)
if err != nil {
// Common reasons for failure:
// - Refresh token expired
// - Refresh token revoked
// - Network error
// - PDS unavailable
// User needs to re-authenticate
log.Printf("Token refresh failed: %v", err)
return redirectToLogin(w, r)
}
// Success - update session
client.UpdateSession(sessionID, newSession)While proactive token refresh (checking expiration before API calls) is recommended, you should also handle expired tokens reactively by catching 401 errors:
// Attempt API operation
err := client.CreatePost(ctx, session, "Hello!")
if err != nil {
// Check if error is due to expired token
if strings.Contains(err.Error(), "invalid_token") && strings.Contains(err.Error(), "401") {
log.Printf("Token expired during API call, attempting refresh")
// Refresh the token
newSession, refreshErr := client.RefreshToken(ctx, session)
if refreshErr != nil {
log.Printf("Token refresh failed: %v", refreshErr)
return errors.New("session expired, please log in again")
}
// Update session in your store
if err := updateSessionInStore(sessionID, newSession); err != nil {
return fmt.Errorf("failed to update session: %w", err)
}
// Retry the operation with refreshed token
if err := client.CreatePost(ctx, newSession, "Hello!"); err != nil {
return fmt.Errorf("operation failed after refresh: %w", err)
}
log.Printf("Successfully retried operation after token refresh")
return nil
}
// Other errors - return as-is
return err
}This pattern handles cases where:
- Tokens expire between the expiration check and the API call
- Users return after long periods of inactivity (12+ hours)
- Clock skew causes premature expiration
- Single-Use Tokens: Per AT Protocol spec, refresh tokens are single-use. The old refresh token becomes invalid after a successful refresh.
- DPoP Binding: Token refresh maintains the same DPoP key binding as the original authentication.
- Automatic Expiration: The library tracks token expiration times when available from the server.
- No Expiration Data: If the server doesn't provide expiration times, the helper methods assume tokens are valid.
The library uses sensible default timeouts for all HTTP operations to prevent requests from hanging indefinitely.
- Request Timeout: 30 seconds (total request time)
- Connection Timeout: 10 seconds (TCP handshake)
- TLS Handshake: 10 seconds
- Response Headers: 10 seconds
- Idle Connections: Reused for 90 seconds
Configure custom timeouts for specific requirements:
// Custom HTTP client with shorter timeout
customClient := &http.Client{
Timeout: 10 * time.Second,
}
client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{
BaseURL: "https://myapp.com",
HTTPClient: customClient,
})Use context timeouts for per-request control:
// 5 second timeout for this specific request
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
flowState, err := client.StartAuthFlow(ctx, handle)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("Request timed out")
}
}Override the HTTP client for testing:
// Fast timeout for tests
testClient := &http.Client{Timeout: 1 * time.Second}
bskyoauth.SetHTTPClient(testClient)
defer bskyoauth.SetHTTPClient(bskyoauth.GetHTTPClient())The library provides a helper to detect timeout errors:
_, err := client.StartAuthFlow(ctx, handle)
if err != nil {
if bskyoauth.IsTimeoutError(err) {
log.Println("Request timed out - check network connection")
} else {
log.Printf("Other error: %v", err)
}
}The library provides comprehensive audit logging for security-relevant operations. Audit logs provide a tamper-evident trail of sensitive actions for compliance, security monitoring, and forensic analysis.
- Structured Events: JSON-formatted audit events with standardized fields
- Automatic Enrichment: Context-aware enrichment with request IDs and session IDs
- Flexible Implementations: File-based, rotating files, or custom backends (database, SIEM)
- Thread-Safe: All audit loggers are safe for concurrent use
- Minimal Overhead: No-op default logger (opt-in)
import "github.com/shindakun/bskyoauth"
// 1. Create and configure audit logger
auditLogger, err := bskyoauth.NewRotatingFileAuditLogger("/var/log/myapp")
if err != nil {
log.Fatal(err)
}
defer auditLogger.Close()
// 2. Enable audit logging globally
bskyoauth.SetAuditLogger(auditLogger)
// 3. Use the library normally - audit events are logged automatically
client := bskyoauth.NewClient("https://example.com")
session, _ := client.CompleteAuthFlow(ctx, code, state, issuer)All operations are automatically audited with standardized event types:
Authentication Events:
auth.start- OAuth flow initiatedauth.callback- OAuth callback receivedauth.success- OAuth flow completed successfullyauth.failure- OAuth flow failed
Session Events:
session.created- New session createdsession.refresh- Token refresh performedsession.deleted- Session explicitly deletedsession.expired- Session expired (TTL)
API Operation Events:
api.post.create- Post createdapi.record.create- Custom record createdapi.record.read- Record retrievedapi.record.delete- Record deleted
Security Events:
security.issuer_mismatch- Issuer validation failed (critical)security.invalid_state- Invalid OAuth state parametersecurity.rate_limit- Rate limit exceeded
Each audit event is logged as a JSON line with the following structure:
{
"timestamp": "2024-01-15T10:30:00Z",
"event_type": "auth.success",
"actor": "did:plc:abc123def456",
"action": "complete_oauth_flow",
"resource": "https://bsky.social",
"result": "success",
"request_id": "req-123",
"session_id": "sess-456",
"metadata": {
"ip_address": "192.168.1.1",
"user_agent": "MyApp/1.0"
}
}Field Descriptions:
timestamp- When the event occurred (UTC)event_type- Standardized event category (see constants)actor- DID of the user performing the action (empty for unauthenticated)action- Description of what happenedresource- What was acted upon (handle, AT URI, etc.)result- Either "success" or "failure"error- Error details if result is "failure"metadata- Additional context (IP, user agent, etc.)request_id- Correlation ID from contextsession_id- Session ID from context
Simple file-based logger that appends to a single file:
logger, err := bskyoauth.NewFileAuditLogger("/var/log/myapp/audit.log")
if err != nil {
log.Fatal(err)
}
defer logger.Close()
bskyoauth.SetAuditLogger(logger)Features:
- Append-only mode for tamper resistance
- Restrictive file permissions (0600)
- Thread-safe concurrent writes
- Automatic directory creation
Daily rotating audit log files:
logger, err := bskyoauth.NewRotatingFileAuditLogger("/var/log/myapp")
if err != nil {
log.Fatal(err)
}
defer logger.Close()
bskyoauth.SetAuditLogger(logger)Features:
- Automatic daily rotation at midnight UTC
- Files named:
audit-YYYY-MM-DD.log - Thread-safe rotation and writes
- Preserves old files for compliance
Example File Structure:
/var/log/myapp/
├── audit-2024-01-13.log
├── audit-2024-01-14.log
└── audit-2024-01-15.log # current
Implement the AuditLogger interface for custom backends:
type AuditLogger interface {
Log(ctx context.Context, event AuditEvent) error
}type PostgreSQLAuditLogger struct {
db *sql.DB
}
func (p *PostgreSQLAuditLogger) Log(ctx context.Context, event bskyoauth.AuditEvent) error {
query := `
INSERT INTO audit_log
(timestamp, event_type, actor, action, resource, result, error, metadata, request_id, session_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
metadataJSON, _ := json.Marshal(event.Metadata)
_, err := p.db.ExecContext(ctx, query,
event.Timestamp,
event.EventType,
event.Actor,
event.Action,
event.Resource,
event.Result,
event.Error,
metadataJSON,
event.RequestID,
event.SessionID,
)
return err
}
// Usage
auditLogger := &PostgreSQLAuditLogger{db: db}
bskyoauth.SetAuditLogger(auditLogger)type SplunkAuditLogger struct {
endpoint string
token string
client *http.Client
}
func (s *SplunkAuditLogger) Log(ctx context.Context, event bskyoauth.AuditEvent) error {
payload := map[string]interface{}{
"sourcetype": "bskyoauth_audit",
"event": event,
}
data, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, "POST", s.endpoint, bytes.NewReader(data))
req.Header.Set("Authorization", "Splunk "+s.token)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}For custom events or additional context:
import "github.com/shindakun/bskyoauth"
// Log custom audit event
err := bskyoauth.LogAuditEvent(ctx, bskyoauth.AuditEvent{
EventType: "custom.operation",
Actor: session.DID,
Action: "perform_sensitive_operation",
Resource: "custom-resource-id",
Result: bskyoauth.AuditResultSuccess,
Metadata: map[string]interface{}{
"ip_address": r.RemoteAddr,
"user_agent": r.UserAgent(),
"custom_field": "custom_value",
},
})Audit events are automatically enriched with data from the request context:
import "context"
// Add request ID to context (automatically included in audit events)
ctx := context.WithValue(ctx, bskyoauth.ContextKeyRequestID, "req-123")
// Add session ID to context (automatically included in audit events)
ctx = context.WithValue(ctx, bskyoauth.ContextKeySessionID, "sess-456")
// Now all audit events will include request_id and session_id
client.CreatePost(ctx, session, "Hello world")Log Retention:
# Example: Keep audit logs for 7 years (SOX compliance)
find /var/log/myapp -name "audit-*.log" -mtime +2555 -deleteLog Integrity:
# Generate SHA-256 checksums for tamper detection
sha256sum /var/log/myapp/audit-*.log > audit-checksums.txtAccess Control:
# Restrict audit log access
chmod 600 /var/log/myapp/audit-*.log
chown app-user:app-group /var/log/myappMonitoring:
# Alert on critical security events
grep '"event_type":"security.' /var/log/myapp/audit-*.log | \
jq 'select(.result == "failure")'- Asynchronous Logging: Audit logging is synchronous by default. For high-throughput applications, consider buffering:
type BufferedAuditLogger struct {
underlying bskyoauth.AuditLogger
buffer chan bskyoauth.AuditEvent
}
func NewBufferedAuditLogger(underlying bskyoauth.AuditLogger, bufferSize int) *BufferedAuditLogger {
b := &BufferedAuditLogger{
underlying: underlying,
buffer: make(chan bskyoauth.AuditEvent, bufferSize),
}
go b.processEvents()
return b
}
func (b *BufferedAuditLogger) Log(ctx context.Context, event bskyoauth.AuditEvent) error {
select {
case b.buffer <- event:
return nil
default:
// Buffer full - log synchronously to avoid data loss
return b.underlying.Log(ctx, event)
}
}
func (b *BufferedAuditLogger) processEvents() {
for event := range b.buffer {
b.underlying.Log(context.Background(), event)
}
}- Log Rotation: Use
RotatingFileAuditLoggerto prevent disk space exhaustion - Sampling: For very high traffic, consider sampling non-security events
- Compression: Compress old audit logs to save disk space:
# Compress logs older than 7 days
find /var/log/myapp -name "audit-*.log" -mtime +7 -exec gzip {} \;Audit logging is disabled by default (no-op logger). To explicitly disable after enabling:
bskyoauth.SetAuditLogger(nil) // Reverts to no-op loggerIssuer Mismatch (Code Injection Attack):
{
"timestamp": "2024-01-15T10:30:00Z",
"event_type": "security.issuer_mismatch",
"actor": "did:plc:abc123",
"action": "oauth_callback_issuer_mismatch",
"resource": "https://evil.com",
"result": "failure",
"error": "expected https://bsky.social, got https://evil.com",
"metadata": {
"expected_issuer": "https://bsky.social",
"received_issuer": "https://evil.com"
}
}Invalid State (CSRF Attack):
{
"timestamp": "2024-01-15T10:31:00Z",
"event_type": "security.invalid_state",
"action": "oauth_callback_invalid_state",
"resource": "https://bsky.social",
"result": "failure",
"error": "invalid or expired OAuth state"
}Rate Limit Exceeded:
{
"timestamp": "2024-01-15T10:32:00Z",
"event_type": "security.rate_limit",
"action": "rate_limit_exceeded",
"resource": "/callback",
"result": "failure",
"metadata": {
"ip_address": "192.168.1.100",
"limit": "10 req/min"
}
}A complete web application example is available in examples/web-demo.
To run the example:
cd examples/web-demo
go run main.goThen visit http://localhost:8181 and log in with your Bluesky handle. (You'll probably want to use the Ngrok example below instead.)
The library is organized into several modules:
- client.go - Main API and high-level operations
- oauth.go - OAuth authorization flow implementation
- dpop.go - DPoP proof generation and HTTP transport
- session.go - Session management utilities
- types.go - Core data types and interfaces
CRITICAL: This library MUST be used with HTTPS in production environments.
OAuth 2.0 authorization flows transmit sensitive data including:
- Authorization codes
- State parameters
- Session cookies
- Access tokens
Running OAuth over HTTP exposes these credentials to interception and compromise.
For local development on localhost, HTTP is acceptable. For any production or publicly accessible deployment, HTTPS is mandatory.
- Uses PKCE (Proof Key for Code Exchange) for authorization
- Implements DPoP (Demonstrating Proof-of-Possession) for token binding
- Automatic nonce handling and retry logic
- Secure session ID generation (cryptographically random)
- OAuth state expiration (10-minute TTL) with automatic cleanup
- Issuer validation to prevent authorization code injection attacks
- Comprehensive input validation to prevent injection attacks and resource exhaustion
The library performs comprehensive input validation to prevent errors and security issues:
-
Handles: Validated against AT Protocol handle specification
- Maximum 253 characters total
- Each segment maximum 63 characters
- Only lowercase letters, digits, and hyphens allowed
- Proper format enforcement (no trailing dots, TLD cannot start with digit)
-
Post Text: Limited to 300 characters per AT Protocol spec
- UTF-8 validation
- Null byte rejection
- Whitespace-only text rejection
-
Custom Records: Field-level validation with configurable limits
- Text field validation up to 1000 characters (configurable)
- DateTime format validation
- Deep nesting prevention (max 10 levels)
-
Collection Names: Must be valid NSIDs (e.g., "app.bsky.feed.post")
All validation errors return descriptive error messages for debugging.
Validation functions are exported and can be called directly:
// Validate a handle
if err := bskyoauth.ValidateHandle("alice.bsky.social"); err != nil {
log.Printf("Invalid handle: %v", err)
}
// Validate post text
if err := bskyoauth.ValidatePostText("Hello, world!"); err != nil {
log.Printf("Invalid post text: %v", err)
}
// Validate a custom text field with custom limit
if err := bskyoauth.ValidateTextField(description, "description", 500); err != nil {
log.Printf("Invalid field: %v", err)
}
// Validate record fields
record := map[string]interface{}{
"text": "My post",
"createdAt": "2025-01-15T12:00:00.000Z",
}
if err := bskyoauth.ValidateRecordFields(record); err != nil {
log.Printf("Invalid record: %v", err)
}
// Validate collection NSID
if err := bskyoauth.ValidateCollectionNSID("app.bsky.feed.post"); err != nil {
log.Printf("Invalid collection: %v", err)
}The library includes automatic security headers middleware with built-in support for Bluesky API integration.
Basic Usage (includes Bluesky domains automatically):
mux := http.NewServeMux()
// ... set up handlers ...
handler := bskyoauth.SecurityHeadersMiddleware()(mux)
http.ListenAndServe(":8080", handler)Custom Options:
opts := &bskyoauth.SecurityHeadersOptions{
// Add additional API domains
CSPConnectSrc: []string{
"'self'",
"https://*.bsky.social", // Already included by default
"https://api.myservice.com", // Custom domain
},
// Add custom headers
CustomHeaders: map[string]string{
"X-Custom-Header": "value",
},
// Add additional CSP directives
AdditionalCSPDirectives: map[string][]string{
"media-src": {"'self'", "https://cdn.example.com"},
"worker-src": {"'self'"},
},
}
handler := bskyoauth.SecurityHeadersMiddlewareWithOptions(opts)(mux)
http.ListenAndServe(":8080", handler)Default CSP Policies:
Localhost:
default-src 'self'script-src 'self' 'unsafe-inline' 'unsafe-eval'(for hot-reload)style-src 'self' 'unsafe-inline'img-src 'self' data:connect-src 'self' https://*.bsky.social https://bsky.socialform-action 'self' https://*.bsky.social https://bsky.social
Production:
default-src 'self'script-src 'self'(strict - no unsafe directives)style-src 'self'img-src 'self' data:connect-src 'self' https://*.bsky.social https://bsky.socialform-action 'self' https://*.bsky.social https://bsky.socialframe-ancestors 'none'base-uri 'self'
Bluesky Integration:
The default CSP automatically includes Bluesky domains in both connect-src and form-action, allowing:
- HTML forms to POST directly to Bluesky API endpoints
- Client-side JavaScript API calls to Bluesky servers
- Wildcard
*.bsky.socialsupports user-specific PDS domains
Note: Server-side Go HTTP requests (current implementation) are NOT affected by CSP. The CSP enables browser-based form submissions and API calls to Bluesky.
Headers Applied:
- Content-Security-Policy: Environment-aware (relaxed for localhost, strict for production)
- X-Frame-Options: DENY (prevents clickjacking)
- X-Content-Type-Options: nosniff (prevents MIME-sniffing attacks)
- Strict-Transport-Security: HTTPS production only (not localhost)
- X-XSS-Protection: 1; mode=block
- Referrer-Policy: strict-origin-when-cross-origin
How it Works:
The middleware automatically detects localhost from the HTTP request's Host header:
- Localhost addresses:
localhost,127.0.0.1,[::1],0.0.0.0 - Production: Everything else
HTTPS detection checks:
r.TLS != nil(direct HTTPS connection)X-Forwarded-Proto: https(reverse proxy)
No configuration needed - the middleware works automatically in any deployment scenario, including reverse proxies, Docker, and cloud platforms.
Per the AT Protocol OAuth specification, access tokens are opaque from the client's perspective. This library follows the spec:
- Tokens are treated as opaque strings - no client-side signature validation is performed
- Server-side validation: The PDS (Resource Server) validates tokens when they're used
- DPoP binding: All tokens are bound to unique session keys via DPoP proofs
- Automatic expiration: Tokens include expiration times enforced by the server
While tokens may be JWTs internally, the library does not validate signatures or claims. This is intentional and follows the AT Protocol design where:
- Token validation is the responsibility of the Resource Server (PDS)
- DPoP provides proof-of-possession security
- Tokens are bound to specific client instances and cannot be reused
The library parses JWTs only to extract the user's DID for session management, but treats the token as opaque for all other purposes.
- Use TLS 1.2 or higher
- Obtain certificates from a trusted CA (Let's Encrypt is free)
- Use a reverse proxy (nginx, Caddy, Traefik) for TLS termination
Configure secure session cookies in production:
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
HttpOnly: true, // Prevents JavaScript access
Secure: true, // HTTPS only - REQUIRED in production
SameSite: http.SameSiteLaxMode, // CSRF protection
MaxAge: 86400, // 24 hours (adjust as needed)
})- Use persistent session storage (Redis, database) instead of in-memory store
- Implement session expiration and cleanup
- Consider encrypted storage for sensitive session data
Implement rate limiting on sensitive endpoints:
/login- Prevent brute force attacks/callback- Prevent token exchange attacks- API endpoints - Prevent abuse
# Production
BASE_URL=https://yourdomain.com
# Development
BASE_URL=http://localhost:8181Nginx Example:
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://localhost:8181;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Caddy Example (automatic HTTPS):
yourdomain.com {
reverse_proxy localhost:8181
}
ngrok Example (development/testing with HTTPS):
ngrok provides a quick way to expose your local development server with HTTPS, perfect for testing OAuth flows. Thanks ngrok! :D
# Install ngrok (if not already installed)
# Visit https://ngrok.com/ to sign up and get your auth token
# Start your application on port 8181
cd examples/web-demo
BASE_URL=https://YOUR_SUBDOMAIN.ngrok.app go run main.go
# In another terminal, start ngrok
ngrok http 8181
# ngrok will display a URL like: https://abc123.ngrok.app
# Update your BASE_URL environment variable to match:
BASE_URL=https://abc123.ngrok.app go run main.gongrok Features:
- ✓ Automatic HTTPS with valid certificates
- ✓ Inspect HTTP requests in web interface (http://127.0.0.1:4040)
- ✓ No server configuration required
- ✓ Perfect for development and testing OAuth flows
- ✓ Works with Bluesky's OAuth redirect requirements
Note: ngrok URLs change on each restart with the free tier. For a permanent subdomain, upgrade to a paid plan or use a reverse proxy in production.
- Keep dependencies updated (
go get -u ./...) - Monitor for security advisories
- Implement logging for security events
- Use environment variables for sensitive configuration
- Never commit credentials or secrets to version control
- Application served over HTTPS
- TLS certificates valid and from trusted CA
- Cookie
Secureflag enabled - Cookie
HttpOnlyflag enabled - Cookie
SameSiteattribute set - Session expiration configured
- Persistent session storage implemented
- Rate limiting enabled
- Security headers configured (CSP, HSTS, etc.)
- Dependencies up to date
- Logging and monitoring enabled
- Secrets stored in environment variables or secret manager
- Go 1.21 or later
- Valid Bluesky/AT Protocol account for testing
The example application (examples/web-demo) supports configuration via environment variables for deployment flexibility.
BASE_URL- Base URL for OAuth callbacks- Example:
https://myapp.comorhttp://localhost:8181 - Default:
http://localhost:8181
- Example:
All optional environment variables have sensible defaults. Only override if you need custom values for your deployment.
SESSION_TIMEOUT_DAYS- Session cookie lifetime in days- Default:
30 - Range: 1-365 days (warnings logged outside this range)
- Example:
SESSION_TIMEOUT_DAYS=7for 7-day sessions - Note: Shorter timeouts improve security, longer improves convenience
- Default:
-
RATE_LIMIT_AUTH- Auth endpoint rate limit asrequests/sec,burst- Default:
5,10 - Format:
requests_per_second,burst_size - Example:
RATE_LIMIT_AUTH=10,20for stricter limits - Applies to:
/login,/callbackendpoints - Purpose: Prevent brute force attacks on authentication
- Default:
-
RATE_LIMIT_API- API endpoint rate limit asrequests/sec,burst- Default:
10,20 - Format:
requests_per_second,burst_size - Example:
RATE_LIMIT_API=50,100for higher throughput - Applies to:
/post,/create-record,/delete-record,/get-recordendpoints - Purpose: Prevent API abuse while allowing normal usage
- Default:
SERVER_PORT- HTTP server port- Default:
8181 - Example:
SERVER_PORT=8080 - Useful for container orchestration or avoiding port conflicts
- Default:
BASE_URL=http://localhost:8181
SESSION_TIMEOUT_DAYS=7 # Shorter for testing
RATE_LIMIT_AUTH=10,20 # More permissive
RATE_LIMIT_API=50,100 # Higher for local testing
SERVER_PORT=8181BASE_URL=https://staging.myapp.com
SESSION_TIMEOUT_DAYS=14 # Medium timeout
RATE_LIMIT_AUTH=5,10 # Production-like
RATE_LIMIT_API=20,40 # Moderate limits
SERVER_PORT=8080BASE_URL=https://myapp.com
SESSION_TIMEOUT_DAYS=30 # Standard (default)
RATE_LIMIT_AUTH=5,10 # Strict (default)
RATE_LIMIT_API=10,20 # Conservative (default)
SERVER_PORT=8080BASE_URL=https://myapp.com
SESSION_TIMEOUT_DAYS=30
RATE_LIMIT_AUTH=10,30 # More permissive for high traffic
RATE_LIMIT_API=100,200 # Much higher throughput
SERVER_PORT=8080The application automatically validates configuration on startup:
- Invalid values: Falls back to defaults and logs warnings
- Unusual values: Logs warnings but applies the value
- Format errors: Logs clear error messages with expected format
Example validation output:
⚠️ Warning: Invalid SESSION_TIMEOUT_DAYS value 'abc', using default: 30
⚠️ Warning: Invalid RATE_LIMIT_AUTH format '5' (expected 'req/sec,burst'), using defaults: 5,10
⚠️ Configuration warnings:
- SESSION_TIMEOUT_DAYS=400 is unusual (expected 1-365)
- RATE_LIMIT_API requests/sec=5000.0 is unusual (expected 0.1-1000)
Command line:
BASE_URL=https://myapp.com SESSION_TIMEOUT_DAYS=14 go run examples/web-demo/main.goEnvironment file:
# .env
BASE_URL=https://myapp.com
SESSION_TIMEOUT_DAYS=14
RATE_LIMIT_AUTH=10,20
RATE_LIMIT_API=50,100
SERVER_PORT=8080source .env
go run examples/web-demo/main.goDocker:
ENV BASE_URL=https://myapp.com
ENV SESSION_TIMEOUT_DAYS=30
ENV RATE_LIMIT_AUTH=5,10
ENV RATE_LIMIT_API=20,40
ENV SERVER_PORT=8080Docker Compose:
services:
app:
environment:
- BASE_URL=https://myapp.com
- SESSION_TIMEOUT_DAYS=30
- RATE_LIMIT_AUTH=5,10
- RATE_LIMIT_API=20,40
- SERVER_PORT=8080Kubernetes:
env:
- name: BASE_URL
value: "https://myapp.com"
- name: SESSION_TIMEOUT_DAYS
value: "30"
- name: RATE_LIMIT_AUTH
value: "5,10"
- name: RATE_LIMIT_API
value: "20,40"
- name: SERVER_PORT
value: "8080"Auth Endpoints (login/callback):
- Conservative (default): 5 req/s, burst 10 - Prevents brute force, minimal false positives
- Strict: 2 req/s, burst 5 - Maximum security, may affect legitimate retries
- Permissive: 10 req/s, burst 20 - High-traffic sites with good monitoring
API Endpoints (post/create/delete/get):
- Conservative (default): 10 req/s, burst 20 - Prevents abuse, handles normal usage
- Low-traffic: 5 req/s, burst 10 - Resource-constrained environments
- High-traffic: 50-100 req/s, burst 100-200 - Large user base, active usage
- 7 days: High-security environments, sensitive data
- 14 days: Balanced security and convenience
- 30 days (default): Standard web applications
- 60-90 days: Low-risk applications, convenience-focused
- 365 days: Not recommended (configure if absolutely required)
Contributions are welcome! Please feel free to submit issues or pull requests.
This project is provided as-is for educational and development purposes.