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>
522 lines
16 KiB
Go
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>
|
|
`
|