Backend implementations
Each example implementsGET /bitrobot/connect and POST /bitrobot/connect/confirm, stores request_id and nonce in the server-side session, and reads BITROBOT_API_KEY, BITROBOT_SUBNET_ID, and BITROBOT_API_BASE from the environment. Replace the login guard (requireLogin, current_user, and so on) with your own. If your API is stateless, use the stateless hold instead of the session.
// routes/bitrobot-connect.js
const express = require("express");
const crypto = require("node:crypto");
const { requireLogin } = require("../middleware/auth"); // your existing login guard
const router = express.Router();
const BITROBOT_API_BASE = process.env.BITROBOT_API_BASE || "https://api.bitrobot.ai";
const BITROBOT_API_KEY = process.env.BITROBOT_API_KEY;
const BITROBOT_SUBNET_ID = process.env.BITROBOT_SUBNET_ID;
// Landing page. Needs a server-side session (for example express-session) mounted before this router.
router.get("/bitrobot/connect", requireLogin, (req, res) => {
const { request_id: requestId, nonce } = req.query;
if (typeof requestId !== "string" || typeof nonce !== "string") {
return res.status(400).send("Missing request_id or nonce");
}
req.session.bitrobotConnect = { requestId, nonce };
res.render("bitrobot-connect", { walletAddress: req.user.walletAddress });
});
// Confirm handler. The Connect button on the landing page posts here.
router.post("/bitrobot/connect/confirm", requireLogin, async (req, res) => {
const pending = req.session.bitrobotConnect;
if (!pending) {
return res.status(400).send("No connection in progress");
}
const response = await fetch(
`${BITROBOT_API_BASE}/subnets/${BITROBOT_SUBNET_ID}/connect/confirm`,
{
method: "POST",
headers: {
Authorization: `Bearer ${BITROBOT_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
request_id: pending.requestId,
nonce: pending.nonce,
wallet_address: req.user.walletAddress,
subnet_user_id: String(req.user.id),
}),
}
);
const body = await response.json();
// Branch on auth_code, not the status. Never log or render auth_code.
const returnUrl = safeReturnUrl(body.return_url);
if (!body.auth_code || !returnUrl) {
return res
.status(400)
.render("bitrobot-connect-error", { error: body.error, reason: body.reason });
}
delete req.session.bitrobotConnect;
// Redirect to return_url verbatim, fragment included.
res.redirect(303, returnUrl);
});
// Allow http(s) URLs only. Return the string as given.
function safeReturnUrl(raw) {
if (!raw) return null;
try {
const url = new URL(raw);
return url.protocol === "https:" || url.protocol === "http:" ? raw : null;
} catch {
return null;
}
}
module.exports = router;
# app/bitrobot_connect.py
import os
import uuid
from urllib.parse import urlsplit
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.auth import current_user # your existing login dependency
# Needs SessionMiddleware on the app:
# app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"])
router = APIRouter()
templates = Jinja2Templates(directory="templates")
BITROBOT_API_BASE = os.environ.get("BITROBOT_API_BASE", "https://api.bitrobot.ai")
BITROBOT_API_KEY = os.environ["BITROBOT_API_KEY"]
BITROBOT_SUBNET_ID = os.environ["BITROBOT_SUBNET_ID"]
# Landing page.
@router.get("/bitrobot/connect", response_class=HTMLResponse)
async def landing(request: Request, request_id: str, nonce: str, user=Depends(current_user)):
request.session["bitrobot_connect"] = {"request_id": request_id, "nonce": nonce}
return templates.TemplateResponse(
"bitrobot_connect.html",
{"request": request, "wallet_address": user.wallet_address},
)
# Confirm handler. The Connect button on the landing page posts here.
@router.post("/bitrobot/connect/confirm")
async def confirm(request: Request, user=Depends(current_user)):
pending = request.session.get("bitrobot_connect")
if not pending:
raise HTTPException(status_code=400, detail="No connection in progress")
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
f"{BITROBOT_API_BASE}/subnets/{BITROBOT_SUBNET_ID}/connect/confirm",
headers={
"Authorization": f"Bearer {BITROBOT_API_KEY}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"request_id": pending["request_id"],
"nonce": pending["nonce"],
"wallet_address": user.wallet_address,
"subnet_user_id": str(user.id),
},
)
body = response.json()
# Branch on auth_code, not the status. Never log or render auth_code.
return_url = safe_return_url(body.get("return_url"))
if not body.get("auth_code") or return_url is None:
return templates.TemplateResponse(
"bitrobot_connect_error.html",
{"request": request, "error": body.get("error"), "reason": body.get("reason")},
status_code=400,
)
request.session.pop("bitrobot_connect", None)
# Redirect to return_url verbatim, fragment included.
return RedirectResponse(return_url, status_code=303)
# Allow http(s) URLs only. Return the string as given.
def safe_return_url(raw):
if not raw:
return None
scheme = urlsplit(raw).scheme.lower()
return raw if scheme in ("http", "https") else None
# bitrobot_connect.rb
require "sinatra"
require "net/http"
require "json"
require "securerandom"
enable :sessions
BITROBOT_API_BASE = ENV.fetch("BITROBOT_API_BASE", "https://api.bitrobot.ai")
BITROBOT_API_KEY = ENV.fetch("BITROBOT_API_KEY")
BITROBOT_SUBNET_ID = ENV.fetch("BITROBOT_SUBNET_ID")
# Landing page.
get "/bitrobot/connect" do
require_login! # your existing login guard
if params[:request_id].to_s.empty? || params[:nonce].to_s.empty?
halt 400, "Missing request_id or nonce"
end
session[:bitrobot_connect] = { "request_id" => params[:request_id], "nonce" => params[:nonce] }
erb :bitrobot_connect, locals: { wallet_address: current_user.wallet_address }
end
# Confirm handler. The Connect button on the landing page posts here.
post "/bitrobot/connect/confirm" do
require_login!
pending = session[:bitrobot_connect]
halt 400, "No connection in progress" unless pending
uri = URI("#{BITROBOT_API_BASE}/subnets/#{BITROBOT_SUBNET_ID}/connect/confirm")
http_request = Net::HTTP::Post.new(uri)
http_request["Authorization"] = "Bearer #{BITROBOT_API_KEY}"
http_request["Content-Type"] = "application/json"
http_request["Idempotency-Key"] = SecureRandom.uuid
http_request.body = JSON.generate(
request_id: pending["request_id"],
nonce: pending["nonce"],
wallet_address: current_user.wallet_address,
subnet_user_id: current_user.id.to_s
)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(http_request) }
body = JSON.parse(response.body)
# Branch on auth_code, not the status. Never log or render auth_code.
return_url = safe_return_url(body["return_url"])
if body["auth_code"].to_s.empty? || return_url.nil?
halt 400, erb(:bitrobot_connect_error, locals: { error: body["error"], reason: body["reason"] })
end
session.delete(:bitrobot_connect)
# Redirect to return_url verbatim, fragment included.
redirect return_url, 303
end
# Allow http(s) URLs only. Return the string as given.
def safe_return_url(raw)
return nil if raw.to_s.empty?
uri = URI.parse(raw)
uri.is_a?(URI::HTTP) ? raw : nil
rescue URI::InvalidURIError
nil
end
// bitrobot_connect.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"github.com/google/uuid"
"github.com/gorilla/sessions" // any server-side session store works
)
var (
bitrobotAPIBase = envOr("BITROBOT_API_BASE", "https://api.bitrobot.ai")
bitrobotAPIKey = os.Getenv("BITROBOT_API_KEY")
bitrobotSubnetID = os.Getenv("BITROBOT_SUBNET_ID")
sessionStore = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
templates = template.Must(template.ParseGlob("templates/*.html"))
)
type confirmResponse struct {
Status string `json:"status"`
ReturnURL string `json:"return_url"`
AuthCode string `json:"auth_code"` // Check its presence only. Never log or render it.
Error string `json:"error"`
Reason string `json:"reason"`
}
// Landing page.
func connectLanding(w http.ResponseWriter, r *http.Request) {
user, ok := currentUser(r) // your existing login guard
if !ok {
redirectToLogin(w, r)
return
}
requestID, nonce := r.URL.Query().Get("request_id"), r.URL.Query().Get("nonce")
if requestID == "" || nonce == "" {
http.Error(w, "Missing request_id or nonce", http.StatusBadRequest)
return
}
session, _ := sessionStore.Get(r, "app")
session.Values["bitrobot_request_id"] = requestID
session.Values["bitrobot_nonce"] = nonce
if err := session.Save(r, w); err != nil {
http.Error(w, "Could not save session", http.StatusInternalServerError)
return
}
templates.ExecuteTemplate(w, "bitrobot_connect.html", map[string]string{
"WalletAddress": user.WalletAddress,
})
}
// Confirm handler. The Connect button on the landing page posts here.
func connectConfirm(w http.ResponseWriter, r *http.Request) {
user, ok := currentUser(r)
if !ok {
redirectToLogin(w, r)
return
}
session, _ := sessionStore.Get(r, "app")
requestID, _ := session.Values["bitrobot_request_id"].(string)
nonce, _ := session.Values["bitrobot_nonce"].(string)
if requestID == "" || nonce == "" {
http.Error(w, "No connection in progress", http.StatusBadRequest)
return
}
payload, _ := json.Marshal(map[string]string{
"request_id": requestID,
"nonce": nonce,
"wallet_address": user.WalletAddress,
"subnet_user_id": user.ID,
})
req, _ := http.NewRequest(
http.MethodPost,
fmt.Sprintf("%s/subnets/%s/connect/confirm", bitrobotAPIBase, bitrobotSubnetID),
bytes.NewReader(payload),
)
req.Header.Set("Authorization", "Bearer "+bitrobotAPIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", uuid.NewString())
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "BitRobot is unreachable, please try again", http.StatusBadGateway)
return
}
defer resp.Body.Close()
var body confirmResponse
_ = json.NewDecoder(resp.Body).Decode(&body)
// Branch on auth_code, not the status.
returnURL := safeReturnURL(body.ReturnURL)
if body.AuthCode == "" || returnURL == "" {
w.WriteHeader(http.StatusBadRequest)
templates.ExecuteTemplate(w, "bitrobot_connect_error.html", map[string]string{
"Error": body.Error, "Reason": body.Reason,
})
return
}
delete(session.Values, "bitrobot_request_id")
delete(session.Values, "bitrobot_nonce")
_ = session.Save(r, w)
// Redirect to return_url verbatim, fragment included.
http.Redirect(w, r, returnURL, http.StatusSeeOther)
}
// Allow http(s) URLs only. Return the string as given.
func safeReturnURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
return raw
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func main() {
http.HandleFunc("GET /bitrobot/connect", connectLanding)
http.HandleFunc("POST /bitrobot/connect/confirm", connectConfirm)
http.ListenAndServe(":8080", nil)
}
<?php
// app/Http/Controllers/BitRobotConnectController.php
//
// routes/web.php:
// Route::get('/bitrobot/connect', [BitRobotConnectController::class, 'landing'])->middleware('auth');
// Route::post('/bitrobot/connect/confirm', [BitRobotConnectController::class, 'confirm'])->middleware('auth');
//
// config/services.php:
// 'bitrobot' => [
// 'api_base' => env('BITROBOT_API_BASE', 'https://api.bitrobot.ai'),
// 'api_key' => env('BITROBOT_API_KEY'),
// 'subnet_id' => env('BITROBOT_SUBNET_ID'),
// ],
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class BitRobotConnectController extends Controller
{
// Landing page.
public function landing(Request $request)
{
$validated = $request->validate([
'request_id' => 'required|string',
'nonce' => 'required|string',
]);
$request->session()->put('bitrobot_connect', $validated);
return view('bitrobot.connect', [
'walletAddress' => $request->user()->wallet_address,
]);
}
// Confirm handler. The Connect button on the landing page posts here.
public function confirm(Request $request)
{
$pending = $request->session()->get('bitrobot_connect');
abort_unless($pending, 400, 'No connection in progress');
$baseUrl = config('services.bitrobot.api_base');
$subnetId = config('services.bitrobot.subnet_id');
$response = Http::withToken(config('services.bitrobot.api_key'))
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
->post("{$baseUrl}/subnets/{$subnetId}/connect/confirm", [
'request_id' => $pending['request_id'],
'nonce' => $pending['nonce'],
'wallet_address' => $request->user()->wallet_address,
'subnet_user_id' => (string) $request->user()->id,
]);
// Branch on auth_code, not the status. Never log or render auth_code.
$returnUrl = $this->safeReturnUrl($response->json('return_url'));
if (!$response->json('auth_code') || $returnUrl === null) {
return response()->view('bitrobot.connect-error', [
'error' => $response->json('error'),
'reason' => $response->json('reason'),
], 400);
}
$request->session()->forget('bitrobot_connect');
// Redirect to return_url verbatim, fragment included.
return redirect()->away($returnUrl, 303);
}
// Allow http(s) URLs only. Return the string as given.
private function safeReturnUrl(?string $raw): ?string
{
if (!$raw) {
return null;
}
$scheme = strtolower((string) parse_url($raw, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) ? $raw : null;
}
}
// src/main/java/com/example/bitrobot/BitRobotConnectController.java
package com.example.bitrobot;
import java.net.URI;
import java.util.Map;
import java.util.UUID;
import jakarta.servlet.http.HttpSession;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
// Both routes sit behind your existing Spring Security login requirement.
// AppUser is your own principal type; it must expose the user's subnet wallet address.
@Controller
public class BitRobotConnectController {
private static final ParameterizedTypeReference<Map<String, Object>> JSON_OBJECT =
new ParameterizedTypeReference<>() {};
private static final String BITROBOT_API_BASE =
System.getenv().getOrDefault("BITROBOT_API_BASE", "https://api.bitrobot.ai");
private static final String BITROBOT_API_KEY = System.getenv("BITROBOT_API_KEY");
private static final String BITROBOT_SUBNET_ID = System.getenv("BITROBOT_SUBNET_ID");
private final RestClient bitrobot = RestClient.builder()
.baseUrl(BITROBOT_API_BASE)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + BITROBOT_API_KEY)
.build();
// Landing page.
@GetMapping("/bitrobot/connect")
public String landing(@RequestParam("request_id") String requestId,
@RequestParam("nonce") String nonce,
@AuthenticationPrincipal AppUser user,
HttpSession session,
Model model) {
session.setAttribute("bitrobotRequestId", requestId);
session.setAttribute("bitrobotNonce", nonce);
model.addAttribute("walletAddress", user.walletAddress());
return "bitrobot-connect";
}
// Confirm handler. The Connect button on the landing page posts here.
@PostMapping("/bitrobot/connect/confirm")
public Object confirm(@AuthenticationPrincipal AppUser user, HttpSession session, Model model) {
String requestId = (String) session.getAttribute("bitrobotRequestId");
String nonce = (String) session.getAttribute("bitrobotNonce");
if (requestId == null || nonce == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No connection in progress");
}
Map<String, Object> body;
try {
body = bitrobot.post()
.uri("/subnets/{subnetId}/connect/confirm", BITROBOT_SUBNET_ID)
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", UUID.randomUUID().toString())
.body(Map.of(
"request_id", requestId,
"nonce", nonce,
"wallet_address", user.walletAddress(),
"subnet_user_id", String.valueOf(user.id())))
.retrieve()
.body(JSON_OBJECT);
} catch (HttpStatusCodeException e) {
// Read the body of a 4xx response too.
body = e.getResponseBodyAs(JSON_OBJECT);
}
// Branch on auth_code, not the status. Never log or render auth_code.
Object authCode = body == null ? null : body.get("auth_code");
String returnUrl = safeReturnUrl(body == null ? null : body.get("return_url"));
if (authCode == null || returnUrl == null) {
model.addAttribute("error", body == null ? null : body.get("error"));
model.addAttribute("reason", body == null ? null : body.get("reason"));
return "bitrobot-connect-error";
}
session.removeAttribute("bitrobotRequestId");
session.removeAttribute("bitrobotNonce");
// Redirect to return_url verbatim, fragment included, with a 303.
return ResponseEntity.status(HttpStatus.SEE_OTHER).header(HttpHeaders.LOCATION, returnUrl).build();
}
// Allow http(s) URLs only. Return the string as given.
private static String safeReturnUrl(Object raw) {
if (!(raw instanceof String value) || value.isBlank()) {
return null;
}
try {
String scheme = URI.create(value).getScheme();
return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme) ? value : null;
} catch (IllegalArgumentException e) {
return null;
}
}
}
// Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
builder.Services.AddHttpClient("bitrobot", client =>
{
client.BaseAddress = new Uri(
Environment.GetEnvironmentVariable("BITROBOT_API_BASE") ?? "https://api.bitrobot.ai");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BITROBOT_API_KEY"));
});
// Plus your existing authentication/authorization registration.
var app = builder.Build();
app.UseSession();
var subnetId = Environment.GetEnvironmentVariable("BITROBOT_SUBNET_ID");
// Landing page.
app.MapGet("/bitrobot/connect", (HttpContext http, string request_id, string nonce) =>
{
var user = http.CurrentUser(); // your existing helper for the signed-in user
http.Session.SetString("bitrobot_request_id", request_id);
http.Session.SetString("bitrobot_nonce", nonce);
return Results.Content(ConfirmPage(user.WalletAddress), "text/html");
}).RequireAuthorization();
// Confirm handler. The Connect button on the landing page posts here.
app.MapPost("/bitrobot/connect/confirm", async (HttpContext http, IHttpClientFactory clients) =>
{
var user = http.CurrentUser();
var requestId = http.Session.GetString("bitrobot_request_id");
var nonce = http.Session.GetString("bitrobot_nonce");
if (requestId is null || nonce is null)
{
return Results.BadRequest("No connection in progress");
}
var client = clients.CreateClient("bitrobot");
using var message = new HttpRequestMessage(HttpMethod.Post, $"/subnets/{subnetId}/connect/confirm")
{
Content = JsonContent.Create(new
{
request_id = requestId,
nonce,
wallet_address = user.WalletAddress,
subnet_user_id = user.Id.ToString(),
}),
};
message.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
var response = await client.SendAsync(message);
var body = await response.Content.ReadFromJsonAsync<ConfirmResponse>();
// Branch on auth_code, not the status. Never log or render auth_code.
var returnUrl = SafeReturnUrl(body?.ReturnUrl);
if (body?.AuthCode is null || returnUrl is null)
{
return Results.Content(ErrorPage(body?.Error, body?.Reason), "text/html", statusCode: 400);
}
http.Session.Remove("bitrobot_request_id");
http.Session.Remove("bitrobot_nonce");
// Redirect to return_url verbatim, fragment included, with a 303.
http.Response.Headers.Location = returnUrl;
return Results.StatusCode(StatusCodes.Status303SeeOther);
}).RequireAuthorization();
app.Run();
// Allow http(s) URLs only. Return the string as given.
static string? SafeReturnUrl(string? raw) =>
Uri.TryCreate(raw, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? raw
: null;
// Render the connect page and the error page from the HTML templates below.
static string ConfirmPage(string walletAddress) =>
File.ReadAllText("Views/bitrobot-connect.html").Replace("{{WALLET_ADDRESS}}", walletAddress);
static string ErrorPage(string? error, string? reason) =>
File.ReadAllText("Views/bitrobot-connect-error.html")
.Replace("{{ERROR}}", error ?? "Something went wrong")
.Replace("{{REASON}}", reason ?? "unknown");
record ConfirmResponse(
[property: JsonPropertyName("status")] string? Status,
[property: JsonPropertyName("return_url")] string? ReturnUrl,
[property: JsonPropertyName("auth_code")] string? AuthCode,
[property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("reason")] string? Reason);
Stateless hold
If your API is stateless, storerequest_id and nonce in a table instead of the session. Keep one row per user, replace it on each new start, and check expiry on read.
CREATE TABLE bitrobot_connect_attempts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id),
request_id TEXT NOT NULL,
nonce TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- One row per user.
CREATE UNIQUE INDEX index_bitrobot_connect_attempts_on_user_id
ON bitrobot_connect_attempts (user_id);
# app/models/bitrobot_connect_attempt.rb
class BitrobotConnectAttempt < ApplicationRecord
TTL = 15.minutes
belongs_to :user
validates :request_id, presence: true
validates :nonce, presence: true
scope :active, -> { where(expires_at: Time.current..) }
# Replaces whatever the user had in flight.
def self.start!(user:, request_id:, nonce:)
attempt = find_or_initialize_by(user_id: user.id)
attempt.update!(request_id: request_id, nonce: nonce, expires_at: TTL.from_now)
attempt
end
# The live hold for this user, or nil once it has lapsed.
def self.active_for(user)
active.find_by(user_id: user.id)
end
end
# In your controller. safe_return_url is the helper from the Ruby (Sinatra) example above.
def confirm
attempt = BitrobotConnectAttempt.active_for(current_user)
return render_error(:not_found, "no_attempt") if attempt.nil?
body = Bitrobot::Client.new.confirm_wallet_connection(
request_id: attempt.request_id,
nonce: attempt.nonce,
wallet_address: current_user.wallet_address,
subnet_user_id: current_user.id.to_s
)
# Branch on auth_code, not the status. Never log or render auth_code.
return_url = safe_return_url(body["return_url"])
if body["auth_code"].to_s.empty? || return_url.nil?
return render_error(:bad_request, body["reason"])
end
attempt.destroy
# Redirect to return_url verbatim, fragment included.
redirect_to return_url, allow_other_host: true, status: :see_other
end
Pages you render
Two pages: the connect page the user lands on, and an error page.Connect page
Show the wallet address in full and one button that POSTs to your confirm handler. Include your framework’s CSRF token in the form.<!-- templates/bitrobot-connect.html: substitute the wallet address with your template engine -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Connect to BitRobot</title>
</head>
<body>
<main>
<h1>Connect your wallet to BitRobot</h1>
<p>
BitRobot will link the wallet you use here to your BitRobot account, so the
Subnet Points and Bolts we grant you reach it.
</p>
<p>Wallet: <code style="word-break: break-all">{{WALLET_ADDRESS}}</code></p>
<form method="post" action="/bitrobot/connect/confirm">
<!-- Include your framework's CSRF token here -->
<button type="submit">Connect</button>
</form>
</main>
</body>
</html>
// BitRobotConnectPage.tsx: for an SPA. Your confirm handler returns JSON: return_url on success,
// error on failure, and never auth_code.
import { useState } from "react";
type Props = {
walletAddress: string;
csrfToken?: string;
};
// Allow http(s) URLs only. Return the string as given.
function safeReturnUrl(raw: unknown): string | null {
if (typeof raw !== "string" || !raw) return null;
try {
const url = new URL(raw);
return url.protocol === "https:" || url.protocol === "http:" ? raw : null;
} catch {
return null;
}
}
export function BitRobotConnectPage({ walletAddress, csrfToken }: Props) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function connect(event: React.FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
const response = await fetch("/bitrobot/connect/confirm", {
method: "POST",
headers: csrfToken ? { "X-CSRF-Token": csrfToken } : {},
});
const body = await response.json();
const href = safeReturnUrl(body.return_url);
if (!href) {
setSubmitting(false);
setError(
typeof body?.error === "string"
? body.error
: "Could not connect to BitRobot. Please try again."
);
return;
}
window.location.replace(href);
}
return (
<main>
<h1>Connect your wallet to BitRobot</h1>
<p>
BitRobot will link the wallet you use here to your BitRobot account, so the
Subnet Points and Bolts we grant you reach it.
</p>
<p>
Wallet: <code style={{ wordBreak: "break-all" }}>{walletAddress}</code>
</p>
{error ? <p role="alert">{error}</p> : null}
<form onSubmit={connect}>
<button type="submit" disabled={submitting}>
{submitting ? "Connecting…" : "Connect"}
</button>
</form>
</main>
);
}
Error page
Plain HTML
<!-- templates/bitrobot-connect-error.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Couldn't connect to BitRobot</title>
</head>
<body>
<main>
<h1>Couldn't connect to BitRobot</h1>
<p>{{ERROR}}</p>
<p><small>Reason: <code>{{REASON}}</code></small></p>
<p><a href="https://app.bitrobot.ai/profile">Go back to BitRobot</a> and press Connect again.</p>
</main>
</body>
</html>
Returning from login
Store the destination, query string included, before redirecting to login, and return the user to it after sign-in.// The guard: remember the destination, including its query string.
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { ready, authenticated } = useAuth();
const location = useLocation();
if (!ready) return <Loading />;
if (!authenticated) {
return (
<Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
);
}
return children;
}
// The login page: accept an in-app path only.
// One leading slash, not followed by another slash or a backslash.
const isInAppPath = (v: unknown): v is string =>
typeof v === "string" && /^\/(?![/\\])/.test(v);
const from = (location.state as { from?: unknown } | null)?.from;
const destination = isInAppPath(from) ? from : "/dashboard";
// After a successful sign-in:
navigate(destination, { replace: true });
request.fullpath before the login redirect and redirect to it afterwards. Accept an in-app path only:
RETURN_PATH_FORMAT = %r{\A/(?!/|\\|%2[fF]|%5[cC])[A-Za-z0-9._~!$&'()*+,;=:@%/?-]*\z}
Tests
RSpec examples against a confirm handler that returns JSON.park_attempt stores a hold for user; confirm_url, success_body, callback_url, and auth_code are fixtures. Translate the assertions to your framework.
1. The handler ignores a request_id and nonce supplied by the caller.
it "ignores a request_id and nonce supplied by the caller" do
park_attempt # holds request_id + nonce for `user`
stub = stub_request(:post, confirm_url)
.with(body: hash_including("request_id" => request_id, "nonce" => nonce))
.to_return(status: 200, body: success_body)
post "/api/v1/bitrobot_connect/confirm",
params: { request_id: "01JZ8ZP9K3QF7XN2M4RB6TVWCE", nonce: "attacker-nonce" },
headers: auth_headers_for(user)
expect(stub).to have_been_requested # the held ids, not the posted ones
expect(response).to have_http_status(:ok)
end
it "never reads another user's hold" do
park_attempt(for_user: create(:user))
post "/api/v1/bitrobot_connect/confirm", headers: auth_headers_for(user)
expect(response).to have_http_status(:not_found)
expect(response.parsed_body.dig("error", "code")).to eq("no_attempt")
expect(a_request(:post, confirm_url)).not_to have_been_made
end
it "treats a lapsed hold as no hold at all" do
park_attempt(expires_at: 1.minute.ago)
post "/api/v1/bitrobot_connect/confirm", headers: auth_headers_for(user)
expect(response).to have_http_status(:not_found)
expect(a_request(:post, confirm_url)).not_to have_been_made
end
return_url passes through byte for byte, fragment included.
it "passes BitRobot's return_url through with its fragment byte-for-byte" do
park_attempt
stub_request(:post, confirm_url).to_return(status: 200, body: success_body)
post "/api/v1/bitrobot_connect/confirm", headers: auth_headers_for(user)
expect(response.parsed_body["return_url"]).to eq(callback_url)
expect(URI.parse(response.parsed_body["return_url"]).fragment).to eq("ac=#{auth_code}")
end
return_url, and never in logs.
it "never renders the auth code as a field of its own, nor logs it" do
park_attempt
stub_request(:post, confirm_url).to_return(status: 200, body: success_body)
logged = []
allow(Rails.logger).to receive(:warn) { |msg| logged << msg.to_s }
allow(Rails.logger).to receive(:error) { |msg| logged << msg.to_s }
post "/api/v1/bitrobot_connect/confirm", headers: auth_headers_for(user)
expect(response.parsed_body).not_to have_key("auth_code")
expect(response.body.scan(auth_code).length).to eq(1) # only inside return_url
expect(logged.join("\n")).not_to include(auth_code)
end
- A
409 already_confirmedwithauth_codeis treated as success. - A
200withoutauth_codeis treated as a failure. - A non-
http(s)return_urlis refused. - The hold is cleared after a successful confirm and kept after a failed one.