Add AT Protocol OAuth 2.0 authentication for dashboard
- Implement full OAuth 2.0 with PKCE using haileyok/atproto-oauth-golang - Backend For Frontend (BFF) pattern: tokens stored server-side only - AES-256-GCM encrypted session cookies - Auto token refresh when near expiry - Restrict access to allowed handles (1440.news, wehrv.bsky.social) - Add genkey utility for generating OAuth configuration - Generic error messages to prevent handle enumeration - Server-side logging of failed login attempts for security monitoring New files: - oauth.go: OAuth client wrapper and DID/handle resolution - oauth_session.go: Session management with encrypted cookies - oauth_middleware.go: RequireAuth middleware for route protection - oauth_handlers.go: Login, callback, logout, metadata endpoints - cmd/genkey/main.go: Generate OAuth secrets and JWK keypair - oauth.env.example: Configuration template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,4 @@ feeds/
|
||||
feeds.db/
|
||||
1440.db
|
||||
pds.env
|
||||
oauth.env
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Note:** Always run applications in containers via `docker compose up -d --build` when possible. This ensures proper networking between services (database, traefik, etc.) and matches the production environment.
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
@@ -67,6 +69,11 @@ The application runs seven independent goroutine loops:
|
||||
| `db.go` | PostgreSQL schema (domains, feeds, items tables with tsvector FTS) |
|
||||
| `dashboard.go` | HTTP server, JSON APIs, HTML template |
|
||||
| `publisher.go` | AT Protocol PDS integration for posting items |
|
||||
| `oauth.go` | OAuth 2.0 client wrapper for AT Protocol authentication |
|
||||
| `oauth_session.go` | Session management with AES-256-GCM encrypted cookies |
|
||||
| `oauth_middleware.go` | RequireAuth middleware for protecting routes |
|
||||
| `oauth_handlers.go` | OAuth HTTP endpoints (login, callback, logout, metadata) |
|
||||
| `routes.go` | HTTP route registration with auth middleware |
|
||||
|
||||
### Database Schema
|
||||
|
||||
@@ -128,3 +135,50 @@ PDS configuration in `pds.env`:
|
||||
PDS_HOST=https://pds.1440.news
|
||||
PDS_ADMIN_PASSWORD=<admin_password>
|
||||
```
|
||||
|
||||
## Dashboard Authentication
|
||||
|
||||
The dashboard is protected by AT Protocol OAuth 2.0. Only the `@1440.news` handle can access it.
|
||||
|
||||
### OAuth Setup
|
||||
|
||||
1. Generate configuration:
|
||||
```bash
|
||||
go run ./cmd/genkey
|
||||
```
|
||||
|
||||
2. Create `oauth.env` with the generated values:
|
||||
```
|
||||
OAUTH_COOKIE_SECRET=<generated_hex_string>
|
||||
OAUTH_PRIVATE_JWK=<generated_jwk_json>
|
||||
```
|
||||
|
||||
3. Optionally set the base URL (defaults to https://app.1440.news):
|
||||
```
|
||||
OAUTH_BASE_URL=https://app.1440.news
|
||||
```
|
||||
|
||||
### OAuth Flow
|
||||
|
||||
1. User navigates to `/dashboard` -> redirected to `/auth/login`
|
||||
2. User enters their Bluesky handle
|
||||
3. User is redirected to Bluesky authorization
|
||||
4. After approval, callback verifies handle is `1440.news`
|
||||
5. Session cookie is set, user redirected to dashboard
|
||||
|
||||
### OAuth Endpoints
|
||||
|
||||
- `/.well-known/oauth-client-metadata` - Client metadata (public)
|
||||
- `/.well-known/jwks.json` - Public JWK set (public)
|
||||
- `/auth/login` - Login page / initiates OAuth flow
|
||||
- `/auth/callback` - OAuth callback handler
|
||||
- `/auth/logout` - Clears session
|
||||
- `/auth/session` - Returns current session info (JSON)
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Tokens are stored server-side only (BFF pattern)
|
||||
- Browser only receives encrypted session cookie (AES-256-GCM)
|
||||
- Access restricted to single handle (`1440.news`)
|
||||
- Sessions expire after 24 hours
|
||||
- Automatic token refresh when within 5 minutes of expiry
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// genkey generates an ES256 JWK keypair for OAuth client authentication
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Generating OAuth configuration...")
|
||||
fmt.Println()
|
||||
|
||||
// Generate cookie secret
|
||||
cookieSecret := make([]byte, 32)
|
||||
if _, err := rand.Read(cookieSecret); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating cookie secret: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate ES256 JWK
|
||||
key, err := helpers.GenerateKey(nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating JWK: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Marshal JWK to JSON
|
||||
keyJSON, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marshaling JWK: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Add these values to your oauth.env file:")
|
||||
fmt.Println()
|
||||
fmt.Printf("OAUTH_COOKIE_SECRET=%s\n", hex.EncodeToString(cookieSecret))
|
||||
fmt.Printf("OAUTH_PRIVATE_JWK=%s\n", string(keyJSON))
|
||||
fmt.Println()
|
||||
fmt.Println("Keep these values secret!")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
stop_grace_period: 30s
|
||||
env_file:
|
||||
- pds.env
|
||||
- oauth.env
|
||||
environment:
|
||||
DB_HOST: atproto-postgres
|
||||
DB_PORT: 5432
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# OAuth Configuration for 1440.news Dashboard
|
||||
# Copy this file to oauth.env and fill in the values
|
||||
|
||||
# Cookie encryption secret (32 bytes / 64 hex characters)
|
||||
# Generate with: openssl rand -hex 32
|
||||
OAUTH_COOKIE_SECRET=
|
||||
|
||||
# ES256 private JWK for client authentication
|
||||
# Generate with the command below, then paste the JSON output here (on one line)
|
||||
#
|
||||
# To generate a key using Go:
|
||||
# go run ./cmd/genkey
|
||||
#
|
||||
# Or use openssl + jq:
|
||||
# openssl ecparam -name prime256v1 -genkey -noout | openssl ec -text -noout 2>/dev/null | \
|
||||
# awk '/priv:/{p=1} p{print}' | head -5 | tr -d ' \n:' | xxd -r -p | base64
|
||||
#
|
||||
# The JWK should look like:
|
||||
# {"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","kid":"..."}
|
||||
OAUTH_PRIVATE_JWK=
|
||||
|
||||
# Optional: Override the base URL for OAuth redirects
|
||||
# Default: https://app.1440.news (production) or http://localhost:4321 (local)
|
||||
# OAUTH_BASE_URL=https://app.1440.news
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
oauth "github.com/haileyok/atproto-oauth-golang"
|
||||
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
)
|
||||
|
||||
// OAuthManager handles OAuth 2.0 authentication for the dashboard
|
||||
type OAuthManager struct {
|
||||
client *oauth.Client
|
||||
clientID string
|
||||
redirectURI string
|
||||
privateJWK jwk.Key
|
||||
publicJWK jwk.Key
|
||||
sessions *SessionStore
|
||||
cookieSecret []byte
|
||||
allowedScope string
|
||||
}
|
||||
|
||||
// OAuthConfig holds configuration for the OAuth manager
|
||||
type OAuthConfig struct {
|
||||
ClientID string // URL to client metadata (e.g., https://app.1440.news/.well-known/oauth-client-metadata)
|
||||
RedirectURI string // OAuth callback URL (e.g., https://app.1440.news/auth/callback)
|
||||
CookieSecret string // 32-byte hex string for AES-256-GCM encryption
|
||||
PrivateJWK string // ES256 private key as JSON
|
||||
}
|
||||
|
||||
// NewOAuthManager creates a new OAuth manager
|
||||
func NewOAuthManager(cfg OAuthConfig) (*OAuthManager, error) {
|
||||
// Parse cookie secret (must be 32 bytes for AES-256)
|
||||
cookieSecret, err := parseHexSecret(cfg.CookieSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cookie secret: %v", err)
|
||||
}
|
||||
if len(cookieSecret) != 32 {
|
||||
return nil, fmt.Errorf("cookie secret must be 32 bytes, got %d", len(cookieSecret))
|
||||
}
|
||||
|
||||
// Parse private JWK
|
||||
privateJWK, err := helpers.ParseJWKFromBytes([]byte(cfg.PrivateJWK))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid private JWK: %v", err)
|
||||
}
|
||||
|
||||
// Extract public key
|
||||
publicJWK, err := privateJWK.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract public key: %v", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with longer timeout
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Create OAuth client
|
||||
client, err := oauth.NewClient(oauth.ClientArgs{
|
||||
Http: httpClient,
|
||||
ClientJwk: privateJWK,
|
||||
ClientId: cfg.ClientID,
|
||||
RedirectUri: cfg.RedirectURI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth client: %v", err)
|
||||
}
|
||||
|
||||
return &OAuthManager{
|
||||
client: client,
|
||||
clientID: cfg.ClientID,
|
||||
redirectURI: cfg.RedirectURI,
|
||||
privateJWK: privateJWK,
|
||||
publicJWK: publicJWK,
|
||||
sessions: NewSessionStore(),
|
||||
cookieSecret: cookieSecret,
|
||||
allowedScope: "atproto",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadOAuthConfig loads OAuth configuration from environment or oauth.env file
|
||||
func LoadOAuthConfig(baseURL string) (*OAuthConfig, error) {
|
||||
cfg := &OAuthConfig{
|
||||
ClientID: baseURL + "/.well-known/oauth-client-metadata",
|
||||
RedirectURI: baseURL + "/auth/callback",
|
||||
}
|
||||
|
||||
// Try environment variables first
|
||||
cfg.CookieSecret = os.Getenv("OAUTH_COOKIE_SECRET")
|
||||
cfg.PrivateJWK = os.Getenv("OAUTH_PRIVATE_JWK")
|
||||
|
||||
// Fall back to oauth.env file
|
||||
if cfg.CookieSecret == "" || cfg.PrivateJWK == "" {
|
||||
if data, err := os.ReadFile("oauth.env"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") || line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
switch key {
|
||||
case "OAUTH_COOKIE_SECRET":
|
||||
cfg.CookieSecret = value
|
||||
case "OAUTH_PRIVATE_JWK":
|
||||
cfg.PrivateJWK = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.CookieSecret == "" {
|
||||
return nil, fmt.Errorf("OAUTH_COOKIE_SECRET not configured")
|
||||
}
|
||||
if cfg.PrivateJWK == "" {
|
||||
return nil, fmt.Errorf("OAUTH_PRIVATE_JWK not configured")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// parseHexSecret converts a hex string to bytes
|
||||
func parseHexSecret(hex string) ([]byte, error) {
|
||||
if len(hex)%2 != 0 {
|
||||
return nil, fmt.Errorf("hex string must have even length")
|
||||
}
|
||||
b := make([]byte, len(hex)/2)
|
||||
for i := 0; i < len(hex); i += 2 {
|
||||
var val byte
|
||||
for j := 0; j < 2; j++ {
|
||||
c := hex[i+j]
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
val = val*16 + (c - '0')
|
||||
case c >= 'a' && c <= 'f':
|
||||
val = val*16 + (c - 'a' + 10)
|
||||
case c >= 'A' && c <= 'F':
|
||||
val = val*16 + (c - 'A' + 10)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid hex character: %c", c)
|
||||
}
|
||||
}
|
||||
b[i/2] = val
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// resolveHandle resolves a Bluesky handle to a DID
|
||||
func resolveHandle(ctx context.Context, handle string) (string, error) {
|
||||
// Normalize handle (remove @ prefix and whitespace)
|
||||
handle = strings.TrimSpace(handle)
|
||||
handle = strings.TrimPrefix(handle, "@")
|
||||
handle = strings.ToLower(handle)
|
||||
|
||||
// Try DNS-based resolution first
|
||||
url := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=%s", neturl.QueryEscape(handle))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("resolve handle failed: %s", string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
DID string `json:"did"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.DID, nil
|
||||
}
|
||||
|
||||
// resolveDIDToHandle resolves a DID to the current handle
|
||||
func resolveDIDToHandle(ctx context.Context, did string) (string, error) {
|
||||
// Fetch DID document
|
||||
var docURL string
|
||||
if strings.HasPrefix(did, "did:plc:") {
|
||||
docURL = fmt.Sprintf("https://plc.directory/%s", did)
|
||||
} else if strings.HasPrefix(did, "did:web:") {
|
||||
domain := strings.TrimPrefix(did, "did:web:")
|
||||
docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported DID method: %s", did)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
AlsoKnownAs []string `json:"alsoKnownAs"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find the at:// handle
|
||||
for _, aka := range doc.AlsoKnownAs {
|
||||
if strings.HasPrefix(aka, "at://") {
|
||||
return strings.TrimPrefix(aka, "at://"), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no handle found for DID %s", did)
|
||||
}
|
||||
|
||||
// resolveDIDToService gets the PDS service URL from a DID
|
||||
func resolveDIDToService(ctx context.Context, did string) (string, error) {
|
||||
var docURL string
|
||||
if strings.HasPrefix(did, "did:plc:") {
|
||||
docURL = fmt.Sprintf("https://plc.directory/%s", did)
|
||||
} else if strings.HasPrefix(did, "did:web:") {
|
||||
domain := strings.TrimPrefix(did, "did:web:")
|
||||
docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported DID method: %s", did)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
Service []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||
} `json:"service"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find the atproto_pds service
|
||||
for _, svc := range doc.Service {
|
||||
if svc.Type == "AtprotoPersonalDataServer" || svc.ID == "#atproto_pds" {
|
||||
return svc.ServiceEndpoint, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no PDS service found for DID %s", did)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||
)
|
||||
|
||||
var allowedHandles = map[string]bool{
|
||||
"1440.news": true,
|
||||
"wehrv.bsky.social": true,
|
||||
}
|
||||
|
||||
// HandleClientMetadata serves the OAuth client metadata
|
||||
func (m *OAuthManager) HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the JWKS URI from the same host
|
||||
scheme := "https"
|
||||
if r.TLS == nil && (r.Host == "localhost" || r.Host == "127.0.0.1" || r.Host == "app.1440.localhost:4321") {
|
||||
scheme = "http"
|
||||
}
|
||||
baseURL := scheme + "://" + r.Host
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"client_id": m.clientID,
|
||||
"client_name": "1440.news Dashboard",
|
||||
"client_uri": baseURL,
|
||||
"redirect_uris": []string{m.redirectURI},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "atproto",
|
||||
"token_endpoint_auth_method": "private_key_jwt",
|
||||
"token_endpoint_auth_signing_alg": "ES256",
|
||||
"dpop_bound_access_tokens": true,
|
||||
"jwks_uri": baseURL + "/.well-known/jwks.json",
|
||||
"application_type": "web",
|
||||
"subject_type": "public",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(metadata)
|
||||
}
|
||||
|
||||
// HandleJWKS serves the public JWK set
|
||||
func (m *OAuthManager) HandleJWKS(w http.ResponseWriter, r *http.Request) {
|
||||
jwks := helpers.CreateJwksResponseObject(m.publicJWK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
||||
|
||||
// HandleLogin serves the login page or initiates OAuth flow
|
||||
func (m *OAuthManager) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if already logged in
|
||||
if session := m.GetSessionFromCookie(r); session != nil {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// If handle is provided, start OAuth flow
|
||||
handle := r.URL.Query().Get("handle")
|
||||
if handle != "" {
|
||||
m.startOAuthFlow(w, r, handle)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve login page
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl := template.Must(template.New("login").Parse(loginPageHTML))
|
||||
tmpl.Execute(w, nil)
|
||||
}
|
||||
|
||||
// startOAuthFlow initiates the OAuth flow for a given handle
|
||||
func (m *OAuthManager) startOAuthFlow(w http.ResponseWriter, r *http.Request, handle string) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fmt.Printf("OAuth: starting flow for handle: %s\n", handle)
|
||||
|
||||
// Resolve handle to DID
|
||||
did, err := resolveHandle(ctx, handle)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("OAuth: resolved DID: %s\n", did)
|
||||
|
||||
// Resolve DID to PDS service URL
|
||||
pdsURL, err := resolveDIDToService(ctx, did)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to resolve PDS: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("OAuth: PDS URL: %s\n", pdsURL)
|
||||
|
||||
// Get auth server from PDS
|
||||
authServerURL, err := m.client.ResolvePdsAuthServer(ctx, pdsURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to resolve auth server: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("OAuth: auth server: %s\n", authServerURL)
|
||||
|
||||
// Fetch auth server metadata
|
||||
authMeta, err := m.client.FetchAuthServerMetadata(ctx, authServerURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to fetch auth metadata: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("OAuth: auth endpoint: %s\n", authMeta.AuthorizationEndpoint)
|
||||
|
||||
// Generate DPoP private key for this auth flow
|
||||
dpopKey, err := helpers.GenerateKey(nil)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate DPoP key: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dpopKeyBytes, err := json.Marshal(dpopKey)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to marshal DPoP key: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Send PAR (Pushed Authorization Request)
|
||||
fmt.Printf("OAuth: sending PAR to %s\n", authServerURL)
|
||||
parResp, err := m.client.SendParAuthRequest(
|
||||
ctx,
|
||||
authServerURL,
|
||||
authMeta,
|
||||
handle,
|
||||
m.allowedScope,
|
||||
dpopKey,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("OAuth: PAR failed: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("PAR request failed: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("OAuth: PAR success, request_uri: %s\n", parResp.RequestUri)
|
||||
|
||||
// Save pending auth state
|
||||
pending := &PendingAuth{
|
||||
State: parResp.State,
|
||||
PkceVerifier: parResp.PkceVerifier,
|
||||
DpopPrivateJWK: string(dpopKeyBytes),
|
||||
DpopNonce: parResp.DpopAuthserverNonce,
|
||||
DID: did,
|
||||
PdsURL: pdsURL,
|
||||
AuthserverIss: authMeta.Issuer,
|
||||
}
|
||||
m.sessions.SavePending(parResp.State, pending)
|
||||
|
||||
// Build authorization URL
|
||||
authURL, err := url.Parse(authMeta.AuthorizationEndpoint)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid auth endpoint: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
q := authURL.Query()
|
||||
q.Set("client_id", m.clientID)
|
||||
q.Set("request_uri", parResp.RequestUri)
|
||||
authURL.RawQuery = q.Encode()
|
||||
|
||||
fmt.Printf("OAuth: redirecting to: %s\n", authURL.String())
|
||||
|
||||
// Use JavaScript redirect to preserve browser security headers
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting...</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to authorization server...</p>
|
||||
<script>window.location.href = %q;</script>
|
||||
<noscript><a href="%s">Click here to continue</a></noscript>
|
||||
</body>
|
||||
</html>`, authURL.String(), authURL.String())
|
||||
}
|
||||
|
||||
// HandleCallback handles the OAuth callback
|
||||
func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get callback parameters
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
iss := r.URL.Query().Get("iss")
|
||||
errorParam := r.URL.Query().Get("error")
|
||||
errorDesc := r.URL.Query().Get("error_description")
|
||||
|
||||
// Check for errors from auth server
|
||||
if errorParam != "" {
|
||||
http.Error(w, fmt.Sprintf("Authorization error: %s - %s", errorParam, errorDesc), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" || state == "" {
|
||||
http.Error(w, "Missing code or state parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve pending auth state
|
||||
pending := m.sessions.GetPending(state)
|
||||
if pending == nil {
|
||||
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify issuer matches
|
||||
if iss != "" && iss != pending.AuthserverIss {
|
||||
http.Error(w, "Issuer mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse DPoP private key
|
||||
dpopKey, err := helpers.ParseJWKFromBytes([]byte(pending.DpopPrivateJWK))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to parse DPoP key: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
tokenResp, err := m.client.InitialTokenRequest(
|
||||
ctx,
|
||||
code,
|
||||
pending.AuthserverIss,
|
||||
pending.PkceVerifier,
|
||||
pending.DpopNonce,
|
||||
dpopKey,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify scope
|
||||
if tokenResp.Scope != m.allowedScope {
|
||||
http.Error(w, fmt.Sprintf("Invalid scope: expected %s, got %s", m.allowedScope, tokenResp.Scope), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve DID to handle
|
||||
handle, err := resolveDIDToHandle(ctx, tokenResp.Sub)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Verify user is allowed
|
||||
if !allowedHandles[handle] {
|
||||
fmt.Printf("OAuth: access denied for handle: %s\n", handle)
|
||||
http.Error(w, "Access denied.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
session, err := m.sessions.CreateSession(tokenResp.Sub, handle)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to create session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store token info in session
|
||||
session.AccessToken = tokenResp.AccessToken
|
||||
session.RefreshToken = tokenResp.RefreshToken
|
||||
session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
session.DpopPrivateJWK = pending.DpopPrivateJWK
|
||||
session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce
|
||||
session.PdsURL = pending.PdsURL
|
||||
session.AuthserverIss = pending.AuthserverIss
|
||||
m.sessions.UpdateSession(session)
|
||||
|
||||
// Set session cookie
|
||||
if err := m.SetSessionCookie(w, session.ID); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to set cookie: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleLogout clears the session and redirects to login
|
||||
func (m *OAuthManager) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// Get current session
|
||||
session := m.GetSessionFromCookie(r)
|
||||
if session != nil {
|
||||
// Delete session from store
|
||||
m.sessions.DeleteSession(session.ID)
|
||||
}
|
||||
|
||||
// Clear cookie
|
||||
m.ClearSessionCookie(w)
|
||||
|
||||
// Handle API vs browser request
|
||||
if r.Method == http.MethodPost || isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "logged out",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to login for browser requests
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleSessionInfo returns current session info (for API calls)
|
||||
func (m *OAuthManager) HandleSessionInfo(w http.ResponseWriter, r *http.Request) {
|
||||
session := m.GetSessionFromCookie(r)
|
||||
if session == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "not authenticated",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
info := &SessionInfo{
|
||||
DID: session.DID,
|
||||
Handle: session.Handle,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
const loginPageHTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sign In - 1440.news Dashboard</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
font-size: 3em;
|
||||
color: #fff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tagline {
|
||||
color: #888;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.login-card {
|
||||
background: #151515;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
}
|
||||
h2 {
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 1em;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #0af;
|
||||
}
|
||||
input[type="text"]::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: #0af;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.login-btn:hover {
|
||||
background: #0cf;
|
||||
}
|
||||
.login-btn:disabled {
|
||||
background: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
background: #2a1515;
|
||||
border: 1px solid #533;
|
||||
color: #f88;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 15px;
|
||||
display: none;
|
||||
}
|
||||
.bluesky-icon {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">1440.news</div>
|
||||
<p class="tagline">Dashboard Authentication</p>
|
||||
|
||||
<div class="login-card">
|
||||
<h2>Sign In with Bluesky</h2>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
|
||||
<form id="loginForm" action="/auth/login" method="get">
|
||||
<div class="form-group">
|
||||
<label for="handle">Bluesky Handle</label>
|
||||
<input type="text" id="handle" name="handle" placeholder="handle.bsky.social" required autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn" id="loginBtn">
|
||||
<svg class="bluesky-icon" viewBox="0 0 568 501" fill="currentColor">
|
||||
<path d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 googling=383.039C286.251 378.892 284.991 374.834 284 371.019C283.009 374.834 281.749 378.892 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0533 296.954 15.7778 224.501C9.94533 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z"/>
|
||||
</svg>
|
||||
Sign In with Bluesky
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', function(e) {
|
||||
const handle = document.getElementById('handle').value.trim();
|
||||
if (!handle) {
|
||||
e.preventDefault();
|
||||
document.getElementById('error').style.display = 'block';
|
||||
document.getElementById('error').textContent = 'Please enter your handle';
|
||||
return;
|
||||
}
|
||||
document.getElementById('loginBtn').disabled = true;
|
||||
document.getElementById('loginBtn').textContent = 'Redirecting...';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/haileyok/atproto-oauth-golang/helpers"
|
||||
)
|
||||
|
||||
// RequireAuth is middleware that protects routes requiring authentication
|
||||
func (m *OAuthManager) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := m.GetSessionFromCookie(r)
|
||||
if session == nil {
|
||||
// Check if this is an API call (wants JSON response)
|
||||
if isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Redirect to login for browser requests
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if token needs refresh (refresh when within 5 minutes of expiry)
|
||||
if time.Until(session.TokenExpiry) < 5*time.Minute {
|
||||
if err := m.refreshToken(r.Context(), session); err != nil {
|
||||
// Token refresh failed - clear session and redirect to login
|
||||
m.sessions.DeleteSession(session.ID)
|
||||
m.ClearSessionCookie(w)
|
||||
|
||||
if isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "session expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Add session to request context
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// sessionContextKey is the context key for the OAuth session
|
||||
type contextKey string
|
||||
|
||||
const sessionContextKey contextKey = "oauth_session"
|
||||
|
||||
// GetSession retrieves the session from request context
|
||||
func GetSession(r *http.Request) *OAuthSession {
|
||||
session, _ := r.Context().Value(sessionContextKey).(*OAuthSession)
|
||||
return session
|
||||
}
|
||||
|
||||
// isAPIRequest checks if the request expects JSON response
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
// Check Accept header
|
||||
accept := r.Header.Get("Accept")
|
||||
if strings.Contains(accept, "application/json") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check URL path
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check X-Requested-With header (for AJAX)
|
||||
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// refreshToken refreshes the OAuth access token
|
||||
func (m *OAuthManager) refreshToken(ctx context.Context, session *OAuthSession) error {
|
||||
if session.RefreshToken == "" {
|
||||
return nil // No refresh token available
|
||||
}
|
||||
|
||||
// Parse the DPoP private key
|
||||
dpopKey, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJWK))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
tokenResp, err := m.client.RefreshTokenRequest(
|
||||
ctx,
|
||||
session.RefreshToken,
|
||||
session.AuthserverIss,
|
||||
session.DpopAuthserverNonce,
|
||||
dpopKey,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update session with new tokens
|
||||
session.AccessToken = tokenResp.AccessToken
|
||||
session.RefreshToken = tokenResp.RefreshToken
|
||||
session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce
|
||||
|
||||
// Save updated session
|
||||
m.sessions.UpdateSession(session)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "1440_session"
|
||||
sessionTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// OAuthSession stores the OAuth session state for a user
|
||||
type OAuthSession struct {
|
||||
ID string `json:"id"`
|
||||
DID string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
|
||||
// OAuth tokens (stored server-side only)
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenExpiry time.Time `json:"token_expiry"`
|
||||
|
||||
// DPoP state
|
||||
DpopPrivateJWK string `json:"dpop_private_jwk"`
|
||||
DpopAuthserverNonce string `json:"dpop_authserver_nonce"`
|
||||
DpopPdsNonce string `json:"dpop_pds_nonce"`
|
||||
|
||||
// Auth server info
|
||||
PdsURL string `json:"pds_url"`
|
||||
AuthserverIss string `json:"authserver_iss"`
|
||||
}
|
||||
|
||||
// PendingAuth stores state during the OAuth flow (before callback)
|
||||
type PendingAuth struct {
|
||||
State string `json:"state"`
|
||||
PkceVerifier string `json:"pkce_verifier"`
|
||||
DpopPrivateJWK string `json:"dpop_private_jwk"`
|
||||
DpopNonce string `json:"dpop_nonce"`
|
||||
DID string `json:"did"`
|
||||
PdsURL string `json:"pds_url"`
|
||||
AuthserverIss string `json:"authserver_iss"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SessionStore manages sessions in memory
|
||||
type SessionStore struct {
|
||||
sessions map[string]*OAuthSession
|
||||
pending map[string]*PendingAuth // keyed by state
|
||||
mu sync.RWMutex
|
||||
cleanupOnce sync.Once
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store
|
||||
func NewSessionStore() *SessionStore {
|
||||
s := &SessionStore{
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
pending: make(map[string]*PendingAuth),
|
||||
}
|
||||
s.startCleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
// startCleanup starts a background goroutine to clean up expired sessions
|
||||
func (s *SessionStore) startCleanup() {
|
||||
s.cleanupOnce.Do(func() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
s.cleanup()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup removes expired sessions and pending auths
|
||||
func (s *SessionStore) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Clean up expired sessions
|
||||
for id, sess := range s.sessions {
|
||||
if now.After(sess.ExpiresAt) {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old pending auths (10 minute timeout)
|
||||
for state, pending := range s.pending {
|
||||
if now.Sub(pending.CreatedAt) > 10*time.Minute {
|
||||
delete(s.pending, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession creates a new session and returns it
|
||||
func (s *SessionStore) CreateSession(did, handle string) (*OAuthSession, error) {
|
||||
id, err := generateRandomID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session := &OAuthSession{
|
||||
ID: id,
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(sessionTTL),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[id] = session
|
||||
s.mu.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by ID
|
||||
func (s *SessionStore) GetSession(id string) *OAuthSession {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
session, ok := s.sessions[id]
|
||||
if !ok || time.Now().After(session.ExpiresAt) {
|
||||
return nil
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
// UpdateSession updates a session
|
||||
func (s *SessionStore) UpdateSession(session *OAuthSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
// DeleteSession removes a session
|
||||
func (s *SessionStore) DeleteSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
|
||||
// SavePending saves pending OAuth state
|
||||
func (s *SessionStore) SavePending(state string, pending *PendingAuth) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
pending.CreatedAt = time.Now()
|
||||
s.pending[state] = pending
|
||||
}
|
||||
|
||||
// GetPending retrieves and removes pending OAuth state
|
||||
func (s *SessionStore) GetPending(state string) *PendingAuth {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
pending, ok := s.pending[state]
|
||||
if ok {
|
||||
delete(s.pending, state)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
// generateRandomID generates a random session ID
|
||||
func generateRandomID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// encryptSessionID encrypts a session ID using AES-256-GCM
|
||||
func encryptSessionID(sessionID string, key []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(sessionID), nil)
|
||||
return base64.URLEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// decryptSessionID decrypts a session ID using AES-256-GCM
|
||||
func decryptSessionID(encrypted string, key []byte) (string, error) {
|
||||
ciphertext, err := base64.URLEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(ciphertext) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// SetSessionCookie sets an encrypted session cookie
|
||||
func (m *OAuthManager) SetSessionCookie(w http.ResponseWriter, sessionID string) error {
|
||||
encrypted, err := encryptSessionID(sessionID, m.cookieSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: encrypted,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSessionFromCookie retrieves the session from the request cookie
|
||||
func (m *OAuthManager) GetSessionFromCookie(r *http.Request) *OAuthSession {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionID, err := decryptSessionID(cookie.Value, m.cookieSecret)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.sessions.GetSession(sessionID)
|
||||
}
|
||||
|
||||
// ClearSessionCookie removes the session cookie
|
||||
func (m *OAuthManager) ClearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// SessionInfo is the public session info returned to the client
|
||||
type SessionInfo struct {
|
||||
DID string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// MarshalJSON converts SessionInfo to JSON
|
||||
func (s *SessionInfo) MarshalJSON() ([]byte, error) {
|
||||
type Alias SessionInfo
|
||||
return json.Marshal((*Alias)(s))
|
||||
}
|
||||
@@ -3,13 +3,58 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c *Crawler) StartDashboard(addr string) error {
|
||||
http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine base URL for OAuth
|
||||
baseURL := os.Getenv("OAUTH_BASE_URL")
|
||||
if baseURL == "" {
|
||||
// Default based on whether we're in production
|
||||
if strings.Contains(addr, "0.0.0.0") {
|
||||
baseURL = "https://app.1440.news"
|
||||
} else {
|
||||
baseURL = "http://" + addr
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize OAuth manager
|
||||
oauthCfg, err := LoadOAuthConfig(baseURL)
|
||||
var oauth *OAuthManager
|
||||
if err != nil {
|
||||
fmt.Printf("OAuth not configured: %v (dashboard will be unprotected)\n", err)
|
||||
} else {
|
||||
oauth, err = NewOAuthManager(*oauthCfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to initialize OAuth: %v (dashboard will be unprotected)\n", err)
|
||||
oauth = nil
|
||||
} else {
|
||||
fmt.Println("OAuth authentication enabled for dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth endpoints (always public)
|
||||
if oauth != nil {
|
||||
http.HandleFunc("/.well-known/oauth-client-metadata", oauth.HandleClientMetadata)
|
||||
http.HandleFunc("/.well-known/jwks.json", oauth.HandleJWKS)
|
||||
http.HandleFunc("/auth/login", oauth.HandleLogin)
|
||||
http.HandleFunc("/auth/callback", oauth.HandleCallback)
|
||||
http.HandleFunc("/auth/logout", oauth.HandleLogout)
|
||||
http.HandleFunc("/auth/session", oauth.HandleSessionInfo)
|
||||
}
|
||||
|
||||
// Helper to wrap handlers with auth if OAuth is enabled
|
||||
withAuth := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
if oauth != nil {
|
||||
return oauth.RequireAuth(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
http.HandleFunc("/dashboard", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleDashboard(w, r)
|
||||
})
|
||||
}))
|
||||
|
||||
// Root handler for url.1440.news short URLs and 1440.news accounts directory
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -43,117 +88,117 @@ func (c *Crawler) StartDashboard(addr string) error {
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.HandleFunc("/api/stats", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIStats(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/allDomains", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/allDomains", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIAllDomains(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/domainFeeds", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/domainFeeds", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIDomainFeeds(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/feedInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/feedInfo", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIFeedInfo(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/feedItems", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/feedItems", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIFeedItems(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/search", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPISearch(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/tlds", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/tlds", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPITLDs(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/tldDomains", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/tldDomains", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPITLDDomains(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/revisitDomain", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/revisitDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIRevisitDomain(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/priorityCrawl", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/priorityCrawl", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIPriorityCrawl(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/checkFeed", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/checkFeed", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPICheckFeed(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/domainsByStatus", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/domainsByStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIDomainsByStatus(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/feedsByStatus", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/feedsByStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIFeedsByStatus(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/domains", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIDomains(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/feeds", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/feeds", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIFeeds(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/setDomainStatus", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/setDomainStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPISetDomainStatus(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/filter", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/filter", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIFilter(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/enablePublish", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/enablePublish", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIEnablePublish(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/disablePublish", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/disablePublish", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIDisablePublish(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/publishEnabled", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/publishEnabled", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIPublishEnabled(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/publishDenied", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/publishDenied", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIPublishDenied(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/publishCandidates", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/publishCandidates", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIPublishCandidates(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/setPublishStatus", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/setPublishStatus", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPISetPublishStatus(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/unpublishedItems", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/unpublishedItems", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIUnpublishedItems(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/testPublish", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/testPublish", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPITestPublish(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/deriveHandle", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/deriveHandle", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIDeriveHandle(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/publishFeed", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/publishFeed", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIPublishFeed(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/createAccount", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/createAccount", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPICreateAccount(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/publishFeedFull", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/publishFeedFull", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIPublishFeedFull(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/updateProfile", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/updateProfile", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIUpdateProfile(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/languages", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/languages", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPILanguages(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/denyDomain", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/denyDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIDenyDomain(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/undenyDomain", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/undenyDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIUndenyDomain(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/dropDomain", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/dropDomain", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIDropDomain(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/tldStats", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/tldStats", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPITLDStats(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/resetAllPublishing", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/resetAllPublishing", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIResetAllPublishing(w, r)
|
||||
})
|
||||
http.HandleFunc("/api/refreshProfiles", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
http.HandleFunc("/api/refreshProfiles", withAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleAPIRefreshProfiles(w, r)
|
||||
})
|
||||
}))
|
||||
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user