Fix OAuth session storage - add missing database columns

- Add dpop_authserver_nonce, dpop_pds_nonce, pds_url, authserver_iss columns
- These columns are required by GetSession query but were missing from schema
- Add migrations to create columns on existing tables
- Add debug logging for OAuth flow troubleshooting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
primal
2026-02-02 00:44:19 -05:00
parent 86d669e08e
commit bce9369cb8
5 changed files with 56 additions and 5 deletions
+11 -1
View File
@@ -111,8 +111,11 @@ CREATE TABLE IF NOT EXISTS oauth_sessions (
token_type TEXT NOT NULL DEFAULT 'DPoP',
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
dpop_nonce TEXT,
dpop_private_jwk TEXT,
dpop_authserver_nonce TEXT,
dpop_pds_nonce TEXT,
pds_url TEXT,
authserver_iss TEXT,
token_expiry TIMESTAMP
);
@@ -227,6 +230,13 @@ func OpenDatabase(connString string) (*DB, error) {
pool.Exec(ctx, "ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS token_expiry TIMESTAMP")
// Make access_token nullable (session created before tokens obtained)
pool.Exec(ctx, "ALTER TABLE oauth_sessions ALTER COLUMN access_token DROP NOT NULL")
// Add missing OAuth session columns
pool.Exec(ctx, "ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS dpop_authserver_nonce TEXT")
pool.Exec(ctx, "ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS dpop_pds_nonce TEXT")
pool.Exec(ctx, "ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS pds_url TEXT")
pool.Exec(ctx, "ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS authserver_iss TEXT")
// Drop old dpop_nonce column if it exists
pool.Exec(ctx, "ALTER TABLE oauth_sessions DROP COLUMN IF EXISTS dpop_nonce")
// Migration: rename feed columns for consistent terminology
// last_crawled_at -> last_checked_at (feed_check = checking feeds for new items)
+30 -1
View File
@@ -194,6 +194,8 @@ func (m *OAuthManager) startOAuthFlow(w http.ResponseWriter, r *http.Request, ha
// 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()
@@ -204,6 +206,17 @@ func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
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)
@@ -218,9 +231,11 @@ func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
// 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 {
@@ -236,6 +251,7 @@ func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
}
// Exchange code for tokens
fmt.Printf("OAuth callback: exchanging code for tokens at %s\n", pending.AuthserverIss)
tokenResp, err := m.client.InitialTokenRequest(
ctx,
code,
@@ -245,36 +261,46 @@ func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
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: access denied for handle: %s\n", 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
@@ -287,12 +313,15 @@ func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
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)
}
+2
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
@@ -15,6 +16,7 @@ func (m *OAuthManager) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := m.GetSessionFromCookie(r)
if session == nil {
fmt.Printf("RequireAuth: no session found for %s\n", r.URL.Path)
// Check if this is an API call (wants JSON response)
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
+11 -1
View File
@@ -304,15 +304,25 @@ func (m *OAuthManager) SetSessionCookie(w http.ResponseWriter, r *http.Request,
func (m *OAuthManager) GetSessionFromCookie(r *http.Request) *OAuthSession {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
fmt.Printf("GetSessionFromCookie: no cookie found: %v\n", err)
return nil
}
fmt.Printf("GetSessionFromCookie: found cookie, length=%d\n", len(cookie.Value))
sessionID, err := decryptSessionID(cookie.Value, m.cookieSecret)
if err != nil {
fmt.Printf("GetSessionFromCookie: decrypt failed: %v\n", err)
return nil
}
fmt.Printf("GetSessionFromCookie: decrypted session ID: %s\n", sessionID)
return m.sessions.GetSession(sessionID)
session := m.sessions.GetSession(sessionID)
if session == nil {
fmt.Printf("GetSessionFromCookie: session not found in store\n")
} else {
fmt.Printf("GetSessionFromCookie: found session for %s\n", session.Handle)
}
return session
}
// ClearSessionCookie removes the session cookie
+2 -2
View File
@@ -445,8 +445,8 @@ const dashboardHTML = `<!DOCTYPE html>
<title>1440.news Feed Crawler</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/dashboard.css?v=1770006945">
<script src="/static/dashboard.js?v=1770006945"></script>
<link rel="stylesheet" href="/static/dashboard.css?v=1770011013">
<script src="/static/dashboard.js?v=1770011013"></script>
</head>
<body>
<div id="topSection">