Files
crawler/oauth_handlers.go
2026-01-30 16:05:59 -05:00

493 lines
15 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"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 != "" {
// Save handle to cookie for prefill on next visit
http.SetCookie(w, &http.Cookie{
Name: "last_handle",
Value: handle,
Path: "/",
MaxAge: 86400 * 365, // 1 year
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
m.startOAuthFlow(w, r, handle)
return
}
// Get last handle from cookie for prefill
lastHandle := ""
if cookie, err := r.Cookie("last_handle"); err == nil {
lastHandle = cookie.Value
}
// Serve login page
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := template.Must(template.New("login").Parse(loginPageHTML))
tmpl.Execute(w, map[string]string{"LastHandle": lastHandle})
}
// 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()
// Auto-append .bsky.social if handle has no dots
if !strings.Contains(handle, ".") {
handle = handle + ".bsky.social"
}
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())
http.Redirect(w, r, authURL.String(), http.StatusFound)
}
// 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, r, 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="username or full.handle" value="{{.LastHandle}}" 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>
`