A community based topic aggregation platform built on atproto

feat(oauth): add dev mode build tags and mobile OAuth improvements

- Add //go:build dev tags to dev_resolver.go and dev_auth_resolver.go
- Create dev_stubs.go with production stubs (//go:build !dev)
- Fix mobile OAuth flow: localhost→127.0.0.1 redirect for cookie consistency
- Fix handle verification via local PDS in callback handler
- Use config.PublicURL for OAuth callback instead of hardcoded localhost
- Add build-dev Makefile target for dev builds
- Update dev-run.sh to use -tags dev
- Create .env.dev.example template (safe to commit)
- Document dev mode configuration in .env.dev

Dev mode code is now physically excluded from production builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+18
.env.dev
···
PLC_DIRECTORY_URL=http://localhost:3002
# =============================================================================
# Notes
# =============================================================================
# All local development configuration in one file!
···
PLC_DIRECTORY_URL=http://localhost:3002
# =============================================================================
+
# Dev Mode Quick Reference
+
# =============================================================================
+
# REQUIRED for local OAuth to work with local PDS:
+
# IS_DEV_ENV=true # Master switch for dev mode
+
# PDS_URL=http://localhost:3001 # Local PDS for handle resolution
+
# PLC_DIRECTORY_URL=http://localhost:3002 # Local PLC directory
+
# APPVIEW_PUBLIC_URL=http://127.0.0.1:8081 # Use IP not localhost (RFC 8252)
+
#
+
# BUILD TAGS:
+
# make run - Runs with -tags dev (includes localhost OAuth resolvers)
+
# make build - Production binary (no dev code)
+
# make build-dev - Dev binary (includes dev code)
+
#
+
# Dev-only code (only compiled with -tags dev):
+
# - internal/atproto/oauth/dev_resolver.go (handle resolution via local PDS)
+
# - internal/atproto/oauth/dev_auth_resolver.go (localhost OAuth bypass)
+
#
+
# =============================================================================
# Notes
# =============================================================================
# All local development configuration in one file!
+92
.env.dev.example
···
···
+
# Coves Local Development Environment Configuration
+
# Copy this to .env.dev and fill in your values
+
#
+
# Quick Start:
+
# 1. cp .env.dev.example .env.dev
+
# 2. Generate OAuth key: go run cmd/genjwks/main.go (copy output to OAUTH_PRIVATE_JWK)
+
# 3. Generate cookie secret: openssl rand -hex 32
+
# 4. make dev-up # Start Docker services
+
# 5. make run # Start the server (uses -tags dev)
+
+
# =============================================================================
+
# Dev Mode Quick Reference
+
# =============================================================================
+
# REQUIRED for local OAuth to work with local PDS:
+
# IS_DEV_ENV=true # Master switch for dev mode
+
# PDS_URL=http://localhost:3001 # Local PDS for handle resolution
+
# PLC_DIRECTORY_URL=http://localhost:3002 # Local PLC directory
+
# APPVIEW_PUBLIC_URL=http://127.0.0.1:8081 # Use IP not localhost (RFC 8252)
+
#
+
# BUILD TAGS:
+
# make run - Runs with -tags dev (includes localhost OAuth resolvers)
+
# make build - Production binary (no dev code)
+
# make build-dev - Dev binary (includes dev code)
+
+
# =============================================================================
+
# PostgreSQL Configuration
+
# =============================================================================
+
POSTGRES_HOST=localhost
+
POSTGRES_PORT=5435
+
POSTGRES_DB=coves_dev
+
POSTGRES_USER=dev_user
+
POSTGRES_PASSWORD=dev_password
+
+
# Test database
+
POSTGRES_TEST_DB=coves_test
+
POSTGRES_TEST_USER=test_user
+
POSTGRES_TEST_PASSWORD=test_password
+
POSTGRES_TEST_PORT=5434
+
+
# =============================================================================
+
# PDS Configuration
+
# =============================================================================
+
PDS_HOSTNAME=localhost
+
PDS_PORT=3001
+
PDS_SERVICE_ENDPOINT=http://localhost:3000
+
PDS_DID_PLC_URL=http://plc-directory:3000
+
PDS_JWT_SECRET=local-dev-jwt-secret-change-in-production
+
PDS_ADMIN_PASSWORD=admin
+
PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social
+
PDS_PLC_ROTATION_KEY=<generate-a-random-hex-key>
+
+
# =============================================================================
+
# AppView Configuration
+
# =============================================================================
+
APPVIEW_PORT=8081
+
FIREHOSE_URL=ws://localhost:3001/xrpc/com.atproto.sync.subscribeRepos
+
PDS_URL=http://localhost:3001
+
APPVIEW_PUBLIC_URL=http://127.0.0.1:8081
+
+
# =============================================================================
+
# Jetstream Configuration
+
# =============================================================================
+
JETSTREAM_URL=ws://localhost:6008/subscribe
+
+
# =============================================================================
+
# Identity Resolution
+
# =============================================================================
+
IDENTITY_CACHE_TTL=24h
+
PLC_DIRECTORY_URL=http://localhost:3002
+
+
# =============================================================================
+
# OAuth Configuration (MUST GENERATE YOUR OWN)
+
# =============================================================================
+
# Generate with: go run cmd/genjwks/main.go
+
OAUTH_PRIVATE_JWK=<generate-your-own-jwk>
+
+
# Generate with: openssl rand -hex 32
+
OAUTH_COOKIE_SECRET=<generate-your-own-secret>
+
+
# =============================================================================
+
# Development Settings
+
# =============================================================================
+
ENV=development
+
NODE_ENV=development
+
IS_DEV_ENV=true
+
LOG_LEVEL=debug
+
LOG_ENABLED=true
+
+
# Security settings (ONLY for local dev - set to false in production!)
+
SKIP_DID_WEB_VERIFICATION=true
+
AUTH_SKIP_VERIFY=true
+
HS256_ISSUERS=http://localhost:3001
+25 -3
Makefile
···
-
.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean
# Default target - show help
.DEFAULT_GOAL := help
···
##@ Build & Run
-
build: ## Build the Coves server
-
@echo "$(GREEN)Building Coves server...$(RESET)"
@go build -o server ./cmd/server
@echo "$(GREEN)✓ Build complete: ./server$(RESET)"
run: ## Run the Coves server with dev environment (requires database running)
@./scripts/dev-run.sh
···
@echo "$(YELLOW)Removing Android port forwarding...$(RESET)"
@adb reverse --remove-all || echo "$(YELLOW)No device connected$(RESET)"
@echo "$(GREEN)✓ Port forwarding removed$(RESET)"
ngrok-up: ## Start ngrok tunnels (for iOS or WiFi testing - requires paid plan for 3 tunnels)
@echo "$(GREEN)Starting ngrok tunnels for mobile testing...$(RESET)"
···
+
.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean verify-stack create-test-account mobile-full-setup
# Default target - show help
.DEFAULT_GOAL := help
···
##@ Build & Run
+
build: ## Build the Coves server (production - no dev code)
+
@echo "$(GREEN)Building Coves server (production)...$(RESET)"
@go build -o server ./cmd/server
@echo "$(GREEN)✓ Build complete: ./server$(RESET)"
+
+
build-dev: ## Build the Coves server with dev mode (includes localhost OAuth resolvers)
+
@echo "$(GREEN)Building Coves server (dev mode)...$(RESET)"
+
@go build -tags dev -o server ./cmd/server
+
@echo "$(GREEN)✓ Build complete: ./server (with dev tags)$(RESET)"
run: ## Run the Coves server with dev environment (requires database running)
@./scripts/dev-run.sh
···
@echo "$(YELLOW)Removing Android port forwarding...$(RESET)"
@adb reverse --remove-all || echo "$(YELLOW)No device connected$(RESET)"
@echo "$(GREEN)✓ Port forwarding removed$(RESET)"
+
+
verify-stack: ## Verify local development stack (PLC, PDS, configs)
+
@./scripts/verify-local-stack.sh
+
+
create-test-account: ## Create a test account on local PDS for OAuth testing
+
@./scripts/create-test-account.sh
+
+
mobile-full-setup: verify-stack create-test-account mobile-setup ## Full mobile setup: verify stack, create account, setup ports
+
@echo ""
+
@echo "$(GREEN)═══════════════════════════════════════════════════════════$(RESET)"
+
@echo "$(GREEN) Mobile development environment ready! $(RESET)"
+
@echo "$(GREEN)═══════════════════════════════════════════════════════════$(RESET)"
+
@echo ""
+
@echo "$(CYAN)Run the Flutter app with:$(RESET)"
+
@echo " $(YELLOW)cd /home/bretton/Code/coves-mobile$(RESET)"
+
@echo " $(YELLOW)flutter run --dart-define=ENVIRONMENT=local$(RESET)"
+
@echo ""
ngrok-up: ## Start ngrok tunnels (for iOS or WiFi testing - requires paid plan for 3 tunnels)
@echo "$(GREEN)Starting ngrok tunnels for mobile testing...$(RESET)"
+3
cmd/server/main.go
···
if plcURL == "" {
plcURL = "https://plc.directory"
}
// Initialize OAuth client for sealed session tokens
// Mobile apps authenticate via OAuth flow and receive sealed session tokens
···
}
isDevMode := os.Getenv("IS_DEV_ENV") == "true"
oauthConfig := &oauth.OAuthConfig{
PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"),
SealSecret: oauthSealSecret,
···
DevMode: isDevMode,
AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode
PLCURL: plcURL,
// SessionTTL and SealedTokenTTL will use defaults if not set (7 days and 14 days)
}
···
if plcURL == "" {
plcURL = "https://plc.directory"
}
+
log.Printf("🔐 OAuth will use PLC directory: %s", plcURL)
// Initialize OAuth client for sealed session tokens
// Mobile apps authenticate via OAuth flow and receive sealed session tokens
···
}
isDevMode := os.Getenv("IS_DEV_ENV") == "true"
+
pdsURL := os.Getenv("PDS_URL") // For dev mode: resolve handles via local PDS
oauthConfig := &oauth.OAuthConfig{
PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"),
SealSecret: oauthSealSecret,
···
DevMode: isDevMode,
AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode
PLCURL: plcURL,
+
PDSURL: pdsURL, // For dev mode handle resolution
// SessionTTL and SealedTokenTTL will use defaults if not set (7 days and 14 days)
}
+5 -1
docker-compose.dev.yml
···
# Bluesky Personal Data Server (PDS)
# Handles user repositories, DIDs, and CAR files
pds:
image: ghcr.io/bluesky-social/pds:latest
container_name: coves-dev-pds
···
PDS_PORT: 3001 # Match external port for correct DID registration
PDS_DATA_DIRECTORY: /pds
PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks
-
PDS_DID_PLC_URL: ${PDS_DID_PLC_URL:-https://plc.directory}
# PDS_CRAWLERS not needed - we're not using a relay for local dev
# Note: PDS uses its own internal SQLite database and CAR file storage
···
# Bluesky Personal Data Server (PDS)
# Handles user repositories, DIDs, and CAR files
+
# NOTE: When using --profile plc, PDS waits for PLC directory to be healthy
pds:
image: ghcr.io/bluesky-social/pds:latest
container_name: coves-dev-pds
···
PDS_PORT: 3001 # Match external port for correct DID registration
PDS_DATA_DIRECTORY: /pds
PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks
+
# IMPORTANT: For local E2E testing, this MUST point to local PLC directory
+
# Default to local PLC (http://plc-directory:3000) for full local stack
+
# The container hostname 'plc-directory' is used for Docker network communication
+
PDS_DID_PLC_URL: ${PDS_DID_PLC_URL:-http://plc-directory:3000}
# PDS_CRAWLERS not needed - we're not using a relay for local dev
# Note: PDS uses its own internal SQLite database and CAR file storage
+13 -2
internal/atproto/oauth/client.go
···
import (
"encoding/base64"
"fmt"
"net/url"
"time"
···
PublicURL string
SealSecret string
PLCURL string
Scopes []string
SessionTTL time.Duration
SealedTokenTTL time.Duration
···
// Create indigo client config
var clientConfig oauth.ClientConfig
if config.DevMode {
-
// Dev mode: localhost with HTTP
-
callbackURL := "http://localhost:3000/oauth/callback"
clientConfig = oauth.NewLocalhostConfig(callbackURL, config.Scopes)
} else {
// Production mode: public OAuth client with HTTPS
// client_id must be the URL of the client metadata document per atproto OAuth spec
···
// Use pointer since CacheDirectory methods have pointer receivers
cacheDir := identity.NewCacheDirectory(baseDir, 100_000, time.Hour*24, time.Minute*2, time.Minute*5)
clientApp.Dir = &cacheDir
}
return &OAuthClient{
···
import (
"encoding/base64"
"fmt"
+
"log/slog"
"net/url"
"time"
···
PublicURL string
SealSecret string
PLCURL string
+
PDSURL string // For dev mode: resolve handles via local PDS
Scopes []string
SessionTTL time.Duration
SealedTokenTTL time.Duration
···
// Create indigo client config
var clientConfig oauth.ClientConfig
if config.DevMode {
+
// Dev mode: loopback with HTTP
+
// IMPORTANT: Use 127.0.0.1 instead of localhost per RFC 8252 - PDS rejects localhost
+
// The callback URL must match the APPVIEW_PUBLIC_URL from .env.dev
+
callbackURL := config.PublicURL + "/oauth/callback"
clientConfig = oauth.NewLocalhostConfig(callbackURL, config.Scopes)
+
slog.Info("dev mode: OAuth client configured",
+
"callback_url", callbackURL,
+
"client_id", clientConfig.ClientID)
} else {
// Production mode: public OAuth client with HTTPS
// client_id must be the URL of the client metadata document per atproto OAuth spec
···
// Use pointer since CacheDirectory methods have pointer receivers
cacheDir := identity.NewCacheDirectory(baseDir, 100_000, time.Hour*24, time.Minute*2, time.Minute*5)
clientApp.Dir = &cacheDir
+
// Log the PLC URL being used for OAuth directory resolution
+
fmt.Printf("🔐 OAuth client directory configured with PLC URL: %s (AllowPrivateIPs: %v)\n", config.PLCURL, config.AllowPrivateIPs)
+
} else {
+
fmt.Println("⚠️ OAuth client using DEFAULT PLC directory (production plc.directory)")
}
return &OAuthClient{
+285
internal/atproto/oauth/dev_auth_resolver.go
···
···
+
//go:build dev
+
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
+
oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// DevAuthResolver is a custom OAuth resolver that allows HTTP localhost URLs for development.
+
// The standard indigo OAuth resolver requires HTTPS and no port numbers, which breaks local testing.
+
type DevAuthResolver struct {
+
Client *http.Client
+
UserAgent string
+
PDSURL string // For resolving handles via local PDS
+
handleResolver *DevHandleResolver
+
}
+
+
// ProtectedResourceMetadata matches the OAuth protected resource metadata document format
+
type ProtectedResourceMetadata struct {
+
Resource string `json:"resource"`
+
AuthorizationServers []string `json:"authorization_servers"`
+
}
+
+
// NewDevAuthResolver creates a resolver that accepts localhost HTTP URLs
+
func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver {
+
resolver := &DevAuthResolver{
+
Client: NewSSRFSafeHTTPClient(allowPrivateIPs),
+
UserAgent: "Coves/1.0",
+
PDSURL: pdsURL,
+
}
+
// Create handle resolver for resolving handles via local PDS
+
if pdsURL != "" {
+
resolver.handleResolver = NewDevHandleResolver(pdsURL, allowPrivateIPs)
+
}
+
return resolver
+
}
+
+
// ResolveAuthServerURL resolves a PDS URL to an auth server URL.
+
// Unlike indigo's standard resolver, this allows HTTP and ports for localhost.
+
func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) {
+
u, err := url.Parse(hostURL)
+
if err != nil {
+
return "", err
+
}
+
+
// For localhost, allow HTTP and port numbers
+
isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
+
if !isLocalhost {
+
// For non-localhost, enforce HTTPS and no port (standard rules)
+
if u.Scheme != "https" || u.Port() != "" {
+
return "", fmt.Errorf("not a valid public host URL: %s", hostURL)
+
}
+
}
+
+
// Build the protected resource document URL
+
var docURL string
+
if isLocalhost {
+
// For localhost, preserve the port and use HTTP
+
port := u.Port()
+
if port == "" {
+
port = "3001" // Default PDS port
+
}
+
docURL = fmt.Sprintf("http://%s:%s/.well-known/oauth-protected-resource", u.Hostname(), port)
+
} else {
+
docURL = fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname())
+
}
+
+
// Fetch the protected resource document
+
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
+
if err != nil {
+
return "", err
+
}
+
if r.UserAgent != "" {
+
req.Header.Set("User-Agent", r.UserAgent)
+
}
+
+
resp, err := r.Client.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("fetching protected resource document: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode)
+
}
+
+
var body ProtectedResourceMetadata
+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+
return "", fmt.Errorf("invalid protected resource document: %w", err)
+
}
+
+
if len(body.AuthorizationServers) < 1 {
+
return "", fmt.Errorf("no auth server URL in protected resource document")
+
}
+
+
authURL := body.AuthorizationServers[0]
+
+
// Validate the auth server URL (with localhost exception)
+
au, err := url.Parse(authURL)
+
if err != nil {
+
return "", fmt.Errorf("invalid auth server URL: %w", err)
+
}
+
+
authIsLocalhost := au.Hostname() == "localhost" || au.Hostname() == "127.0.0.1"
+
if !authIsLocalhost {
+
if au.Scheme != "https" || au.Port() != "" {
+
return "", fmt.Errorf("invalid auth server URL: %s", authURL)
+
}
+
}
+
+
return authURL, nil
+
}
+
+
// ResolveAuthServerMetadataDev fetches OAuth server metadata from a given auth server URL.
+
// Unlike indigo's resolver, this allows HTTP and ports for localhost.
+
func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) {
+
u, err := url.Parse(serverURL)
+
if err != nil {
+
return nil, err
+
}
+
+
// Build metadata URL - preserve port for localhost
+
var metaURL string
+
isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
+
if isLocalhost && u.Port() != "" {
+
metaURL = fmt.Sprintf("%s://%s:%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname(), u.Port())
+
} else if isLocalhost {
+
metaURL = fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname())
+
} else {
+
metaURL = fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname())
+
}
+
+
slog.Debug("dev mode: fetching auth server metadata", "url", metaURL)
+
+
req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil)
+
if err != nil {
+
return nil, err
+
}
+
if r.UserAgent != "" {
+
req.Header.Set("User-Agent", r.UserAgent)
+
}
+
+
resp, err := r.Client.Do(req)
+
if err != nil {
+
return nil, fmt.Errorf("fetching auth server metadata: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode)
+
}
+
+
var metadata oauthlib.AuthServerMetadata
+
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
+
return nil, fmt.Errorf("invalid auth server metadata: %w", err)
+
}
+
+
// Skip validation for localhost (indigo's Validate checks HTTPS)
+
if !isLocalhost {
+
if err := metadata.Validate(serverURL); err != nil {
+
return nil, fmt.Errorf("invalid auth server metadata: %w", err)
+
}
+
}
+
+
return &metadata, nil
+
}
+
+
// StartDevAuthFlow performs OAuth flow for localhost development.
+
// This bypasses indigo's HTTPS validation for the auth server URL.
+
// It resolves the identity, gets the PDS endpoint, fetches auth server metadata,
+
// and returns a redirect URL for the user to approve.
+
func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+
var accountDID syntax.DID
+
var pdsEndpoint string
+
+
// Check if identifier is a handle or DID
+
if strings.HasPrefix(identifier, "did:") {
+
// It's a DID - look up via directory (PLC)
+
atid, err := syntax.ParseAtIdentifier(identifier)
+
if err != nil {
+
return "", fmt.Errorf("not a valid DID (%s): %w", identifier, err)
+
}
+
ident, err := dir.Lookup(ctx, *atid)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve DID (%s): %w", identifier, err)
+
}
+
accountDID = ident.DID
+
pdsEndpoint = ident.PDSEndpoint()
+
} else {
+
// It's a handle - resolve via local PDS first
+
if r.handleResolver == nil {
+
return "", fmt.Errorf("handle resolution not configured (PDS URL not set)")
+
}
+
+
// Resolve handle to DID via local PDS
+
did, err := r.handleResolver.ResolveHandle(ctx, identifier)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve handle via PDS (%s): %w", identifier, err)
+
}
+
if did == "" {
+
return "", fmt.Errorf("handle not found: %s", identifier)
+
}
+
+
slog.Info("dev mode: resolved handle via local PDS", "handle", identifier, "did", did)
+
+
// Parse the DID
+
parsedDID, err := syntax.ParseDID(did)
+
if err != nil {
+
return "", fmt.Errorf("invalid DID from PDS (%s): %w", did, err)
+
}
+
accountDID = parsedDID
+
+
// Now look up the DID document via PLC to get PDS endpoint
+
atid, err := syntax.ParseAtIdentifier(did)
+
if err != nil {
+
return "", fmt.Errorf("not a valid DID (%s): %w", did, err)
+
}
+
ident, err := dir.Lookup(ctx, *atid)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve DID document (%s): %w", did, err)
+
}
+
pdsEndpoint = ident.PDSEndpoint()
+
}
+
+
if pdsEndpoint == "" {
+
return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
+
}
+
+
slog.Debug("dev mode: resolving auth server",
+
"did", accountDID,
+
"pds", pdsEndpoint)
+
+
// Resolve auth server URL (allowing HTTP for localhost)
+
authServerURL, err := r.ResolveAuthServerURL(ctx, pdsEndpoint)
+
if err != nil {
+
return "", fmt.Errorf("resolving auth server: %w", err)
+
}
+
+
slog.Info("dev mode: resolved auth server", "url", authServerURL)
+
+
// Fetch auth server metadata using our dev-friendly resolver
+
authMeta, err := r.ResolveAuthServerMetadataDev(ctx, authServerURL)
+
if err != nil {
+
return "", fmt.Errorf("fetching auth server metadata: %w", err)
+
}
+
+
slog.Debug("dev mode: got auth server metadata",
+
"issuer", authMeta.Issuer,
+
"authorization_endpoint", authMeta.AuthorizationEndpoint,
+
"token_endpoint", authMeta.TokenEndpoint)
+
+
// Send auth request (PAR) using indigo's method
+
info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier)
+
if err != nil {
+
return "", fmt.Errorf("auth request failed: %w", err)
+
}
+
+
// Set the account DID
+
info.AccountDID = &accountDID
+
+
// Persist auth request info
+
client.ClientApp.Store.SaveAuthRequestInfo(ctx, *info)
+
+
// Build redirect URL
+
params := url.Values{}
+
params.Set("client_id", client.ClientApp.Config.ClientID)
+
params.Set("request_uri", info.RequestURI)
+
+
authEndpoint := authMeta.AuthorizationEndpoint
+
redirectURL := fmt.Sprintf("%s?%s", authEndpoint, params.Encode())
+
+
slog.Info("dev mode: OAuth redirect URL built", "url_prefix", authEndpoint)
+
+
return redirectURL, nil
+
}
+106
internal/atproto/oauth/dev_resolver.go
···
···
+
//go:build dev
+
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
)
+
+
// DevHandleResolver resolves handles via local PDS for development
+
// This is needed because local handles (e.g., user.local.coves.dev) can't be
+
// resolved via standard DNS/HTTP well-known methods - they only exist on the local PDS.
+
type DevHandleResolver struct {
+
pdsURL string
+
httpClient *http.Client
+
}
+
+
// NewDevHandleResolver creates a resolver that queries local PDS for handle resolution
+
func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver {
+
return &DevHandleResolver{
+
pdsURL: strings.TrimSuffix(pdsURL, "/"),
+
httpClient: NewSSRFSafeHTTPClient(allowPrivateIPs),
+
}
+
}
+
+
// ResolveHandle queries the local PDS to resolve a handle to a DID
+
// Returns the DID if successful, or empty string if not found
+
func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
+
if r.pdsURL == "" {
+
return "", fmt.Errorf("PDS URL not configured")
+
}
+
+
// Build the resolve handle URL
+
resolveURL := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s",
+
r.pdsURL, url.QueryEscape(handle))
+
+
// Create request with context and timeout
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+
defer cancel()
+
+
req, err := http.NewRequestWithContext(ctx, "GET", resolveURL, nil)
+
if err != nil {
+
return "", fmt.Errorf("failed to create request: %w", err)
+
}
+
req.Header.Set("User-Agent", "Coves/1.0")
+
+
// Execute request
+
resp, err := r.httpClient.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("failed to query PDS: %w", err)
+
}
+
defer resp.Body.Close()
+
+
// Check response status
+
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
+
return "", nil // Handle not found
+
}
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("PDS returned status %d", resp.StatusCode)
+
}
+
+
// Parse response
+
var result struct {
+
DID string `json:"did"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return "", fmt.Errorf("failed to parse PDS response: %w", err)
+
}
+
+
if result.DID == "" {
+
return "", nil // No DID in response
+
}
+
+
slog.Debug("resolved handle via local PDS",
+
"handle", handle,
+
"did", result.DID,
+
"pds_url", r.pdsURL)
+
+
return result.DID, nil
+
}
+
+
// ResolveIdentifier attempts to resolve a handle to DID, or returns the DID if already provided
+
// This is the main entry point for the handlers
+
func (r *DevHandleResolver) ResolveIdentifier(ctx context.Context, identifier string) (string, error) {
+
// If it's already a DID, return as-is
+
if strings.HasPrefix(identifier, "did:") {
+
return identifier, nil
+
}
+
+
// Try to resolve the handle via local PDS
+
did, err := r.ResolveHandle(ctx, identifier)
+
if err != nil {
+
return "", fmt.Errorf("failed to resolve handle via PDS: %w", err)
+
}
+
if did == "" {
+
return "", fmt.Errorf("handle not found on local PDS: %s", identifier)
+
}
+
+
return did, nil
+
}
+41
internal/atproto/oauth/dev_stubs.go
···
···
+
//go:build !dev
+
+
package oauth
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/atproto/identity"
+
)
+
+
// DevHandleResolver is a stub for production builds.
+
// The actual implementation is in dev_resolver.go (only compiled with -tags dev).
+
type DevHandleResolver struct{}
+
+
// NewDevHandleResolver returns nil in production builds.
+
// Dev mode features are only available when built with -tags dev.
+
func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver {
+
return nil
+
}
+
+
// ResolveHandle is a stub that should never be called in production.
+
// The nil check in handlers.go prevents this from being reached.
+
func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {
+
panic("dev mode: ResolveHandle called in production build - this should never happen")
+
}
+
+
// DevAuthResolver is a stub for production builds.
+
// The actual implementation is in dev_auth_resolver.go (only compiled with -tags dev).
+
type DevAuthResolver struct{}
+
+
// NewDevAuthResolver returns nil in production builds.
+
// Dev mode features are only available when built with -tags dev.
+
func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver {
+
return nil
+
}
+
+
// StartDevAuthFlow is a stub that should never be called in production.
+
// The nil check in handlers.go prevents this from being reached.
+
func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+
panic("dev mode: StartDevAuthFlow called in production build - this should never happen")
+
}
+107 -15
internal/atproto/oauth/handlers.go
···
"log/slog"
"net/http"
"net/url"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
···
// OAuthHandler handles OAuth-related HTTP endpoints
type OAuthHandler struct {
-
client *OAuthClient
-
store oauth.ClientAuthStore
-
mobileStore MobileOAuthStore // For server-side CSRF validation
}
// NewOAuthHandler creates a new OAuth handler
···
// Check if the store implements MobileOAuthStore for server-side CSRF
if mobileStore, ok := store.(MobileOAuthStore); ok {
handler.mobileStore = mobileStore
}
return handler
···
return
}
-
// Start OAuth flow
-
redirectURL, err := h.client.ClientApp.StartAuthFlow(ctx, identifier)
-
if err != nil {
-
slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier)
-
http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest)
-
return
}
// Log OAuth flow initiation (sanitized - no full URL to avoid leaking state)
···
func (h *OAuthHandler) HandleMobileLogin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get handle or DID from query params
identifier := r.URL.Query().Get("handle")
if identifier == "" {
···
RedirectURI: mobileRedirectURI,
})
-
// Start OAuth flow (the store wrapper will save mobile data when auth request is saved)
-
redirectURL, err := h.client.ClientApp.StartAuthFlow(mobileCtx, identifier)
-
if err != nil {
-
slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier)
-
http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest)
-
return
}
// Log mobile OAuth flow initiation (sanitized - no full URLs or sensitive params)
···
// Check if the handle is the special "handle.invalid" value
// This indicates that bidirectional verification failed (DID->handle->DID roundtrip failed)
if ident.Handle.String() == "handle.invalid" {
slog.Warn("OAuth callback: bidirectional handle verification failed",
"did", sessData.AccountDID,
"handle", "handle.invalid",
···
"did", sessData.AccountDID)
slog.Info("OAuth callback successful (no handle verification)", "did", sessData.AccountDID)
}
// Check if this is a mobile callback (check for mobile_redirect_uri cookie)
mobileRedirect, err := r.Cookie("mobile_redirect_uri")
···
"log/slog"
"net/http"
"net/url"
+
"strings"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
···
// OAuthHandler handles OAuth-related HTTP endpoints
type OAuthHandler struct {
+
client *OAuthClient
+
store oauth.ClientAuthStore
+
mobileStore MobileOAuthStore // For server-side CSRF validation
+
devResolver *DevHandleResolver // For dev mode: resolve handles via local PDS
+
devAuthResolver *DevAuthResolver // For dev mode: bypass HTTPS validation for localhost OAuth
}
// NewOAuthHandler creates a new OAuth handler
···
// Check if the store implements MobileOAuthStore for server-side CSRF
if mobileStore, ok := store.(MobileOAuthStore); ok {
handler.mobileStore = mobileStore
+
}
+
+
// In dev mode, create resolvers for local PDS/PLC
+
// This is needed because:
+
// 1. Local handles (e.g., user.local.coves.dev) can't be resolved via DNS/HTTP
+
// 2. Indigo's OAuth library requires HTTPS, which localhost doesn't have
+
if client.Config.DevMode {
+
if client.Config.PDSURL != "" {
+
handler.devResolver = NewDevHandleResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs)
+
slog.Info("dev mode: handle resolution via local PDS enabled", "pds_url", client.Config.PDSURL)
+
}
+
// Create dev auth resolver to bypass HTTPS validation (pass PDS URL for handle resolution)
+
handler.devAuthResolver = NewDevAuthResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs)
+
slog.Info("dev mode: localhost OAuth auth resolver enabled", "pds_url", client.Config.PDSURL)
}
return handler
···
return
}
+
var redirectURL string
+
var err error
+
+
// DEV MODE: Use custom OAuth flow that bypasses HTTPS validation
+
// This is needed because:
+
// 1. Local handles can't be resolved via DNS/HTTP well-known
+
// 2. Indigo's OAuth library requires HTTPS for auth servers
+
if h.devAuthResolver != nil {
+
slog.Info("dev mode: using localhost OAuth flow", "identifier", identifier)
+
redirectURL, err = h.devAuthResolver.StartDevAuthFlow(ctx, h.client, identifier, h.client.ClientApp.Dir)
+
if err != nil {
+
slog.Error("dev mode: failed to start OAuth flow", "error", err, "identifier", identifier)
+
http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest)
+
return
+
}
+
} else {
+
// Production mode: use standard indigo OAuth flow
+
redirectURL, err = h.client.ClientApp.StartAuthFlow(ctx, identifier)
+
if err != nil {
+
slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier)
+
http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest)
+
return
+
}
}
// Log OAuth flow initiation (sanitized - no full URL to avoid leaking state)
···
func (h *OAuthHandler) HandleMobileLogin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+
// DEV MODE: Redirect localhost to 127.0.0.1 for cookie consistency
+
// The OAuth callback URL uses 127.0.0.1 (per RFC 8252), so cookies must be set
+
// on 127.0.0.1. If user calls localhost, redirect to 127.0.0.1 first.
+
if h.client.Config.DevMode && strings.Contains(r.Host, "localhost") {
+
// Use the configured PublicURL host for consistency
+
redirectURL := h.client.Config.PublicURL + r.URL.RequestURI()
+
slog.Info("dev mode: redirecting localhost to PublicURL host for cookie consistency",
+
"from", r.Host, "to", h.client.Config.PublicURL)
+
http.Redirect(w, r, redirectURL, http.StatusFound)
+
return
+
}
+
// Get handle or DID from query params
identifier := r.URL.Query().Get("handle")
if identifier == "" {
···
RedirectURI: mobileRedirectURI,
})
+
var redirectURL string
+
+
// DEV MODE: Use custom OAuth flow that bypasses HTTPS validation
+
// This is needed because:
+
// 1. Local handles can't be resolved via DNS/HTTP well-known
+
// 2. Indigo's OAuth library requires HTTPS for auth servers
+
if h.devAuthResolver != nil {
+
slog.Info("dev mode: using localhost OAuth flow for mobile", "identifier", identifier)
+
redirectURL, err = h.devAuthResolver.StartDevAuthFlow(mobileCtx, h.client, identifier, h.client.ClientApp.Dir)
+
if err != nil {
+
slog.Error("dev mode: failed to start OAuth flow", "error", err, "identifier", identifier)
+
http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest)
+
return
+
}
+
} else {
+
// Production mode: use standard indigo OAuth flow
+
redirectURL, err = h.client.ClientApp.StartAuthFlow(mobileCtx, identifier)
+
if err != nil {
+
slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier)
+
http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest)
+
return
+
}
}
// Log mobile OAuth flow initiation (sanitized - no full URLs or sensitive params)
···
// Check if the handle is the special "handle.invalid" value
// This indicates that bidirectional verification failed (DID->handle->DID roundtrip failed)
if ident.Handle.String() == "handle.invalid" {
+
// DEV MODE: For local handles, verify via PDS instead of DNS/HTTP
+
// Local handles like "user.local.coves.dev" can't be resolved via DNS
+
if h.devResolver != nil {
+
// Get the handle from DID document (alsoKnownAs)
+
declaredHandle := ""
+
if len(ident.AlsoKnownAs) > 0 {
+
// Extract handle from at:// URI
+
for _, aka := range ident.AlsoKnownAs {
+
if len(aka) > 5 && aka[:5] == "at://" {
+
declaredHandle = aka[5:]
+
break
+
}
+
}
+
}
+
+
if declaredHandle != "" {
+
// Verify handle via PDS
+
resolvedDID, err := h.devResolver.ResolveHandle(ctx, declaredHandle)
+
if err == nil && resolvedDID == sessData.AccountDID.String() {
+
slog.Info("OAuth callback successful (dev mode: handle verified via PDS)",
+
"did", sessData.AccountDID, "handle", declaredHandle)
+
goto handleVerificationPassed
+
}
+
slog.Warn("dev mode: PDS handle verification failed",
+
"did", sessData.AccountDID, "handle", declaredHandle,
+
"resolved_did", resolvedDID, "error", err)
+
}
+
}
+
slog.Warn("OAuth callback: bidirectional handle verification failed",
"did", sessData.AccountDID,
"handle", "handle.invalid",
···
"did", sessData.AccountDID)
slog.Info("OAuth callback successful (no handle verification)", "did", sessData.AccountDID)
}
+
handleVerificationPassed:
// Check if this is a mobile callback (check for mobile_redirect_uri cookie)
mobileRedirect, err := r.Cookie("mobile_redirect_uri")
+5 -1
scripts/dev-run.sh
···
#!/bin/bash
# Development server runner - loads .env.dev before starting
set -a # automatically export all variables
source .env.dev
···
echo " IS_DEV_ENV: $IS_DEV_ENV"
echo " PLC_DIRECTORY_URL: $PLC_DIRECTORY_URL"
echo " JETSTREAM_URL: $JETSTREAM_URL"
echo ""
-
go run ./cmd/server
···
#!/bin/bash
# Development server runner - loads .env.dev before starting
+
# Uses -tags dev to include dev-only code (localhost OAuth resolvers, etc.)
set -a # automatically export all variables
source .env.dev
···
echo " IS_DEV_ENV: $IS_DEV_ENV"
echo " PLC_DIRECTORY_URL: $PLC_DIRECTORY_URL"
echo " JETSTREAM_URL: $JETSTREAM_URL"
+
echo " APPVIEW_PUBLIC_URL: $APPVIEW_PUBLIC_URL"
+
echo " PDS_URL: $PDS_URL"
+
echo " Build tags: dev"
echo ""
+
go run -tags dev ./cmd/server