polls on atproto pollz.waow.tech
atproto zig
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

use exchange token pattern for OAuth session cookies

Setting cookies on 302 redirects from cross-site OAuth flows is
unreliable. Instead, redirect to frontend with a short-lived exchange
token, then the frontend POSTs to /api/exchange which sets the cookie
on a normal same-origin response.

Follows the same pattern as plyr.fm's auth flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 745d2f9e 551886ab

+163 -18
+43
backend/src/db.zig
··· 120 120 return err; 121 121 }; 122 122 123 + conn.execNoArgs( 124 + \\CREATE TABLE IF NOT EXISTS exchange_tokens ( 125 + \\ token TEXT PRIMARY KEY, 126 + \\ did TEXT NOT NULL, 127 + \\ created_at INTEGER NOT NULL 128 + \\) 129 + ) catch |err| { 130 + std.debug.print("failed to create exchange_tokens table: {}\n", .{err}); 131 + return err; 132 + }; 133 + 123 134 std.debug.print("database schema initialized\n", .{}); 124 135 } 125 136 ··· 364 375 defer mutex.unlock(); 365 376 366 377 conn.exec("UPDATE oauth_session SET access_token = ?, refresh_token = ? WHERE did = ?", .{ access_token, refresh_token, did }) catch {}; 378 + } 379 + 380 + // --- Exchange tokens (short-lived, one-time use) --- 381 + 382 + pub fn insertExchangeToken(token: []const u8, did: []const u8) !void { 383 + mutex.lock(); 384 + defer mutex.unlock(); 385 + 386 + conn.exec( 387 + "INSERT INTO exchange_tokens (token, did, created_at) VALUES (?, ?, ?)", 388 + .{ token, did, @as(i64, @intCast(std.time.timestamp())) }, 389 + ) catch |err| { 390 + std.debug.print("db insert exchange token error: {}\n", .{err}); 391 + return err; 392 + }; 393 + } 394 + 395 + pub fn consumeExchangeToken(token: []const u8) ?[]const u8 { 396 + mutex.lock(); 397 + defer mutex.unlock(); 398 + 399 + const cutoff = @as(i64, @intCast(std.time.timestamp())) - 60; // 60 second expiry 400 + const row = conn.row( 401 + "SELECT did FROM exchange_tokens WHERE token = ? AND created_at > ?", 402 + .{ token, cutoff }, 403 + ) catch return null; 404 + if (row == null) return null; 405 + const did = row.?.text(0); 406 + 407 + conn.exec("DELETE FROM exchange_tokens WHERE token = ?", .{token}) catch {}; 408 + 409 + return did; 367 410 } 368 411 369 412 pub fn cleanupExpiredAuthRequests() void {
+75 -18
backend/src/http.zig
··· 127 127 try handleGetPoll(request, uri_encoded); 128 128 } 129 129 } 130 + } else if (mem.eql(u8, target, "/api/exchange") and request.head.method == .POST) { 131 + try handleExchange(request); 130 132 } else if (mem.eql(u8, target, "/api/logout") and request.head.method == .POST) { 131 133 try handleLogout(request); 132 134 } else { ··· 450 452 // clean up auth request 451 453 db.deleteAuthRequest(state); 452 454 453 - // redirect to frontend with session cookie 454 - var cookie_buf: [512]u8 = undefined; 455 - const cookie = std.fmt.bufPrint( 456 - &cookie_buf, 457 - "pollz_session={s}; HttpOnly; Secure; SameSite=Lax; Domain=pollz.waow.tech; Path=/; Max-Age=2592000", 458 - .{auth_req.did}, 459 - ) catch { 460 - try sendError(request, .internal_server_error, "cookie error"); 455 + // generate a short-lived exchange token and redirect to frontend 456 + // (setting cookies on 302 redirects from cross-site OAuth flows is unreliable) 457 + var token_bytes: [16]u8 = undefined; 458 + crypto.random.bytes(&token_bytes); 459 + const exchange_token = std.fmt.bytesToHex(token_bytes, .lower); 460 + 461 + db.insertExchangeToken(&exchange_token, auth_req.did) catch { 462 + try sendError(request, .internal_server_error, "could not store exchange token"); 461 463 return; 462 464 }; 463 465 464 - try request.respond("", .{ 465 - .status = .found, 466 - .extra_headers = &.{ 467 - .{ .name = "location", .value = getFrontendOrigin() }, 468 - .{ .name = "set-cookie", .value = cookie }, 469 - .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 470 - .{ .name = "access-control-allow-credentials", .value = "true" }, 471 - }, 472 - }); 466 + var redirect_url: std.ArrayList(u8) = .{}; 467 + defer redirect_url.deinit(alloc); 468 + try redirect_url.print(alloc, "{s}/auth/callback?exchange_token={s}", .{ getFrontendOrigin(), &exchange_token }); 469 + 470 + try sendRedirect(request, redirect_url.items); 473 471 } 474 472 475 473 fn handleMe(request: *http.Server.Request) !void { ··· 497 495 try sendJson(request, body.items); 498 496 } 499 497 498 + fn handleExchange(request: *http.Server.Request) !void { 499 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 500 + defer arena.deinit(); 501 + const alloc = arena.allocator(); 502 + 503 + const body = readRequestBody(alloc, request) orelse { 504 + try sendError(request, .bad_request, "missing body"); 505 + return; 506 + }; 507 + 508 + const parsed = json.parseFromSlice(json.Value, alloc, body, .{}) catch { 509 + try sendError(request, .bad_request, "invalid JSON"); 510 + return; 511 + }; 512 + defer parsed.deinit(); 513 + 514 + const token = jsonGetString(parsed.value, "exchange_token") orelse { 515 + try sendError(request, .bad_request, "missing exchange_token"); 516 + return; 517 + }; 518 + 519 + const did = db.consumeExchangeToken(token) orelse { 520 + try sendError(request, .unauthorized, "invalid or expired exchange token"); 521 + return; 522 + }; 523 + 524 + const session = db.getSession(did) orelse { 525 + try sendError(request, .unauthorized, "session not found"); 526 + return; 527 + }; 528 + 529 + var cookie_buf: [512]u8 = undefined; 530 + const cookie = std.fmt.bufPrint( 531 + &cookie_buf, 532 + "pollz_session={s}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000", 533 + .{did}, 534 + ) catch { 535 + try sendError(request, .internal_server_error, "cookie error"); 536 + return; 537 + }; 538 + 539 + var resp_body: std.ArrayList(u8) = .{}; 540 + defer resp_body.deinit(alloc); 541 + 542 + try resp_body.print(alloc, 543 + \\{{"did":"{s}","handle":"{s}"}} 544 + , .{ session.did, session.handle }); 545 + 546 + try request.respond(resp_body.items, .{ 547 + .status = .ok, 548 + .extra_headers = &.{ 549 + .{ .name = "content-type", .value = "application/json" }, 550 + .{ .name = "set-cookie", .value = cookie }, 551 + .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 552 + .{ .name = "access-control-allow-credentials", .value = "true" }, 553 + }, 554 + }); 555 + } 556 + 500 557 fn handleLogout(request: *http.Server.Request) !void { 501 558 const session_did = getSessionDid(request); 502 559 if (session_did) |did| { ··· 507 564 .status = .ok, 508 565 .extra_headers = &.{ 509 566 .{ .name = "content-type", .value = "application/json" }, 510 - .{ .name = "set-cookie", .value = "pollz_session=; HttpOnly; Secure; SameSite=Lax; Domain=pollz.waow.tech; Path=/; Max-Age=0" }, 567 + .{ .name = "set-cookie", .value = "pollz_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0" }, 511 568 .{ .name = "access-control-allow-origin", .value = getFrontendOrigin() }, 512 569 .{ .name = "access-control-allow-credentials", .value = "true" }, 513 570 },
+11
frontend/src/lib/api.ts
··· 104 104 await api('/api/logout', { method: 'POST' }); 105 105 } 106 106 107 + export async function exchangeToken(token: string): Promise<User | null> { 108 + const res = await fetch(`${API}/api/exchange`, { 109 + method: 'POST', 110 + credentials: 'include', 111 + headers: { 'Content-Type': 'application/json' }, 112 + body: JSON.stringify({ exchange_token: token }) 113 + }); 114 + if (!res.ok) return null; 115 + return res.json(); 116 + } 117 + 107 118 export function loginUrl(handle: string): string { 108 119 return `${API}/oauth/login?handle=${encodeURIComponent(handle)}`; 109 120 }
+34
frontend/src/routes/auth/callback/+page.svelte
··· 1 + <script lang="ts"> 2 + import { exchangeToken } from '$lib/api'; 3 + import { setUser } from '$lib/user.svelte'; 4 + import { goto } from '$app/navigation'; 5 + import { page } from '$app/state'; 6 + import { onMount } from 'svelte'; 7 + 8 + let error = $state(false); 9 + 10 + onMount(async () => { 11 + const token = page.url.searchParams.get('exchange_token'); 12 + if (!token) { 13 + error = true; 14 + return; 15 + } 16 + try { 17 + const result = await exchangeToken(token); 18 + if (result) { 19 + setUser(result); 20 + goto('/'); 21 + } else { 22 + error = true; 23 + } 24 + } catch { 25 + error = true; 26 + } 27 + }); 28 + </script> 29 + 30 + {#if error} 31 + <p>login failed</p> 32 + {:else} 33 + <p>logging in...</p> 34 + {/if}