···11-# caddy-atproto-auth
22-33-A native Caddy module that provides Identity-Aware Proxy (IAP) capabilities using the **atproto** (Bluesky) OAuth 2.1 ecosystem.
44-55-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.
11+# Caddy Atproto Auth
6277-## Features
88-99-- **Zero-Dependency**: Plugs directly into Caddy, no external databases (uses embedded SQLite).
1010-- **Stateless Verification**: Uses signed, domain-scoped cookies for lightning-fast request verification at the edge without database lookups.
1111-- **Transparent Session Refresh**: Automatically uses OAuth Refresh Tokens to extend sessions in the background, minimizing forced re-logins.
1212-- **Two Deployment Modes**:
1313- - *Standalone*: Add to any individual app's Caddyfile route directly.
1414- - *Centralized Hub*: Act as an Identity Provider (`auth.example.com`) granting SSO access to many subdomains (`app.example.com`).
1515-- **Full Customization**: Fully override the login and forbidden pages with your own HTML templates.
33+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 *`).
164175## Usage
186···2311 --with tangled.org/vvill.dev/caddy-atproto-auth
2412```
25132626-## Configuration
1414+## Quick Start
27152828-### Important Note on Local Development
1616+### Single App
29173030-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.
1818+```caddyfile
1919+{
2020+ app.example.com {
2121+ route {
2222+ atproto_portal {
2323+ # So it doesn't conflict with your /login
2424+ path_prefix atproto_auth
2525+ }
2626+ atproto_gate {
2727+ # Specify handles/DIDs here or just require login
2828+ allow @vvill.dev @tangled.org
2929+ # If you need path_prefix above
3030+ portal_url https://app.example.com/atproto_auth
3131+ }
3232+ # Whatever your app is
3333+ reverse_proxy localhost:8080
3434+ }
3535+ }
3636+}
3737+```
3838+### Multiple Apps
3939+4040+```caddyfile
4141+{
4242+ auth.example.com {
4343+ route {
4444+ atproto_portal {
4545+ cookie_domain example.com
4646+ }
4747+ }
4848+ }
4949+ # Repeat this for N apps :)
5050+ app1.example.com {
5151+ route {
5252+ atproto_gate {
5353+ allow *
5454+ portal_url https://auth.example.com
5555+ cookie_domain example.com
5656+ }
5757+ # Whatever your app is
5858+ reverse_proxy localhost:8081
5959+ }
6060+ }
6161+}
6262+```
31633232-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.
6464+## Full Configuration
33653466### Global Options
35673636-The `atproto` global block configures the shared storage and security settings.
3737-3868```caddyfile
3969{
7070+ # Optional
4071 atproto {
4172 # Path to the SQLite database.
4242- # Default: "atproto.db"
7373+ # Default: atproto.db
4374 storage_path /var/lib/caddy/atproto.db
44754576 # A random 32+ character string used to sign session cookies.
4646- # OPTIONAL. If omitted, a secure random key is generated and stored in the DB.
4747- # Required for "Auth Hub" setups where Gate and Portal are on different machines.
4848- # cookie_secret "change-me-to-a-secure-random-string-at-least-32-chars"
7777+ # For use where a Gate and its Portal are on different machines.
7878+ # Default: 32 random bytes
7979+ cookie_secret "change-me-to-a-secure-random-string-at-least-32-chars"
4980 }
5081}
5182```
···54855586The `atproto_portal` directive configures the central authentication server. This handles the OAuth flow, serves the login page, and issues session cookies.
56878888+You can replace the html templates with your own.
8989+- The login page:
9090+ - May show `{{ .AppName }}` and `{{ .Error }}`
9191+ - POSTs to `{{ .LoginURL }}` with `handle` and `redirect_to`
9292+ - Use: `<input type="hidden" name="redirect_to" value="{{ .Redirect }}" />`
9393+- The forbidden page:
9494+ - May show `{{ .AppName }}`, `{{ .DID }}` and `{{ .Handle }}`
9595+ - Should link to `{{ .LogoutURL }}`
9696+5797```caddyfile
5898auth.example.com {
5999 atproto_portal {
6060- # The public domain of the portal.
6161- # REQUIRED.
6262- domain auth.example.com
100100+ # Required when the portal isn't
101101+ # on the same subdomain as the gate
102102+ cookie_domain example.com
6310364104 # The display name shown on the login page.
65105 # Default: "Authentication Portal"
66106 name "My Services"
6767-6868- # Custom UI templates (optional)
6969- ui {
7070- # Path to a custom HTML template for the login page.
7171- login_template /path/to/login.html
7272- }
107107+108108+ # Path to a custom HTML template for the login page.
109109+ login_template /path/to/login.html
110110+ # Path to a custom HTML template for the access denied page.
111111+ forbidden_template /path/to/forbidden.html
731127474- # Path Prefix Configuration (Optional)
75113 # Prepends a prefix to /login and /logout paths.
76114 # Useful to avoid conflicts with downstream apps.
7777- # e.g., path_prefix /auth -> /auth/login, /auth/logout
7878- # path_prefix /auth
115115+ path_prefix atproto_auth
116116+117117+ # Change cookie name, also to avoid conficts
118118+ # Default: atproto_session
119119+ cookie_name caddy_atproto_session
79120 }
80121}
81122```
···83124### Authentication Gate (`atproto_gate`)
8412585126The `atproto_gate` directive protects your services. It verifies the session cookie and enforces access control.
127127+128128+If you set `cookie_domain` or `cookie_name` in this gate's portal, it needs to be set here as well.
8612987130```caddyfile
88131app.example.com {
89132 atproto_gate {
90133 # List of allowed identities (DIDs or Handles).
9191- # OPTIONAL. If omitted, any authenticated user will be allowed.
9292- allow @alice.bsky.social
134134+ allow @alice.selfhosted.social
93135 allow did:plc:1234abcd...
9494-9595- # URL of the central Auth Portal.
9696- # Requests without a valid session will be redirected here.
9797- # REQUIRED.
9898- # If the Portal uses a path_prefix (e.g. /auth), append it here (e.g. https://auth.example.com/auth)
9999- portal_url https://auth.example.com
100100-101101- # Client ID for Transparent Refresh (Optional)
102102- # If provided, enables background token refreshing using the shared DB.
103103- # Should match the Portal's Client ID (usually https://domain/.well-known/oauth-client-metadata.json).
104104- # client_id https://app.example.com/.well-known/oauth-client-metadata.json
105105-106106- # Custom UI templates (optional)
107107- ui {
108108- # Path to a custom HTML template for the "Access Denied" page.
109109- forbidden_template /path/to/forbidden.html
110110- }
111111- }
112112-113113- reverse_proxy localhost:8080
114114-}
115115-```
116116-117117-## Production Best Practices
136136+ # Allow all (still requires sign in)
137137+ allow *
118138119119-### Enable Compression
139139+ # If the Portal uses a path_prefix, include it here
140140+ portal_url https://auth.example.com/atproto_auth
120141121121-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.
122122-123123-```caddyfile
124124-app.example.com {
125125- # Enable compression for all responses (including auth pages)
126126- encode zstd gzip
127127-128128- route {
129129- atproto_portal { ... }
130130- atproto_gate { ... }
131131- reverse_proxy localhost:8080
142142+ # Match Portal's values if set. Used for token refresh.
143143+ cookie_domain example.com
144144+ cookie_name caddy_atproto_session
132145 }
133146}
134147```
135135-136136-### Localhost Development
137137-138138-The AT Protocol OAuth flow requires the Authentication Server (PDS) to fetch client metadata from your application. If you are running Caddy on `localhost`:
139139-140140-1. **Issue**: Production PDS instances (like `bsky.social`) **cannot reach** `http://localhost`.
141141-2. **Symptom**: You will see an `invalid_client` error during login.
142142-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.
143143-144144-## Documentation
145145-146146-See the `docs/` folder for detailed architectural constraints and implementation details.
-79
e2e/Caddyfile
···11-{
22- admin off
33- atproto {
44- storage_path ./e2e.db
55- cookie_secret "testing-secret-must-be-at-least-32-bytes-long"
66- }
77-}
88-99-# --- Scenario 1: Standalone App (Composed) ---
1010-# Acts as its own portal using composition.
1111-http://localhost:8081 {
1212- route {
1313- atproto_portal {
1414- domain localhost:8081
1515- name "Standalone App 1"
1616- }
1717- atproto_gate {
1818- # Portal is local
1919- portal_url /
2020- # Enable refresh by providing client_id
2121- client_id https://localhost:8081/.well-known/oauth-client-metadata.json
2222- allow @vvill.dev
2323- }
2424-2525- # Protected content
2626- respond "Welcome to Standalone App! You are authenticated."
2727- }
2828-}
2929-3030-# --- Scenario 2: Centralized Auth Hub ---
3131-3232-# The Portal (Identity Provider)
3333-http://localhost:8082 {
3434- route {
3535- atproto_portal {
3636- domain localhost:8082
3737- name "Local E2E Hub"
3838- }
3939- }
4040-}
4141-4242-# The Service (Relying Party)
4343-# Redirects users to port 8082 for login
4444-http://localhost:8083 {
4545- route {
4646- atproto_gate {
4747- # Auth Hub mode (no 'domain' set)
4848- portal_url http://localhost:8082
4949- allow @vvill.dev
5050- }
5151-5252- respond "Welcome to Service App! You authenticated via the Hub."
5353- }
5454-}
5555-5656-# --- Scenario 3: Standalone app with Custom Paths ---
5757-5858-# Standalone app serves The Portal, gates access, then the App
5959-http://localhost:8084 {
6060- route {
6161- # First, auth portal
6262- atproto_portal {
6363- domain localhost:8084
6464- name "Standalone App 3"
6565- path_prefix /atproto
6666- }
6767- # Then, make sure user is authenticated
6868- atproto_gate {
6969- # Portal is local but at custom path.
7070- # Gate appends /login to portal_url.
7171- # So we set portal_url to /atproto
7272- portal_url /atproto
7373- client_id https://localhost:8084/.well-known/oauth-client-metadata.json
7474- allow @vvill.dev
7575- }
7676- # Then, they have access to the App
7777- respond "Welcome to Standalone App 3! Custom paths working."
7878- }
7979-}
-23
e2e/README.md
···11-# End-to-End Testing
22-33-This directory contains a Caddyfile configuration to test both "Standalone" and "Auth Hub" modes locally.
44-55-## Usage
66-77-1. **Build the Caddy binary**:
88- ```bash
99- go build -o caddy ../cmd/caddy
1010- ```
1111-1212-2. **Run Caddy**:
1313- ```bash
1414- ./caddy run --config Caddyfile --adapter caddyfile
1515- ```
1616-1717-3. **Test in Browser**:
1818-1919- * **Standalone Mode**: Open [http://localhost:8081](http://localhost:8081).
2020- * Expected: Redirect to `/login`.
2121-2222- * **Auth Hub Mode**: Open [http://localhost:8083](http://localhost:8083).
2323- * Expected: Redirect to `http://localhost:8082/login` (The Portal).
···11-package test
11+package caddyatprotoauth
2233import (
44 "net/http"
···1010 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
1111 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
1212 _ "github.com/caddyserver/caddy/v2/modules/standard"
1313-1414- _ "tangled.org/vvill.dev/caddy-atproto-auth" // Register modules
1513)
16141715func TestCaddyIntegration(t *testing.T) {
···2119 admin off
2220 atproto {
2321 storage_path :memory:
2424- cookie_secret "my-secret-key-must-be-very-long-and-secure"
2522 }
2623 }
2724···2926 route /auth/* {
3027 uri strip_prefix /auth
3128 atproto_portal {
3232- domain localhost:8080
3329 name "Test Portal"
3430 }
3531 }
3636-3232+3733 route /protected/* {
3834 atproto_gate {
3939- allow @test.bsky.social
3535+ allow @tangled.org
4036 portal_url http://localhost:8080/auth
4137 }
4238 respond "Authorized Content"
···6258 t.Fatalf("Failed to load caddy: %v", err)
6359 }
6460 defer caddy.Stop()
6565-6666- // 3. Helper to simulate requests
6767- // Since Caddy is running its own listeners, we can just make HTTP requests to it.
6868- // But in a test environment, binding ports might be flaky.
6969- // Ideally we'd invoke the handler directly, but getting the handler chain from Caddy is complex.
7070- // We'll rely on the real HTTP server since we used :8080.
71617262 // Wait a moment for server start
7363 time.Sleep(100 * time.Millisecond)