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>

+700 -22
+18
.env.dev
··· 182 182 PLC_DIRECTORY_URL=http://localhost:3002 183 183 184 184 # ============================================================================= 185 + # Dev Mode Quick Reference 186 + # ============================================================================= 187 + # REQUIRED for local OAuth to work with local PDS: 188 + # IS_DEV_ENV=true # Master switch for dev mode 189 + # PDS_URL=http://localhost:3001 # Local PDS for handle resolution 190 + # PLC_DIRECTORY_URL=http://localhost:3002 # Local PLC directory 191 + # APPVIEW_PUBLIC_URL=http://127.0.0.1:8081 # Use IP not localhost (RFC 8252) 192 + # 193 + # BUILD TAGS: 194 + # make run - Runs with -tags dev (includes localhost OAuth resolvers) 195 + # make build - Production binary (no dev code) 196 + # make build-dev - Dev binary (includes dev code) 197 + # 198 + # Dev-only code (only compiled with -tags dev): 199 + # - internal/atproto/oauth/dev_resolver.go (handle resolution via local PDS) 200 + # - internal/atproto/oauth/dev_auth_resolver.go (localhost OAuth bypass) 201 + # 202 + # ============================================================================= 185 203 # Notes 186 204 # ============================================================================= 187 205 # All local development configuration in one file!
+92
.env.dev.example
··· 1 + # Coves Local Development Environment Configuration 2 + # Copy this to .env.dev and fill in your values 3 + # 4 + # Quick Start: 5 + # 1. cp .env.dev.example .env.dev 6 + # 2. Generate OAuth key: go run cmd/genjwks/main.go (copy output to OAUTH_PRIVATE_JWK) 7 + # 3. Generate cookie secret: openssl rand -hex 32 8 + # 4. make dev-up # Start Docker services 9 + # 5. make run # Start the server (uses -tags dev) 10 + 11 + # ============================================================================= 12 + # Dev Mode Quick Reference 13 + # ============================================================================= 14 + # REQUIRED for local OAuth to work with local PDS: 15 + # IS_DEV_ENV=true # Master switch for dev mode 16 + # PDS_URL=http://localhost:3001 # Local PDS for handle resolution 17 + # PLC_DIRECTORY_URL=http://localhost:3002 # Local PLC directory 18 + # APPVIEW_PUBLIC_URL=http://127.0.0.1:8081 # Use IP not localhost (RFC 8252) 19 + # 20 + # BUILD TAGS: 21 + # make run - Runs with -tags dev (includes localhost OAuth resolvers) 22 + # make build - Production binary (no dev code) 23 + # make build-dev - Dev binary (includes dev code) 24 + 25 + # ============================================================================= 26 + # PostgreSQL Configuration 27 + # ============================================================================= 28 + POSTGRES_HOST=localhost 29 + POSTGRES_PORT=5435 30 + POSTGRES_DB=coves_dev 31 + POSTGRES_USER=dev_user 32 + POSTGRES_PASSWORD=dev_password 33 + 34 + # Test database 35 + POSTGRES_TEST_DB=coves_test 36 + POSTGRES_TEST_USER=test_user 37 + POSTGRES_TEST_PASSWORD=test_password 38 + POSTGRES_TEST_PORT=5434 39 + 40 + # ============================================================================= 41 + # PDS Configuration 42 + # ============================================================================= 43 + PDS_HOSTNAME=localhost 44 + PDS_PORT=3001 45 + PDS_SERVICE_ENDPOINT=http://localhost:3000 46 + PDS_DID_PLC_URL=http://plc-directory:3000 47 + PDS_JWT_SECRET=local-dev-jwt-secret-change-in-production 48 + PDS_ADMIN_PASSWORD=admin 49 + PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social 50 + PDS_PLC_ROTATION_KEY=<generate-a-random-hex-key> 51 + 52 + # ============================================================================= 53 + # AppView Configuration 54 + # ============================================================================= 55 + APPVIEW_PORT=8081 56 + FIREHOSE_URL=ws://localhost:3001/xrpc/com.atproto.sync.subscribeRepos 57 + PDS_URL=http://localhost:3001 58 + APPVIEW_PUBLIC_URL=http://127.0.0.1:8081 59 + 60 + # ============================================================================= 61 + # Jetstream Configuration 62 + # ============================================================================= 63 + JETSTREAM_URL=ws://localhost:6008/subscribe 64 + 65 + # ============================================================================= 66 + # Identity Resolution 67 + # ============================================================================= 68 + IDENTITY_CACHE_TTL=24h 69 + PLC_DIRECTORY_URL=http://localhost:3002 70 + 71 + # ============================================================================= 72 + # OAuth Configuration (MUST GENERATE YOUR OWN) 73 + # ============================================================================= 74 + # Generate with: go run cmd/genjwks/main.go 75 + OAUTH_PRIVATE_JWK=<generate-your-own-jwk> 76 + 77 + # Generate with: openssl rand -hex 32 78 + OAUTH_COOKIE_SECRET=<generate-your-own-secret> 79 + 80 + # ============================================================================= 81 + # Development Settings 82 + # ============================================================================= 83 + ENV=development 84 + NODE_ENV=development 85 + IS_DEV_ENV=true 86 + LOG_LEVEL=debug 87 + LOG_ENABLED=true 88 + 89 + # Security settings (ONLY for local dev - set to false in production!) 90 + SKIP_DID_WEB_VERIFICATION=true 91 + AUTH_SKIP_VERIFY=true 92 + HS256_ISSUERS=http://localhost:3001
+25 -3
Makefile
··· 1 - .PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean 1 + .PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean verify-stack create-test-account mobile-full-setup 2 2 3 3 # Default target - show help 4 4 .DEFAULT_GOAL := help ··· 186 186 187 187 ##@ Build & Run 188 188 189 - build: ## Build the Coves server 190 - @echo "$(GREEN)Building Coves server...$(RESET)" 189 + build: ## Build the Coves server (production - no dev code) 190 + @echo "$(GREEN)Building Coves server (production)...$(RESET)" 191 191 @go build -o server ./cmd/server 192 192 @echo "$(GREEN)✓ Build complete: ./server$(RESET)" 193 + 194 + build-dev: ## Build the Coves server with dev mode (includes localhost OAuth resolvers) 195 + @echo "$(GREEN)Building Coves server (dev mode)...$(RESET)" 196 + @go build -tags dev -o server ./cmd/server 197 + @echo "$(GREEN)✓ Build complete: ./server (with dev tags)$(RESET)" 193 198 194 199 run: ## Run the Coves server with dev environment (requires database running) 195 200 @./scripts/dev-run.sh ··· 235 240 @echo "$(YELLOW)Removing Android port forwarding...$(RESET)" 236 241 @adb reverse --remove-all || echo "$(YELLOW)No device connected$(RESET)" 237 242 @echo "$(GREEN)✓ Port forwarding removed$(RESET)" 243 + 244 + verify-stack: ## Verify local development stack (PLC, PDS, configs) 245 + @./scripts/verify-local-stack.sh 246 + 247 + create-test-account: ## Create a test account on local PDS for OAuth testing 248 + @./scripts/create-test-account.sh 249 + 250 + mobile-full-setup: verify-stack create-test-account mobile-setup ## Full mobile setup: verify stack, create account, setup ports 251 + @echo "" 252 + @echo "$(GREEN)═══════════════════════════════════════════════════════════$(RESET)" 253 + @echo "$(GREEN) Mobile development environment ready! $(RESET)" 254 + @echo "$(GREEN)═══════════════════════════════════════════════════════════$(RESET)" 255 + @echo "" 256 + @echo "$(CYAN)Run the Flutter app with:$(RESET)" 257 + @echo " $(YELLOW)cd /home/bretton/Code/coves-mobile$(RESET)" 258 + @echo " $(YELLOW)flutter run --dart-define=ENVIRONMENT=local$(RESET)" 259 + @echo "" 238 260 239 261 ngrok-up: ## Start ngrok tunnels (for iOS or WiFi testing - requires paid plan for 3 tunnels) 240 262 @echo "$(GREEN)Starting ngrok tunnels for mobile testing...$(RESET)"
+3
cmd/server/main.go
··· 143 143 if plcURL == "" { 144 144 plcURL = "https://plc.directory" 145 145 } 146 + log.Printf("🔐 OAuth will use PLC directory: %s", plcURL) 146 147 147 148 // Initialize OAuth client for sealed session tokens 148 149 // Mobile apps authenticate via OAuth flow and receive sealed session tokens ··· 162 163 } 163 164 164 165 isDevMode := os.Getenv("IS_DEV_ENV") == "true" 166 + pdsURL := os.Getenv("PDS_URL") // For dev mode: resolve handles via local PDS 165 167 oauthConfig := &oauth.OAuthConfig{ 166 168 PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"), 167 169 SealSecret: oauthSealSecret, ··· 169 171 DevMode: isDevMode, 170 172 AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode 171 173 PLCURL: plcURL, 174 + PDSURL: pdsURL, // For dev mode handle resolution 172 175 // SessionTTL and SealedTokenTTL will use defaults if not set (7 days and 14 days) 173 176 } 174 177
+5 -1
docker-compose.dev.yml
··· 58 58 59 59 # Bluesky Personal Data Server (PDS) 60 60 # Handles user repositories, DIDs, and CAR files 61 + # NOTE: When using --profile plc, PDS waits for PLC directory to be healthy 61 62 pds: 62 63 image: ghcr.io/bluesky-social/pds:latest 63 64 container_name: coves-dev-pds ··· 69 70 PDS_PORT: 3001 # Match external port for correct DID registration 70 71 PDS_DATA_DIRECTORY: /pds 71 72 PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks 72 - PDS_DID_PLC_URL: ${PDS_DID_PLC_URL:-https://plc.directory} 73 + # IMPORTANT: For local E2E testing, this MUST point to local PLC directory 74 + # Default to local PLC (http://plc-directory:3000) for full local stack 75 + # The container hostname 'plc-directory' is used for Docker network communication 76 + PDS_DID_PLC_URL: ${PDS_DID_PLC_URL:-http://plc-directory:3000} 73 77 # PDS_CRAWLERS not needed - we're not using a relay for local dev 74 78 75 79 # Note: PDS uses its own internal SQLite database and CAR file storage
+13 -2
internal/atproto/oauth/client.go
··· 3 3 import ( 4 4 "encoding/base64" 5 5 "fmt" 6 + "log/slog" 6 7 "net/url" 7 8 "time" 8 9 ··· 22 23 PublicURL string 23 24 SealSecret string 24 25 PLCURL string 26 + PDSURL string // For dev mode: resolve handles via local PDS 25 27 Scopes []string 26 28 SessionTTL time.Duration 27 29 SealedTokenTTL time.Duration ··· 77 79 // Create indigo client config 78 80 var clientConfig oauth.ClientConfig 79 81 if config.DevMode { 80 - // Dev mode: localhost with HTTP 81 - callbackURL := "http://localhost:3000/oauth/callback" 82 + // Dev mode: loopback with HTTP 83 + // IMPORTANT: Use 127.0.0.1 instead of localhost per RFC 8252 - PDS rejects localhost 84 + // The callback URL must match the APPVIEW_PUBLIC_URL from .env.dev 85 + callbackURL := config.PublicURL + "/oauth/callback" 82 86 clientConfig = oauth.NewLocalhostConfig(callbackURL, config.Scopes) 87 + slog.Info("dev mode: OAuth client configured", 88 + "callback_url", callbackURL, 89 + "client_id", clientConfig.ClientID) 83 90 } else { 84 91 // Production mode: public OAuth client with HTTPS 85 92 // client_id must be the URL of the client metadata document per atproto OAuth spec ··· 112 119 // Use pointer since CacheDirectory methods have pointer receivers 113 120 cacheDir := identity.NewCacheDirectory(baseDir, 100_000, time.Hour*24, time.Minute*2, time.Minute*5) 114 121 clientApp.Dir = &cacheDir 122 + // Log the PLC URL being used for OAuth directory resolution 123 + fmt.Printf("🔐 OAuth client directory configured with PLC URL: %s (AllowPrivateIPs: %v)\n", config.PLCURL, config.AllowPrivateIPs) 124 + } else { 125 + fmt.Println("⚠️ OAuth client using DEFAULT PLC directory (production plc.directory)") 115 126 } 116 127 117 128 return &OAuthClient{
+285
internal/atproto/oauth/dev_auth_resolver.go
··· 1 + //go:build dev 2 + 3 + package oauth 4 + 5 + import ( 6 + "context" 7 + "encoding/json" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "strings" 13 + 14 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + ) 18 + 19 + // DevAuthResolver is a custom OAuth resolver that allows HTTP localhost URLs for development. 20 + // The standard indigo OAuth resolver requires HTTPS and no port numbers, which breaks local testing. 21 + type DevAuthResolver struct { 22 + Client *http.Client 23 + UserAgent string 24 + PDSURL string // For resolving handles via local PDS 25 + handleResolver *DevHandleResolver 26 + } 27 + 28 + // ProtectedResourceMetadata matches the OAuth protected resource metadata document format 29 + type ProtectedResourceMetadata struct { 30 + Resource string `json:"resource"` 31 + AuthorizationServers []string `json:"authorization_servers"` 32 + } 33 + 34 + // NewDevAuthResolver creates a resolver that accepts localhost HTTP URLs 35 + func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver { 36 + resolver := &DevAuthResolver{ 37 + Client: NewSSRFSafeHTTPClient(allowPrivateIPs), 38 + UserAgent: "Coves/1.0", 39 + PDSURL: pdsURL, 40 + } 41 + // Create handle resolver for resolving handles via local PDS 42 + if pdsURL != "" { 43 + resolver.handleResolver = NewDevHandleResolver(pdsURL, allowPrivateIPs) 44 + } 45 + return resolver 46 + } 47 + 48 + // ResolveAuthServerURL resolves a PDS URL to an auth server URL. 49 + // Unlike indigo's standard resolver, this allows HTTP and ports for localhost. 50 + func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) { 51 + u, err := url.Parse(hostURL) 52 + if err != nil { 53 + return "", err 54 + } 55 + 56 + // For localhost, allow HTTP and port numbers 57 + isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" 58 + if !isLocalhost { 59 + // For non-localhost, enforce HTTPS and no port (standard rules) 60 + if u.Scheme != "https" || u.Port() != "" { 61 + return "", fmt.Errorf("not a valid public host URL: %s", hostURL) 62 + } 63 + } 64 + 65 + // Build the protected resource document URL 66 + var docURL string 67 + if isLocalhost { 68 + // For localhost, preserve the port and use HTTP 69 + port := u.Port() 70 + if port == "" { 71 + port = "3001" // Default PDS port 72 + } 73 + docURL = fmt.Sprintf("http://%s:%s/.well-known/oauth-protected-resource", u.Hostname(), port) 74 + } else { 75 + docURL = fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname()) 76 + } 77 + 78 + // Fetch the protected resource document 79 + req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) 80 + if err != nil { 81 + return "", err 82 + } 83 + if r.UserAgent != "" { 84 + req.Header.Set("User-Agent", r.UserAgent) 85 + } 86 + 87 + resp, err := r.Client.Do(req) 88 + if err != nil { 89 + return "", fmt.Errorf("fetching protected resource document: %w", err) 90 + } 91 + defer resp.Body.Close() 92 + 93 + if resp.StatusCode != http.StatusOK { 94 + return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode) 95 + } 96 + 97 + var body ProtectedResourceMetadata 98 + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 99 + return "", fmt.Errorf("invalid protected resource document: %w", err) 100 + } 101 + 102 + if len(body.AuthorizationServers) < 1 { 103 + return "", fmt.Errorf("no auth server URL in protected resource document") 104 + } 105 + 106 + authURL := body.AuthorizationServers[0] 107 + 108 + // Validate the auth server URL (with localhost exception) 109 + au, err := url.Parse(authURL) 110 + if err != nil { 111 + return "", fmt.Errorf("invalid auth server URL: %w", err) 112 + } 113 + 114 + authIsLocalhost := au.Hostname() == "localhost" || au.Hostname() == "127.0.0.1" 115 + if !authIsLocalhost { 116 + if au.Scheme != "https" || au.Port() != "" { 117 + return "", fmt.Errorf("invalid auth server URL: %s", authURL) 118 + } 119 + } 120 + 121 + return authURL, nil 122 + } 123 + 124 + // ResolveAuthServerMetadataDev fetches OAuth server metadata from a given auth server URL. 125 + // Unlike indigo's resolver, this allows HTTP and ports for localhost. 126 + func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) { 127 + u, err := url.Parse(serverURL) 128 + if err != nil { 129 + return nil, err 130 + } 131 + 132 + // Build metadata URL - preserve port for localhost 133 + var metaURL string 134 + isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" 135 + if isLocalhost && u.Port() != "" { 136 + metaURL = fmt.Sprintf("%s://%s:%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname(), u.Port()) 137 + } else if isLocalhost { 138 + metaURL = fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname()) 139 + } else { 140 + metaURL = fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname()) 141 + } 142 + 143 + slog.Debug("dev mode: fetching auth server metadata", "url", metaURL) 144 + 145 + req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil) 146 + if err != nil { 147 + return nil, err 148 + } 149 + if r.UserAgent != "" { 150 + req.Header.Set("User-Agent", r.UserAgent) 151 + } 152 + 153 + resp, err := r.Client.Do(req) 154 + if err != nil { 155 + return nil, fmt.Errorf("fetching auth server metadata: %w", err) 156 + } 157 + defer resp.Body.Close() 158 + 159 + if resp.StatusCode != http.StatusOK { 160 + return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode) 161 + } 162 + 163 + var metadata oauthlib.AuthServerMetadata 164 + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { 165 + return nil, fmt.Errorf("invalid auth server metadata: %w", err) 166 + } 167 + 168 + // Skip validation for localhost (indigo's Validate checks HTTPS) 169 + if !isLocalhost { 170 + if err := metadata.Validate(serverURL); err != nil { 171 + return nil, fmt.Errorf("invalid auth server metadata: %w", err) 172 + } 173 + } 174 + 175 + return &metadata, nil 176 + } 177 + 178 + // StartDevAuthFlow performs OAuth flow for localhost development. 179 + // This bypasses indigo's HTTPS validation for the auth server URL. 180 + // It resolves the identity, gets the PDS endpoint, fetches auth server metadata, 181 + // and returns a redirect URL for the user to approve. 182 + func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) { 183 + var accountDID syntax.DID 184 + var pdsEndpoint string 185 + 186 + // Check if identifier is a handle or DID 187 + if strings.HasPrefix(identifier, "did:") { 188 + // It's a DID - look up via directory (PLC) 189 + atid, err := syntax.ParseAtIdentifier(identifier) 190 + if err != nil { 191 + return "", fmt.Errorf("not a valid DID (%s): %w", identifier, err) 192 + } 193 + ident, err := dir.Lookup(ctx, *atid) 194 + if err != nil { 195 + return "", fmt.Errorf("failed to resolve DID (%s): %w", identifier, err) 196 + } 197 + accountDID = ident.DID 198 + pdsEndpoint = ident.PDSEndpoint() 199 + } else { 200 + // It's a handle - resolve via local PDS first 201 + if r.handleResolver == nil { 202 + return "", fmt.Errorf("handle resolution not configured (PDS URL not set)") 203 + } 204 + 205 + // Resolve handle to DID via local PDS 206 + did, err := r.handleResolver.ResolveHandle(ctx, identifier) 207 + if err != nil { 208 + return "", fmt.Errorf("failed to resolve handle via PDS (%s): %w", identifier, err) 209 + } 210 + if did == "" { 211 + return "", fmt.Errorf("handle not found: %s", identifier) 212 + } 213 + 214 + slog.Info("dev mode: resolved handle via local PDS", "handle", identifier, "did", did) 215 + 216 + // Parse the DID 217 + parsedDID, err := syntax.ParseDID(did) 218 + if err != nil { 219 + return "", fmt.Errorf("invalid DID from PDS (%s): %w", did, err) 220 + } 221 + accountDID = parsedDID 222 + 223 + // Now look up the DID document via PLC to get PDS endpoint 224 + atid, err := syntax.ParseAtIdentifier(did) 225 + if err != nil { 226 + return "", fmt.Errorf("not a valid DID (%s): %w", did, err) 227 + } 228 + ident, err := dir.Lookup(ctx, *atid) 229 + if err != nil { 230 + return "", fmt.Errorf("failed to resolve DID document (%s): %w", did, err) 231 + } 232 + pdsEndpoint = ident.PDSEndpoint() 233 + } 234 + 235 + if pdsEndpoint == "" { 236 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 237 + } 238 + 239 + slog.Debug("dev mode: resolving auth server", 240 + "did", accountDID, 241 + "pds", pdsEndpoint) 242 + 243 + // Resolve auth server URL (allowing HTTP for localhost) 244 + authServerURL, err := r.ResolveAuthServerURL(ctx, pdsEndpoint) 245 + if err != nil { 246 + return "", fmt.Errorf("resolving auth server: %w", err) 247 + } 248 + 249 + slog.Info("dev mode: resolved auth server", "url", authServerURL) 250 + 251 + // Fetch auth server metadata using our dev-friendly resolver 252 + authMeta, err := r.ResolveAuthServerMetadataDev(ctx, authServerURL) 253 + if err != nil { 254 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 255 + } 256 + 257 + slog.Debug("dev mode: got auth server metadata", 258 + "issuer", authMeta.Issuer, 259 + "authorization_endpoint", authMeta.AuthorizationEndpoint, 260 + "token_endpoint", authMeta.TokenEndpoint) 261 + 262 + // Send auth request (PAR) using indigo's method 263 + info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier) 264 + if err != nil { 265 + return "", fmt.Errorf("auth request failed: %w", err) 266 + } 267 + 268 + // Set the account DID 269 + info.AccountDID = &accountDID 270 + 271 + // Persist auth request info 272 + client.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) 273 + 274 + // Build redirect URL 275 + params := url.Values{} 276 + params.Set("client_id", client.ClientApp.Config.ClientID) 277 + params.Set("request_uri", info.RequestURI) 278 + 279 + authEndpoint := authMeta.AuthorizationEndpoint 280 + redirectURL := fmt.Sprintf("%s?%s", authEndpoint, params.Encode()) 281 + 282 + slog.Info("dev mode: OAuth redirect URL built", "url_prefix", authEndpoint) 283 + 284 + return redirectURL, nil 285 + }
+106
internal/atproto/oauth/dev_resolver.go
··· 1 + //go:build dev 2 + 3 + package oauth 4 + 5 + import ( 6 + "context" 7 + "encoding/json" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "strings" 13 + "time" 14 + ) 15 + 16 + // DevHandleResolver resolves handles via local PDS for development 17 + // This is needed because local handles (e.g., user.local.coves.dev) can't be 18 + // resolved via standard DNS/HTTP well-known methods - they only exist on the local PDS. 19 + type DevHandleResolver struct { 20 + pdsURL string 21 + httpClient *http.Client 22 + } 23 + 24 + // NewDevHandleResolver creates a resolver that queries local PDS for handle resolution 25 + func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver { 26 + return &DevHandleResolver{ 27 + pdsURL: strings.TrimSuffix(pdsURL, "/"), 28 + httpClient: NewSSRFSafeHTTPClient(allowPrivateIPs), 29 + } 30 + } 31 + 32 + // ResolveHandle queries the local PDS to resolve a handle to a DID 33 + // Returns the DID if successful, or empty string if not found 34 + func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) { 35 + if r.pdsURL == "" { 36 + return "", fmt.Errorf("PDS URL not configured") 37 + } 38 + 39 + // Build the resolve handle URL 40 + resolveURL := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", 41 + r.pdsURL, url.QueryEscape(handle)) 42 + 43 + // Create request with context and timeout 44 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 45 + defer cancel() 46 + 47 + req, err := http.NewRequestWithContext(ctx, "GET", resolveURL, nil) 48 + if err != nil { 49 + return "", fmt.Errorf("failed to create request: %w", err) 50 + } 51 + req.Header.Set("User-Agent", "Coves/1.0") 52 + 53 + // Execute request 54 + resp, err := r.httpClient.Do(req) 55 + if err != nil { 56 + return "", fmt.Errorf("failed to query PDS: %w", err) 57 + } 58 + defer resp.Body.Close() 59 + 60 + // Check response status 61 + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest { 62 + return "", nil // Handle not found 63 + } 64 + if resp.StatusCode != http.StatusOK { 65 + return "", fmt.Errorf("PDS returned status %d", resp.StatusCode) 66 + } 67 + 68 + // Parse response 69 + var result struct { 70 + DID string `json:"did"` 71 + } 72 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 73 + return "", fmt.Errorf("failed to parse PDS response: %w", err) 74 + } 75 + 76 + if result.DID == "" { 77 + return "", nil // No DID in response 78 + } 79 + 80 + slog.Debug("resolved handle via local PDS", 81 + "handle", handle, 82 + "did", result.DID, 83 + "pds_url", r.pdsURL) 84 + 85 + return result.DID, nil 86 + } 87 + 88 + // ResolveIdentifier attempts to resolve a handle to DID, or returns the DID if already provided 89 + // This is the main entry point for the handlers 90 + func (r *DevHandleResolver) ResolveIdentifier(ctx context.Context, identifier string) (string, error) { 91 + // If it's already a DID, return as-is 92 + if strings.HasPrefix(identifier, "did:") { 93 + return identifier, nil 94 + } 95 + 96 + // Try to resolve the handle via local PDS 97 + did, err := r.ResolveHandle(ctx, identifier) 98 + if err != nil { 99 + return "", fmt.Errorf("failed to resolve handle via PDS: %w", err) 100 + } 101 + if did == "" { 102 + return "", fmt.Errorf("handle not found on local PDS: %s", identifier) 103 + } 104 + 105 + return did, nil 106 + }
+41
internal/atproto/oauth/dev_stubs.go
··· 1 + //go:build !dev 2 + 3 + package oauth 4 + 5 + import ( 6 + "context" 7 + 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + ) 10 + 11 + // DevHandleResolver is a stub for production builds. 12 + // The actual implementation is in dev_resolver.go (only compiled with -tags dev). 13 + type DevHandleResolver struct{} 14 + 15 + // NewDevHandleResolver returns nil in production builds. 16 + // Dev mode features are only available when built with -tags dev. 17 + func NewDevHandleResolver(pdsURL string, allowPrivateIPs bool) *DevHandleResolver { 18 + return nil 19 + } 20 + 21 + // ResolveHandle is a stub that should never be called in production. 22 + // The nil check in handlers.go prevents this from being reached. 23 + func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) { 24 + panic("dev mode: ResolveHandle called in production build - this should never happen") 25 + } 26 + 27 + // DevAuthResolver is a stub for production builds. 28 + // The actual implementation is in dev_auth_resolver.go (only compiled with -tags dev). 29 + type DevAuthResolver struct{} 30 + 31 + // NewDevAuthResolver returns nil in production builds. 32 + // Dev mode features are only available when built with -tags dev. 33 + func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver { 34 + return nil 35 + } 36 + 37 + // StartDevAuthFlow is a stub that should never be called in production. 38 + // The nil check in handlers.go prevents this from being reached. 39 + func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) { 40 + panic("dev mode: StartDevAuthFlow called in production build - this should never happen") 41 + }
+107 -15
internal/atproto/oauth/handlers.go
··· 8 8 "log/slog" 9 9 "net/http" 10 10 "net/url" 11 + "strings" 11 12 12 13 "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" ··· 146 147 147 148 // OAuthHandler handles OAuth-related HTTP endpoints 148 149 type OAuthHandler struct { 149 - client *OAuthClient 150 - store oauth.ClientAuthStore 151 - mobileStore MobileOAuthStore // For server-side CSRF validation 150 + client *OAuthClient 151 + store oauth.ClientAuthStore 152 + mobileStore MobileOAuthStore // For server-side CSRF validation 153 + devResolver *DevHandleResolver // For dev mode: resolve handles via local PDS 154 + devAuthResolver *DevAuthResolver // For dev mode: bypass HTTPS validation for localhost OAuth 152 155 } 153 156 154 157 // NewOAuthHandler creates a new OAuth handler ··· 161 164 // Check if the store implements MobileOAuthStore for server-side CSRF 162 165 if mobileStore, ok := store.(MobileOAuthStore); ok { 163 166 handler.mobileStore = mobileStore 167 + } 168 + 169 + // In dev mode, create resolvers for local PDS/PLC 170 + // This is needed because: 171 + // 1. Local handles (e.g., user.local.coves.dev) can't be resolved via DNS/HTTP 172 + // 2. Indigo's OAuth library requires HTTPS, which localhost doesn't have 173 + if client.Config.DevMode { 174 + if client.Config.PDSURL != "" { 175 + handler.devResolver = NewDevHandleResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs) 176 + slog.Info("dev mode: handle resolution via local PDS enabled", "pds_url", client.Config.PDSURL) 177 + } 178 + // Create dev auth resolver to bypass HTTPS validation (pass PDS URL for handle resolution) 179 + handler.devAuthResolver = NewDevAuthResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs) 180 + slog.Info("dev mode: localhost OAuth auth resolver enabled", "pds_url", client.Config.PDSURL) 164 181 } 165 182 166 183 return handler ··· 203 220 return 204 221 } 205 222 206 - // Start OAuth flow 207 - redirectURL, err := h.client.ClientApp.StartAuthFlow(ctx, identifier) 208 - if err != nil { 209 - slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 210 - http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 211 - return 223 + var redirectURL string 224 + var err error 225 + 226 + // DEV MODE: Use custom OAuth flow that bypasses HTTPS validation 227 + // This is needed because: 228 + // 1. Local handles can't be resolved via DNS/HTTP well-known 229 + // 2. Indigo's OAuth library requires HTTPS for auth servers 230 + if h.devAuthResolver != nil { 231 + slog.Info("dev mode: using localhost OAuth flow", "identifier", identifier) 232 + redirectURL, err = h.devAuthResolver.StartDevAuthFlow(ctx, h.client, identifier, h.client.ClientApp.Dir) 233 + if err != nil { 234 + slog.Error("dev mode: failed to start OAuth flow", "error", err, "identifier", identifier) 235 + http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 236 + return 237 + } 238 + } else { 239 + // Production mode: use standard indigo OAuth flow 240 + redirectURL, err = h.client.ClientApp.StartAuthFlow(ctx, identifier) 241 + if err != nil { 242 + slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 243 + http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 244 + return 245 + } 212 246 } 213 247 214 248 // Log OAuth flow initiation (sanitized - no full URL to avoid leaking state) ··· 223 257 func (h *OAuthHandler) HandleMobileLogin(w http.ResponseWriter, r *http.Request) { 224 258 ctx := r.Context() 225 259 260 + // DEV MODE: Redirect localhost to 127.0.0.1 for cookie consistency 261 + // The OAuth callback URL uses 127.0.0.1 (per RFC 8252), so cookies must be set 262 + // on 127.0.0.1. If user calls localhost, redirect to 127.0.0.1 first. 263 + if h.client.Config.DevMode && strings.Contains(r.Host, "localhost") { 264 + // Use the configured PublicURL host for consistency 265 + redirectURL := h.client.Config.PublicURL + r.URL.RequestURI() 266 + slog.Info("dev mode: redirecting localhost to PublicURL host for cookie consistency", 267 + "from", r.Host, "to", h.client.Config.PublicURL) 268 + http.Redirect(w, r, redirectURL, http.StatusFound) 269 + return 270 + } 271 + 226 272 // Get handle or DID from query params 227 273 identifier := r.URL.Query().Get("handle") 228 274 if identifier == "" { ··· 276 322 RedirectURI: mobileRedirectURI, 277 323 }) 278 324 279 - // Start OAuth flow (the store wrapper will save mobile data when auth request is saved) 280 - redirectURL, err := h.client.ClientApp.StartAuthFlow(mobileCtx, identifier) 281 - if err != nil { 282 - slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 283 - http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 284 - return 325 + var redirectURL string 326 + 327 + // DEV MODE: Use custom OAuth flow that bypasses HTTPS validation 328 + // This is needed because: 329 + // 1. Local handles can't be resolved via DNS/HTTP well-known 330 + // 2. Indigo's OAuth library requires HTTPS for auth servers 331 + if h.devAuthResolver != nil { 332 + slog.Info("dev mode: using localhost OAuth flow for mobile", "identifier", identifier) 333 + redirectURL, err = h.devAuthResolver.StartDevAuthFlow(mobileCtx, h.client, identifier, h.client.ClientApp.Dir) 334 + if err != nil { 335 + slog.Error("dev mode: failed to start OAuth flow", "error", err, "identifier", identifier) 336 + http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 337 + return 338 + } 339 + } else { 340 + // Production mode: use standard indigo OAuth flow 341 + redirectURL, err = h.client.ClientApp.StartAuthFlow(mobileCtx, identifier) 342 + if err != nil { 343 + slog.Error("failed to start OAuth flow", "error", err, "identifier", identifier) 344 + http.Error(w, fmt.Sprintf("failed to start OAuth flow: %v", err), http.StatusBadRequest) 345 + return 346 + } 285 347 } 286 348 287 349 // Log mobile OAuth flow initiation (sanitized - no full URLs or sensitive params) ··· 384 446 // Check if the handle is the special "handle.invalid" value 385 447 // This indicates that bidirectional verification failed (DID->handle->DID roundtrip failed) 386 448 if ident.Handle.String() == "handle.invalid" { 449 + // DEV MODE: For local handles, verify via PDS instead of DNS/HTTP 450 + // Local handles like "user.local.coves.dev" can't be resolved via DNS 451 + if h.devResolver != nil { 452 + // Get the handle from DID document (alsoKnownAs) 453 + declaredHandle := "" 454 + if len(ident.AlsoKnownAs) > 0 { 455 + // Extract handle from at:// URI 456 + for _, aka := range ident.AlsoKnownAs { 457 + if len(aka) > 5 && aka[:5] == "at://" { 458 + declaredHandle = aka[5:] 459 + break 460 + } 461 + } 462 + } 463 + 464 + if declaredHandle != "" { 465 + // Verify handle via PDS 466 + resolvedDID, err := h.devResolver.ResolveHandle(ctx, declaredHandle) 467 + if err == nil && resolvedDID == sessData.AccountDID.String() { 468 + slog.Info("OAuth callback successful (dev mode: handle verified via PDS)", 469 + "did", sessData.AccountDID, "handle", declaredHandle) 470 + goto handleVerificationPassed 471 + } 472 + slog.Warn("dev mode: PDS handle verification failed", 473 + "did", sessData.AccountDID, "handle", declaredHandle, 474 + "resolved_did", resolvedDID, "error", err) 475 + } 476 + } 477 + 387 478 slog.Warn("OAuth callback: bidirectional handle verification failed", 388 479 "did", sessData.AccountDID, 389 480 "handle", "handle.invalid", ··· 401 492 "did", sessData.AccountDID) 402 493 slog.Info("OAuth callback successful (no handle verification)", "did", sessData.AccountDID) 403 494 } 495 + handleVerificationPassed: 404 496 405 497 // Check if this is a mobile callback (check for mobile_redirect_uri cookie) 406 498 mobileRedirect, err := r.Cookie("mobile_redirect_uri")
+5 -1
scripts/dev-run.sh
··· 1 1 #!/bin/bash 2 2 # Development server runner - loads .env.dev before starting 3 + # Uses -tags dev to include dev-only code (localhost OAuth resolvers, etc.) 3 4 4 5 set -a # automatically export all variables 5 6 source .env.dev ··· 9 10 echo " IS_DEV_ENV: $IS_DEV_ENV" 10 11 echo " PLC_DIRECTORY_URL: $PLC_DIRECTORY_URL" 11 12 echo " JETSTREAM_URL: $JETSTREAM_URL" 13 + echo " APPVIEW_PUBLIC_URL: $APPVIEW_PUBLIC_URL" 14 + echo " PDS_URL: $PDS_URL" 15 + echo " Build tags: dev" 12 16 echo "" 13 17 14 - go run ./cmd/server 18 + go run -tags dev ./cmd/server