Caddy module to require at-proto authentication and restrict routes to DIDs
3

Configure Feed

Select the types of activity you want to include in your feed.

Human: Rewrite README, cleanup old files, move test

+90 -202
-1
.gitignore
··· 1 - docs/
+87 -86
README.md
··· 1 - # caddy-atproto-auth 2 - 3 - A native Caddy module that provides Identity-Aware Proxy (IAP) capabilities using the **atproto** (Bluesky) OAuth 2.1 ecosystem. 4 - 5 - Turn any atproto identity into a "Web Passport" for your self-hosted services. The module acts as an OAuth Confidential Client, managing the DPoP cryptographic handshake, session persistence, and DID-based authorization without requiring external authentication sidecars like Authelia. 1 + # Caddy Atproto Auth 6 2 7 - ## Features 8 - 9 - - **Zero-Dependency**: Plugs directly into Caddy, no external databases (uses embedded SQLite). 10 - - **Stateless Verification**: Uses signed, domain-scoped cookies for lightning-fast request verification at the edge without database lookups. 11 - - **Transparent Session Refresh**: Automatically uses OAuth Refresh Tokens to extend sessions in the background, minimizing forced re-logins. 12 - - **Two Deployment Modes**: 13 - - *Standalone*: Add to any individual app's Caddyfile route directly. 14 - - *Centralized Hub*: Act as an Identity Provider (`auth.example.com`) granting SSO access to many subdomains (`app.example.com`). 15 - - **Full Customization**: Fully override the login and forbidden pages with your own HTML templates. 3 + A Caddy module that provides Identity-Aware Proxy (IAP) capabilities via AT Protocol OAuth 2.1. Dedicate an auth portal, place your gates, and define the handles or DIDs that should be allowed through (or `allow *`). 16 4 17 5 ## Usage 18 6 ··· 23 11 --with tangled.org/vvill.dev/caddy-atproto-auth 24 12 ``` 25 13 26 - ## Configuration 14 + ## Quick Start 27 15 28 - ### Important Note on Local Development 16 + ### Single App 29 17 30 - The AT Protocol OAuth flow requires the Authentication Server (PDS) to fetch client metadata from your application. If you are running Caddy on `localhost`, production PDS instances (like `bsky.social`) **cannot reach your local server**, resulting in an `invalid_client` error. 18 + ```caddyfile 19 + { 20 + app.example.com { 21 + route { 22 + atproto_portal { 23 + # So it doesn't conflict with your /login 24 + path_prefix atproto_auth 25 + } 26 + atproto_gate { 27 + # Specify handles/DIDs here or just require login 28 + allow @vvill.dev @tangled.org 29 + # If you need path_prefix above 30 + portal_url https://app.example.com/atproto_auth 31 + } 32 + # Whatever your app is 33 + reverse_proxy localhost:8080 34 + } 35 + } 36 + } 37 + ``` 38 + ### Multiple Apps 39 + 40 + ```caddyfile 41 + { 42 + auth.example.com { 43 + route { 44 + atproto_portal { 45 + cookie_domain example.com 46 + } 47 + } 48 + } 49 + # Repeat this for N apps :) 50 + app1.example.com { 51 + route { 52 + atproto_gate { 53 + allow * 54 + portal_url https://auth.example.com 55 + cookie_domain example.com 56 + } 57 + # Whatever your app is 58 + reverse_proxy localhost:8081 59 + } 60 + } 61 + } 62 + ``` 31 63 32 - To test locally with a real PDS, you must expose your local server to the internet using a tunnel (e.g., `ngrok`, `cloudflared`, or `tailscale funnel`) and configure the `domain` directive with the public tunnel URL. 64 + ## Full Configuration 33 65 34 66 ### Global Options 35 67 36 - The `atproto` global block configures the shared storage and security settings. 37 - 38 68 ```caddyfile 39 69 { 70 + # Optional 40 71 atproto { 41 72 # Path to the SQLite database. 42 - # Default: "atproto.db" 73 + # Default: atproto.db 43 74 storage_path /var/lib/caddy/atproto.db 44 75 45 76 # A random 32+ character string used to sign session cookies. 46 - # OPTIONAL. If omitted, a secure random key is generated and stored in the DB. 47 - # Required for "Auth Hub" setups where Gate and Portal are on different machines. 48 - # cookie_secret "change-me-to-a-secure-random-string-at-least-32-chars" 77 + # For use where a Gate and its Portal are on different machines. 78 + # Default: 32 random bytes 79 + cookie_secret "change-me-to-a-secure-random-string-at-least-32-chars" 49 80 } 50 81 } 51 82 ``` ··· 54 85 55 86 The `atproto_portal` directive configures the central authentication server. This handles the OAuth flow, serves the login page, and issues session cookies. 56 87 88 + You can replace the html templates with your own. 89 + - The login page: 90 + - May show `{{ .AppName }}` and `{{ .Error }}` 91 + - POSTs to `{{ .LoginURL }}` with `handle` and `redirect_to` 92 + - Use: `<input type="hidden" name="redirect_to" value="{{ .Redirect }}" />` 93 + - The forbidden page: 94 + - May show `{{ .AppName }}`, `{{ .DID }}` and `{{ .Handle }}` 95 + - Should link to `{{ .LogoutURL }}` 96 + 57 97 ```caddyfile 58 98 auth.example.com { 59 99 atproto_portal { 60 - # The public domain of the portal. 61 - # REQUIRED. 62 - domain auth.example.com 100 + # Required when the portal isn't 101 + # on the same subdomain as the gate 102 + cookie_domain example.com 63 103 64 104 # The display name shown on the login page. 65 105 # Default: "Authentication Portal" 66 106 name "My Services" 67 - 68 - # Custom UI templates (optional) 69 - ui { 70 - # Path to a custom HTML template for the login page. 71 - login_template /path/to/login.html 72 - } 107 + 108 + # Path to a custom HTML template for the login page. 109 + login_template /path/to/login.html 110 + # Path to a custom HTML template for the access denied page. 111 + forbidden_template /path/to/forbidden.html 73 112 74 - # Path Prefix Configuration (Optional) 75 113 # Prepends a prefix to /login and /logout paths. 76 114 # Useful to avoid conflicts with downstream apps. 77 - # e.g., path_prefix /auth -> /auth/login, /auth/logout 78 - # path_prefix /auth 115 + path_prefix atproto_auth 116 + 117 + # Change cookie name, also to avoid conficts 118 + # Default: atproto_session 119 + cookie_name caddy_atproto_session 79 120 } 80 121 } 81 122 ``` ··· 83 124 ### Authentication Gate (`atproto_gate`) 84 125 85 126 The `atproto_gate` directive protects your services. It verifies the session cookie and enforces access control. 127 + 128 + If you set `cookie_domain` or `cookie_name` in this gate's portal, it needs to be set here as well. 86 129 87 130 ```caddyfile 88 131 app.example.com { 89 132 atproto_gate { 90 133 # List of allowed identities (DIDs or Handles). 91 - # OPTIONAL. If omitted, any authenticated user will be allowed. 92 - allow @alice.bsky.social 134 + allow @alice.selfhosted.social 93 135 allow did:plc:1234abcd... 94 - 95 - # URL of the central Auth Portal. 96 - # Requests without a valid session will be redirected here. 97 - # REQUIRED. 98 - # If the Portal uses a path_prefix (e.g. /auth), append it here (e.g. https://auth.example.com/auth) 99 - portal_url https://auth.example.com 100 - 101 - # Client ID for Transparent Refresh (Optional) 102 - # If provided, enables background token refreshing using the shared DB. 103 - # Should match the Portal's Client ID (usually https://domain/.well-known/oauth-client-metadata.json). 104 - # client_id https://app.example.com/.well-known/oauth-client-metadata.json 105 - 106 - # Custom UI templates (optional) 107 - ui { 108 - # Path to a custom HTML template for the "Access Denied" page. 109 - forbidden_template /path/to/forbidden.html 110 - } 111 - } 112 - 113 - reverse_proxy localhost:8080 114 - } 115 - ``` 116 - 117 - ## Production Best Practices 136 + # Allow all (still requires sign in) 137 + allow * 118 138 119 - ### Enable Compression 139 + # If the Portal uses a path_prefix, include it here 140 + portal_url https://auth.example.com/atproto_auth 120 141 121 - To ensure the Login and Forbidden HTML pages (which include inline CSS and SVGs) are delivered as quickly as possible, enable Gzip and Zstd compression in your Caddyfile. This reduces the transfer size significantly. 122 - 123 - ```caddyfile 124 - app.example.com { 125 - # Enable compression for all responses (including auth pages) 126 - encode zstd gzip 127 - 128 - route { 129 - atproto_portal { ... } 130 - atproto_gate { ... } 131 - reverse_proxy localhost:8080 142 + # Match Portal's values if set. Used for token refresh. 143 + cookie_domain example.com 144 + cookie_name caddy_atproto_session 132 145 } 133 146 } 134 147 ``` 135 - 136 - ### Localhost Development 137 - 138 - The AT Protocol OAuth flow requires the Authentication Server (PDS) to fetch client metadata from your application. If you are running Caddy on `localhost`: 139 - 140 - 1. **Issue**: Production PDS instances (like `bsky.social`) **cannot reach** `http://localhost`. 141 - 2. **Symptom**: You will see an `invalid_client` error during login. 142 - 3. **Fix**: Expose your local server to the internet using a tunnel (e.g., `ngrok`, `cloudflared`, or `tailscale funnel`) and set your `domain` config to that public URL. 143 - 144 - ## Documentation 145 - 146 - See the `docs/` folder for detailed architectural constraints and implementation details.
-79
e2e/Caddyfile
··· 1 - { 2 - admin off 3 - atproto { 4 - storage_path ./e2e.db 5 - cookie_secret "testing-secret-must-be-at-least-32-bytes-long" 6 - } 7 - } 8 - 9 - # --- Scenario 1: Standalone App (Composed) --- 10 - # Acts as its own portal using composition. 11 - http://localhost:8081 { 12 - route { 13 - atproto_portal { 14 - domain localhost:8081 15 - name "Standalone App 1" 16 - } 17 - atproto_gate { 18 - # Portal is local 19 - portal_url / 20 - # Enable refresh by providing client_id 21 - client_id https://localhost:8081/.well-known/oauth-client-metadata.json 22 - allow @vvill.dev 23 - } 24 - 25 - # Protected content 26 - respond "Welcome to Standalone App! You are authenticated." 27 - } 28 - } 29 - 30 - # --- Scenario 2: Centralized Auth Hub --- 31 - 32 - # The Portal (Identity Provider) 33 - http://localhost:8082 { 34 - route { 35 - atproto_portal { 36 - domain localhost:8082 37 - name "Local E2E Hub" 38 - } 39 - } 40 - } 41 - 42 - # The Service (Relying Party) 43 - # Redirects users to port 8082 for login 44 - http://localhost:8083 { 45 - route { 46 - atproto_gate { 47 - # Auth Hub mode (no 'domain' set) 48 - portal_url http://localhost:8082 49 - allow @vvill.dev 50 - } 51 - 52 - respond "Welcome to Service App! You authenticated via the Hub." 53 - } 54 - } 55 - 56 - # --- Scenario 3: Standalone app with Custom Paths --- 57 - 58 - # Standalone app serves The Portal, gates access, then the App 59 - http://localhost:8084 { 60 - route { 61 - # First, auth portal 62 - atproto_portal { 63 - domain localhost:8084 64 - name "Standalone App 3" 65 - path_prefix /atproto 66 - } 67 - # Then, make sure user is authenticated 68 - atproto_gate { 69 - # Portal is local but at custom path. 70 - # Gate appends /login to portal_url. 71 - # So we set portal_url to /atproto 72 - portal_url /atproto 73 - client_id https://localhost:8084/.well-known/oauth-client-metadata.json 74 - allow @vvill.dev 75 - } 76 - # Then, they have access to the App 77 - respond "Welcome to Standalone App 3! Custom paths working." 78 - } 79 - }
-23
e2e/README.md
··· 1 - # End-to-End Testing 2 - 3 - This directory contains a Caddyfile configuration to test both "Standalone" and "Auth Hub" modes locally. 4 - 5 - ## Usage 6 - 7 - 1. **Build the Caddy binary**: 8 - ```bash 9 - go build -o caddy ../cmd/caddy 10 - ``` 11 - 12 - 2. **Run Caddy**: 13 - ```bash 14 - ./caddy run --config Caddyfile --adapter caddyfile 15 - ``` 16 - 17 - 3. **Test in Browser**: 18 - 19 - * **Standalone Mode**: Open [http://localhost:8081](http://localhost:8081). 20 - * Expected: Redirect to `/login`. 21 - 22 - * **Auth Hub Mode**: Open [http://localhost:8083](http://localhost:8083). 23 - * Expected: Redirect to `http://localhost:8082/login` (The Portal).
+3 -13
internal/test/integration_test.go integration_test.go
··· 1 - package test 1 + package caddyatprotoauth 2 2 3 3 import ( 4 4 "net/http" ··· 10 10 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 11 11 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 12 12 _ "github.com/caddyserver/caddy/v2/modules/standard" 13 - 14 - _ "tangled.org/vvill.dev/caddy-atproto-auth" // Register modules 15 13 ) 16 14 17 15 func TestCaddyIntegration(t *testing.T) { ··· 21 19 admin off 22 20 atproto { 23 21 storage_path :memory: 24 - cookie_secret "my-secret-key-must-be-very-long-and-secure" 25 22 } 26 23 } 27 24 ··· 29 26 route /auth/* { 30 27 uri strip_prefix /auth 31 28 atproto_portal { 32 - domain localhost:8080 33 29 name "Test Portal" 34 30 } 35 31 } 36 - 32 + 37 33 route /protected/* { 38 34 atproto_gate { 39 - allow @test.bsky.social 35 + allow @tangled.org 40 36 portal_url http://localhost:8080/auth 41 37 } 42 38 respond "Authorized Content" ··· 62 58 t.Fatalf("Failed to load caddy: %v", err) 63 59 } 64 60 defer caddy.Stop() 65 - 66 - // 3. Helper to simulate requests 67 - // Since Caddy is running its own listeners, we can just make HTTP requests to it. 68 - // But in a test environment, binding ports might be flaky. 69 - // Ideally we'd invoke the handler directly, but getting the handler chain from Caddy is complex. 70 - // We'll rely on the real HTTP server since we used :8080. 71 61 72 62 // Wait a moment for server start 73 63 time.Sleep(100 * time.Millisecond)