Files
watcher/oauth_handlers.go
primal 53919fa31e Add OAuth files - authentication system
Migrated from app/:
- oauth.go - OAuthManager, config loading, handle/DID resolution
- oauth_session.go - SessionStore, encrypted cookies, token storage
- oauth_middleware.go - RequireAuth middleware, token refresh
- oauth_handlers.go - Login, callback, logout, JWKS endpoints

Changed *DB to *shared.DB, using shared.StringValue/NullableString helpers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:48:23 -05:00

522 lines
16 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) {
fmt.Printf("OAuth callback: received request from %s\n", r.URL.String())
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")
codePreview := code
if len(codePreview) > 10 {
codePreview = codePreview[:10]
}
statePreview := state
if len(statePreview) > 10 {
statePreview = statePreview[:10]
}
fmt.Printf("OAuth callback: code=%s..., state=%s..., iss=%s, error=%s\n",
codePreview, statePreview, iss, errorParam)
// 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 {
fmt.Printf("OAuth callback: no pending state found for %s\n", state)
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
return
}
fmt.Printf("OAuth callback: found pending state for DID %s\n", pending.DID)
// 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
fmt.Printf("OAuth callback: exchanging code for tokens at %s\n", pending.AuthserverIss)
tokenResp, err := m.client.InitialTokenRequest(
ctx,
code,
pending.AuthserverIss,
pending.PkceVerifier,
pending.DpopNonce,
dpopKey,
)
if err != nil {
fmt.Printf("OAuth callback: token exchange failed: %v\n", err)
http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("OAuth callback: token exchange success, sub=%s, scope=%s\n", tokenResp.Sub, tokenResp.Scope)
// Verify scope
if tokenResp.Scope != m.allowedScope {
fmt.Printf("OAuth callback: scope mismatch: expected %s, got %s\n", m.allowedScope, tokenResp.Scope)
http.Error(w, fmt.Sprintf("Invalid scope: expected %s, got %s", m.allowedScope, tokenResp.Scope), http.StatusForbidden)
return
}
// Resolve DID to handle
fmt.Printf("OAuth callback: resolving DID %s to handle\n", tokenResp.Sub)
handle, err := resolveDIDToHandle(ctx, tokenResp.Sub)
if err != nil {
fmt.Printf("OAuth callback: failed to resolve handle: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("OAuth callback: resolved handle: %s\n", handle)
// CRITICAL: Verify user is allowed
if !allowedHandles[handle] {
fmt.Printf("OAuth callback: access denied for handle: %s (allowed: %v)\n", handle, allowedHandles)
http.Error(w, "Access denied.", http.StatusForbidden)
return
}
fmt.Printf("OAuth callback: handle %s is allowed\n", handle)
// Create session
fmt.Printf("OAuth callback: creating session for %s\n", handle)
session, err := m.sessions.CreateSession(tokenResp.Sub, handle)
if err != nil {
fmt.Printf("OAuth callback: failed to create session: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to create session: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("OAuth callback: session created with ID %s\n", session.ID)
// 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
fmt.Printf("OAuth callback: setting session cookie\n")
if err := m.SetSessionCookie(w, r, session.ID); err != nil {
fmt.Printf("OAuth callback: failed to set cookie: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to set cookie: %v", err), http.StatusInternalServerError)
return
}
// Redirect to dashboard
fmt.Printf("OAuth callback: success! redirecting to /dashboard\n")
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 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>
`