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 = ` Sign In - 1440.news Dashboard

Dashboard Authentication

Sign In with Bluesky

`