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

Configure Feed

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

docs: Update module path and configuration documentation

+244 -157
+55 -9
README.md
··· 19 19 20 20 ```bash 21 21 xcaddy build \ 22 - --with github.com/vvill/caddy-atproto-auth 22 + --with tangled.org/vvill.dev/caddy-atproto-auth 23 23 ``` 24 24 25 - ### Example: Centralized Auth Hub 25 + ## Configuration 26 + 27 + ### Global Options 28 + 29 + The `atproto` global block configures the shared storage and security settings. 26 30 27 31 ```caddyfile 28 32 { 29 33 atproto { 34 + # Path to the SQLite database. 35 + # Default: "atproto.db" 30 36 storage_path /var/lib/caddy/atproto.db 31 - cookie_secret "your-very-long-random-secret-key" 37 + 38 + # A random 32+ character string used to sign session cookies. 39 + # REQUIRED. 40 + cookie_secret "change-me-to-a-secure-random-string-at-least-32-chars" 32 41 } 33 42 } 43 + ``` 34 44 35 - # The Portal (Login page and OAuth endpoints) 45 + ### Authentication Portal (`atproto_portal`) 46 + 47 + The `atproto_portal` directive configures the central authentication server. This handles the OAuth flow, serves the login page, and issues session cookies. 48 + 49 + ```caddyfile 36 50 auth.example.com { 37 51 atproto_portal { 38 - name "My HomeLab" 52 + # The public domain of the portal. 53 + # REQUIRED. 39 54 domain auth.example.com 55 + 56 + # The display name shown on the login page. 57 + # Default: "Authentication Portal" 58 + name "My Services" 59 + 60 + # Custom UI templates (optional) 61 + ui { 62 + # Path to a custom HTML template for the login page. 63 + login_template /path/to/login.html 64 + } 40 65 } 41 66 } 67 + ``` 42 68 43 - # A protected application 69 + ### Authentication Gate (`atproto_gate`) 70 + 71 + The `atproto_gate` directive protects your services. It verifies the session cookie and enforces access control. 72 + 73 + ```caddyfile 44 74 app.example.com { 45 75 atproto_gate { 76 + # List of allowed identities (DIDs or Handles). 77 + # REQUIRED. 46 78 allow @alice.bsky.social 47 - allow did:plc:1234... 79 + allow did:plc:1234abcd... 80 + 81 + # URL of the central Auth Portal. 82 + # Requests without a valid session will be redirected here. 83 + # REQUIRED (unless in Standalone Mode). 48 84 portal_url https://auth.example.com 85 + 86 + # Standalone Mode Configuration (Alternative to portal_url) 87 + # If set, this gate acts as its own portal. 88 + # domain app.example.com 89 + 90 + # Custom UI templates (optional) 91 + ui { 92 + # Path to a custom HTML template for the "Access Denied" page. 93 + forbidden_template /path/to/forbidden.html 94 + } 49 95 } 50 - 96 + 51 97 reverse_proxy localhost:8080 52 98 } 53 99 ``` 54 100 55 101 ## Documentation 56 102 57 - See the `docs/` folder for detailed architectural constraints and configuration options. 103 + See the `docs/` folder for detailed architectural constraints and implementation details.
+2 -2
cmd/caddy/main.go
··· 3 3 import ( 4 4 caddycmd "github.com/caddyserver/caddy/v2/cmd" 5 5 _ "github.com/caddyserver/caddy/v2/modules/standard" 6 - 7 - _ "github.com/vvill/caddy-atproto-auth" 6 + 7 + _ "tangled.org/vvill.dev/caddy-atproto-auth" 8 8 ) 9 9 10 10 func main() {
+6 -6
gate.go
··· 11 11 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 12 12 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 13 13 "github.com/caddyserver/caddy/v2/modules/caddyhttp" 14 - "github.com/vvill/caddy-atproto-auth/internal/oauth" 15 - "github.com/vvill/caddy-atproto-auth/internal/resolver" 16 - "github.com/vvill/caddy-atproto-auth/internal/session" 17 - "github.com/vvill/caddy-atproto-auth/internal/ui" 18 14 "go.uber.org/zap" 15 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/oauth" 16 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/resolver" 17 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/session" 18 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/ui" 19 19 ) 20 20 21 21 func init() { ··· 161 161 } 162 162 if r.URL.Path == "/callback" { 163 163 // Process callback 164 - sessionData, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query()) 164 + sessionData, handle, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query()) 165 165 if err != nil { 166 166 return caddyhttp.Error(http.StatusBadRequest, err) 167 167 } ··· 169 169 // Create Session Cookie 170 170 cookie, err := g.sessions.CreateCookie( 171 171 sessionData.AccountDID, 172 - "user", // Placeholder handle 172 + handle, 173 173 24*7*time.Hour, 174 174 g.Domain, 175 175 )
+3 -22
global.go
··· 8 8 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 9 9 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 10 10 11 - "github.com/vvill/caddy-atproto-auth/internal/db" 12 - "github.com/vvill/caddy-atproto-auth/internal/oauth" 11 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/db" 12 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/oauth" 13 13 ) 14 14 15 15 func init() { ··· 39 39 func (a *App) Provision(ctx caddy.Context) error { 40 40 // Defaults 41 41 if a.StoragePath == "" { 42 - a.StoragePath = "atproto.db" // Relative to workdir or specific path 42 + a.StoragePath = "atproto.db" 43 43 } 44 - // Resolve relative path against Caddy's storage or workdir if needed. 45 - // For simplicity, we assume absolute or relative to CWD. 46 44 47 45 // Initialize DB 48 46 store, err := db.NewStore(a.StoragePath) ··· 50 48 return fmt.Errorf("failed to initialize atproto storage: %w", err) 51 49 } 52 50 a.Store = store 53 - 54 - // Initialize OAuth Manager (requires client ID and callback URL to be fully configured, 55 - // but those might be per-portal or global. The spec says "acts as an OAuth Client". 56 - // If the plugin acts as a *single* client for many subdomains, we need global config for client ID. 57 - // But spec says: "Path A: The Self-Contained Route" and "Path B: The Auth Hub". 58 - // This implies potentially different client IDs for different sites OR one central hub. 59 - // For now, let's defer OAuthManager creation to the Portal or Gate if it's per-route, 60 - // OR we need to add ClientID/CallbackURL to the global config if it's shared. 61 - // 62 - // Looking at the spec: 63 - // "The module acts as an OAuth Client" 64 - // "Global Configuration: storage_path, cookie_secret" 65 - // 66 - // It seems the App module holds the *Storage* and *Keys*. 67 - // The *Portal* (or Gate) defines the "Client" identity (metadata, callback). 68 - // However, `oauth.NewManager` takes a `db.Store`. So the App owns the Store. 69 - // The Portal will instantiate the Manager using the App's Store. 70 51 71 52 return nil 72 53 }
+1 -1
go.mod
··· 1 - module github.com/vvill/caddy-atproto-auth 1 + module tangled.org/vvill.dev/caddy-atproto-auth 2 2 3 3 go 1.25.5 4 4
+12 -5
internal/oauth/manager.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/atcrypto" 10 10 indigoOauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 11 12 - "github.com/vvill/caddy-atproto-auth/internal/db" 12 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/db" 13 13 ) 14 14 15 15 // Manager wraps the bluesky oauth client app to handle the lifecycle. ··· 88 88 } 89 89 90 90 // ProcessCallback exchanges the authorization code for an access token 91 - func (m *Manager) ProcessCallback(ctx context.Context, query url.Values) (*indigoOauth.ClientSessionData, error) { 91 + func (m *Manager) ProcessCallback(ctx context.Context, query url.Values) (*indigoOauth.ClientSessionData, string, error) { 92 92 sess, err := m.App.ProcessCallback(ctx, query) 93 93 if err != nil { 94 - return nil, fmt.Errorf("failed to process callback: %w", err) 94 + return nil, "", fmt.Errorf("failed to process callback: %w", err) 95 + } 96 + 97 + // Resolve the handle from the DID 98 + ident, err := m.App.Dir.LookupDID(ctx, sess.AccountDID) 99 + handle := "" 100 + if err == nil && ident != nil { 101 + handle = ident.Handle.String() 95 102 } 96 - return sess, nil 103 + 104 + return sess, handle, nil 97 105 } 98 -
+1 -1
internal/test/integration_test.go
··· 11 11 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 12 12 _ "github.com/caddyserver/caddy/v2/modules/standard" 13 13 14 - _ "github.com/vvill/caddy-atproto-auth" // Register modules 14 + _ "tangled.org/vvill.dev/caddy-atproto-auth" // Register modules 15 15 ) 16 16 17 17 func TestCaddyIntegration(t *testing.T) {
+121 -92
internal/ui/templates/forbidden.html
··· 1 - <!DOCTYPE html> 1 + <!doctype html> 2 2 <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>Access Denied - {{ .AppName }}</title> 7 - <style> 8 - :root { 9 - --bg-color: #f4f4f9; 10 - --card-bg: #ffffff; 11 - --text-color: #333333; 12 - --border-color: #dddddd; 13 - --error-color: #d32f2f; 14 - --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 15 - } 16 - 17 - @media (prefers-color-scheme: dark) { 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Access Denied - {{ .AppName }}</title> 7 + <style> 18 8 :root { 19 - --bg-color: #1a1a1a; 20 - --card-bg: #2d2d2d; 21 - --text-color: #ffffff; 22 - --border-color: #444444; 23 - --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); 9 + --bg-color: #f4f4f9; 10 + --card-bg: #ffffff; 11 + --text-color: #333333; 12 + --border-color: #dddddd; 13 + --error-color: #d32f2f; 14 + --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 15 + } 16 + 17 + @media (prefers-color-scheme: dark) { 18 + :root { 19 + --bg-color: #1a1a1a; 20 + --card-bg: #2d2d2d; 21 + --text-color: #ffffff; 22 + --border-color: #444444; 23 + --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); 24 + } 25 + } 26 + 27 + body { 28 + font-family: 29 + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 30 + Helvetica, Arial, sans-serif; 31 + background-color: var(--bg-color); 32 + color: var(--text-color); 33 + display: flex; 34 + flex-direction: column; 35 + justify-content: center; 36 + align-items: center; 37 + height: 100vh; 38 + margin: 0; 39 + padding: 1rem; 24 40 } 25 - } 26 41 27 - body { 28 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 29 - background-color: var(--bg-color); 30 - color: var(--text-color); 31 - display: flex; 32 - justify-content: center; 33 - align-items: center; 34 - height: 100vh; 35 - margin: 0; 36 - padding: 1rem; 37 - } 42 + main { 43 + background-color: var(--card-bg); 44 + padding: 2rem; 45 + border-radius: 12px; 46 + box-shadow: var(--shadow); 47 + width: 100%; 48 + max-width: 400px; 49 + text-align: center; 50 + border-top: 4px solid var(--error-color); 51 + } 38 52 39 - .error-card { 40 - background-color: var(--card-bg); 41 - padding: 2rem; 42 - border-radius: 12px; 43 - box-shadow: var(--shadow); 44 - width: 100%; 45 - max-width: 400px; 46 - text-align: center; 47 - border-top: 4px solid var(--error-color); 48 - } 53 + h1 { 54 + margin-top: 0; 55 + font-size: 1.5rem; 56 + margin-bottom: 1rem; 57 + } 49 58 50 - h1 { 51 - margin-top: 0; 52 - font-size: 1.5rem; 53 - margin-bottom: 1rem; 54 - } 59 + p { 60 + margin-bottom: 1.5rem; 61 + line-height: 1.5; 62 + } 55 63 56 - p { 57 - margin-bottom: 1.5rem; 58 - line-height: 1.5; 59 - } 64 + .icon { 65 + color: var(--error-color); 66 + margin-bottom: 1rem; 67 + } 60 68 61 - .icon { 62 - color: var(--error-color); 63 - margin-bottom: 1rem; 64 - } 69 + .icon svg { 70 + width: 48px; 71 + height: 48px; 72 + fill: currentColor; 73 + } 65 74 66 - .icon svg { 67 - width: 48px; 68 - height: 48px; 69 - fill: currentColor; 70 - } 75 + .subtext { 76 + opacity: 0.7; 77 + margin-bottom: 0; 78 + } 71 79 72 - a.button { 73 - display: inline-block; 74 - background-color: transparent; 75 - color: var(--text-color); 76 - border: 1px solid var(--border-color); 77 - padding: 0.75rem 1.5rem; 78 - text-decoration: none; 79 - border-radius: 6px; 80 - font-weight: 500; 81 - transition: background-color 0.2s; 82 - } 80 + a { 81 + color: var(--text-color); 82 + } 83 83 84 - a.button:hover { 85 - background-color: rgba(0, 0, 0, 0.05); 86 - } 84 + a.button { 85 + display: inline-block; 86 + background-color: transparent; 87 + color: var(--text-color); 88 + border: 1px solid var(--border-color); 89 + padding: 0.75rem 1.5rem; 90 + text-decoration: none; 91 + border-radius: 6px; 92 + font-weight: 500; 93 + transition: background-color 0.2s; 94 + } 87 95 88 - @media (prefers-color-scheme: dark) { 89 96 a.button:hover { 90 - background-color: rgba(255, 255, 255, 0.1); 97 + background-color: rgba(0, 0, 0, 0.05); 91 98 } 92 - } 93 - </style> 94 - </head> 95 - <body> 96 - <div class="error-card"> 97 - <div class="icon"> 98 - <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 99 - <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/> 100 - </svg> 101 - </div> 102 - <h1>Access Denied</h1> 103 - <p>You are logged in as <strong>{{ .Handle }}</strong> ({{ .DID }}), but you are not authorized to access this resource.</p> 104 - <a href="/logout" class="button">Log Out</a> 105 - </div> 106 - </body> 99 + 100 + @media (prefers-color-scheme: dark) { 101 + a.button:hover { 102 + background-color: rgba(255, 255, 255, 0.1); 103 + } 104 + } 105 + </style> 106 + </head> 107 + <body> 108 + <main> 109 + <div class="icon"> 110 + <svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> 111 + <path 112 + d="M0 26.016v-20q0-2.496 1.76-4.256t4.256-1.76h20q2.464 0 4.224 1.76t1.76 4.256v20q0 2.496-1.76 4.224t-4.224 1.76h-20q-2.496 0-4.256-1.76t-1.76-4.224zM4 26.016q0 0.832 0.576 1.408t1.44 0.576h20q0.8 0 1.408-0.576t0.576-1.408v-14.016h-24v14.016zM4 10.016h24v-4q0-0.832-0.576-1.408t-1.408-0.608h-20q-0.832 0-1.44 0.608t-0.576 1.408v4zM6.016 8v-1.984h1.984v1.984h-1.984zM10.016 8v-1.984h1.984v1.984h-1.984zM10.336 22.848l2.848-2.848-2.848-2.816 2.848-2.816 2.816 2.816 2.816-2.816 2.848 2.816-2.848 2.816 2.848 2.848-2.848 2.816-2.816-2.816-2.816 2.816zM14.016 8v-1.984h12v1.984h-12z" 113 + /> 114 + </svg> 115 + </div> 116 + <h1>Access Denied</h1> 117 + <p> 118 + You are logged in as <strong>{{ .Handle }}</strong> ({{ .DID 119 + }}), but you are not authorized to access this resource. 120 + </p> 121 + <a href="/logout" class="button">Log Out</a> 122 + </main> 123 + <footer> 124 + <p class="subtext"> 125 + Vector icon by 126 + <a 127 + href="https://github.com/d8vjork/batch-icons?ref=svgrepo.com" 128 + target="_blank" 129 + >Adam Whitcroft</a 130 + > 131 + in MIT License via 132 + <a href="https://www.svgrepo.com/" target="_blank">SVG Repo</a> 133 + </p> 134 + </footer> 135 + </body> 107 136 </html>
+38 -9
internal/ui/templates/login.html
··· 32 32 background-color: var(--bg-color); 33 33 color: var(--text-color); 34 34 display: flex; 35 + flex-direction: column; 35 36 justify-content: center; 36 37 align-items: center; 37 38 height: 100vh; 38 39 margin: 0; 39 40 } 40 41 41 - .login-card { 42 + main { 42 43 background-color: var(--card-bg); 43 44 padding: 2rem; 44 45 border-radius: 12px; ··· 100 101 background-color: var(--primary-hover); 101 102 } 102 103 104 + a { 105 + color: var(--text-color); 106 + } 107 + 108 + .subtext { 109 + opacity: 0.7; 110 + margin-bottom: 0; 111 + } 112 + 103 113 .logo { 104 114 margin-bottom: 1rem; 105 115 } ··· 112 122 </style> 113 123 </head> 114 124 <body> 115 - <div class="login-card"> 125 + <main> 116 126 <div class="logo"> 117 - <!-- Simple Placeholder Icon --> 118 - <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 127 + <svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> 119 128 <path 120 - d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" 129 + d="M0 16q0-3.232 1.28-6.208t3.392-5.12 5.12-3.392 6.208-1.28q3.264 0 6.24 1.28t5.088 3.392 3.392 5.12 1.28 6.208v6.016q0 2.496-1.76 4.224t-4.224 1.76q-2.272 0-3.936-1.472t-1.984-3.68q-1.952 1.152-4.096 1.152-2.176 0-4-1.056t-2.944-2.912-1.056-4.032q0-3.296 2.336-5.632t5.664-2.368 5.664 2.368 2.336 5.632v6.016q0 0.8 0.608 1.408t1.408 0.576q0.8 0 1.408-0.576t0.576-1.408v-6.016q0-3.264-1.6-6.016t-4.384-4.352-6.016-1.632-6.016 1.632-4.384 4.352-1.6 6.016 1.6 6.048 4.384 4.352 6.016 1.6h2.016q0.8 0 1.408 0.608t0.576 1.408-0.576 1.408-1.408 0.576h-2.016q-3.264 0-6.208-1.248t-5.12-3.424-3.392-5.12-1.28-6.208zM12 16q0 1.664 1.184 2.848t2.816 1.152 2.816-1.152 1.184-2.848-1.184-2.816-2.816-1.184-2.816 1.184-1.184 2.816z" 121 130 /> 122 131 </svg> 123 132 </div> 124 - <h1>Sign in with At-Protocol</h1> 133 + <h1>Sign in with your internet handle</h1> 125 134 <form action="/login" method="POST"> 126 135 <div class="input-group"> 127 - <label for="handle">User Handle</label> 136 + <label for="handle">Handle</label> 128 137 <input 129 138 type="text" 130 139 id="handle" 131 140 name="handle" 132 - placeholder="@user.bsky.social" 141 + placeholder="@user.selfhosted.social" 133 142 required 134 143 autofocus 135 144 /> 136 145 </div> 137 146 <button type="submit">Continue</button> 138 147 </form> 139 - </div> 148 + <p class="subtext"> 149 + Your Bluesky, Tangled, Pckt... AT Protocol account. <br /> 150 + See 151 + <a target="_blank" href="https://internethandle.org" 152 + >internethandle.org</a 153 + > 154 + for more info. 155 + </p> 156 + </main> 157 + <footer> 158 + <p class="subtext"> 159 + Vector icon by 160 + <a 161 + href="https://github.com/d8vjork/batch-icons?ref=svgrepo.com" 162 + target="_blank" 163 + >Adam Whitcroft</a 164 + > 165 + in MIT License via 166 + <a href="https://www.svgrepo.com/" target="_blank">SVG Repo</a> 167 + </p> 168 + </footer> 140 169 </body> 141 170 </html>
+5 -10
portal.go
··· 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/caddyhttp" 13 - "github.com/vvill/caddy-atproto-auth/internal/oauth" 14 - "github.com/vvill/caddy-atproto-auth/internal/session" 15 - "github.com/vvill/caddy-atproto-auth/internal/ui" 16 13 "go.uber.org/zap" 14 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/oauth" 15 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/session" 16 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/ui" 17 17 ) 18 18 19 19 func init() { ··· 162 162 ctx := r.Context() 163 163 query := r.URL.Query() 164 164 165 - sessionData, err := p.oauth.ProcessCallback(ctx, query) 165 + sessionData, handle, err := p.oauth.ProcessCallback(ctx, query) 166 166 if err != nil { 167 167 p.logger.Error("oauth callback failed", zap.Error(err)) 168 168 http.Error(w, fmt.Sprintf("Authentication failed: %v", err), http.StatusBadRequest) ··· 170 170 } 171 171 172 172 // Create Session Cookie 173 - // Use root domain for Auth Hub? Or specific? 174 - // For now, use the portal's domain. 175 173 cookie, err := p.sessions.CreateCookie( 176 174 sessionData.AccountDID, 177 - "user", // We don't have handle in ClientSessionData yet? Indigo might resolve it. 178 - // Actually ClientSessionData only has AccountDID. 179 - // We might need to resolve handle separately or store it in state? 180 - // For now, placeholder handle. 175 + handle, 181 176 24*7*time.Hour, 182 177 p.Domain, 183 178 )