Files
crawler/oauth_middleware.go
primal 8192bce301 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>
2026-01-30 15:16:51 -05:00

125 lines
3.2 KiB
Go

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
}