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

Configure Feed

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

refresh expired access tokens instead of deleting sessions

AT Protocol OAuth access tokens expire after ~5 minutes. When the PDS
returns 401, refresh the token using the refresh token and retry the
request. Only delete the session if refresh also fails.

Also keeps the exchange token pattern from the previous commit for
more reliable cookie setting.

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

zzstoatzz 7fa0354d 745d2f9e

+159 -62
+159 -62
backend/src/http.zig
··· 1454 1454 fn pdsAuthedRequest(alloc: std.mem.Allocator, session: db.Session, method_str: []const u8, path: []const u8, body: ?[]const u8) ![]u8 { 1455 1455 const dpop_keypair = keypairFromHex(session.dpop_private_key) catch return error.InvalidSessionKey; 1456 1456 1457 - const url = try std.fmt.allocPrint(alloc, "{s}{s}", .{ session.pds_url, path }); 1458 - defer alloc.free(url); 1457 + var access_token = session.access_token; 1458 + var refreshed = false; 1459 1459 1460 - const ath = try oauth.accessTokenHash(alloc, session.access_token); 1461 - defer alloc.free(ath); 1460 + // outer loop: try request, refresh token on 401, retry once 1461 + for (0..2) |attempt| { 1462 + const url = try std.fmt.allocPrint(alloc, "{s}{s}", .{ session.pds_url, path }); 1463 + defer alloc.free(url); 1462 1464 1463 - var nonce: ?[]const u8 = if (session.dpop_pds_nonce.len > 0) session.dpop_pds_nonce else null; 1465 + const ath = try oauth.accessTokenHash(alloc, access_token); 1466 + defer alloc.free(ath); 1464 1467 1465 - // try once, retry once on DPoP nonce error (matching PAR/token pattern) 1466 - for (0..2) |_| { 1467 - const dpop_proof = try oauth.createDpopProof(alloc, &dpop_keypair, method_str, url, nonce, ath); 1468 - defer alloc.free(dpop_proof); 1468 + var nonce: ?[]const u8 = if (session.dpop_pds_nonce.len > 0) session.dpop_pds_nonce else null; 1469 + 1470 + // inner loop: retry once on DPoP nonce error 1471 + const nonce_result = for (0..2) |_| { 1472 + const dpop_proof = try oauth.createDpopProof(alloc, &dpop_keypair, method_str, url, nonce, ath); 1473 + defer alloc.free(dpop_proof); 1474 + 1475 + var auth_header_buf: [4096]u8 = undefined; 1476 + const auth_header = std.fmt.bufPrint(&auth_header_buf, "DPoP {s}", .{access_token}) catch return error.AuthHeaderTooLong; 1477 + 1478 + const http_method: http.Method = if (mem.eql(u8, method_str, "POST")) .POST else .GET; 1469 1479 1470 - var auth_header_buf: [4096]u8 = undefined; 1471 - const auth_header = std.fmt.bufPrint(&auth_header_buf, "DPoP {s}", .{session.access_token}) catch return error.AuthHeaderTooLong; 1480 + var client: std.http.Client = .{ .allocator = alloc }; 1481 + defer client.deinit(); 1482 + 1483 + var req = try client.request(http_method, try std.Uri.parse(url), .{ 1484 + .extra_headers = &.{ 1485 + .{ .name = "Authorization", .value = auth_header }, 1486 + .{ .name = "DPoP", .value = dpop_proof }, 1487 + }, 1488 + .headers = .{ 1489 + .content_type = .{ .override = "application/json" }, 1490 + }, 1491 + }); 1492 + defer req.deinit(); 1493 + 1494 + if (body) |b| { 1495 + req.transfer_encoding = .{ .content_length = b.len }; 1496 + var body_writer = try req.sendBodyUnflushed(&.{}); 1497 + try body_writer.writer.writeAll(b); 1498 + try body_writer.end(); 1499 + try req.connection.?.flush(); 1500 + } else { 1501 + try req.sendBodiless(); 1502 + } 1503 + 1504 + var redirect_buf: [1]u8 = undefined; 1505 + var response = req.receiveHead(&redirect_buf) catch return error.FetchFailed; 1506 + 1507 + var new_nonce: ?[]const u8 = null; 1508 + var header_iter = response.head.iterateHeaders(); 1509 + while (header_iter.next()) |header| { 1510 + if (std.ascii.eqlIgnoreCase(header.name, "dpop-nonce")) { 1511 + new_nonce = header.value; 1512 + break; 1513 + } 1514 + } 1472 1515 1473 - const http_method: http.Method = if (mem.eql(u8, method_str, "POST")) .POST else .GET; 1516 + var aw: std.Io.Writer.Allocating = .init(alloc); 1517 + const reader = response.reader(&.{}); 1518 + _ = reader.streamRemaining(&aw.writer) catch { 1519 + aw.deinit(); 1520 + return error.FetchFailed; 1521 + }; 1522 + const resp_body = aw.toOwnedSlice() catch return error.FetchFailed; 1474 1523 1475 - var client: std.http.Client = .{ .allocator = alloc }; 1476 - defer client.deinit(); 1524 + if (new_nonce) |n| { 1525 + db.updateSessionNonce(session.did, .pds, n); 1526 + } 1477 1527 1478 - var req = try client.request(http_method, try std.Uri.parse(url), .{ 1479 - .extra_headers = &.{ 1480 - .{ .name = "Authorization", .value = auth_header }, 1481 - .{ .name = "DPoP", .value = dpop_proof }, 1482 - }, 1483 - .headers = .{ 1484 - .content_type = .{ .override = "application/json" }, 1485 - }, 1486 - }); 1487 - defer req.deinit(); 1528 + if (new_nonce != null and isDpopNonceError(response.head.status, resp_body)) { 1529 + alloc.free(resp_body); 1530 + nonce = try alloc.dupe(u8, new_nonce.?); 1531 + continue; 1532 + } 1488 1533 1489 - if (body) |b| { 1490 - req.transfer_encoding = .{ .content_length = b.len }; 1491 - var body_writer = try req.sendBodyUnflushed(&.{}); 1492 - try body_writer.writer.writeAll(b); 1493 - try body_writer.end(); 1494 - try req.connection.?.flush(); 1534 + break .{ response.head.status, resp_body }; 1495 1535 } else { 1496 - try req.sendBodiless(); 1536 + return error.DpopNonceRetryExhausted; 1537 + }; 1538 + 1539 + const status = nonce_result[0]; 1540 + const resp_body = nonce_result[1]; 1541 + 1542 + if (status != .unauthorized) { 1543 + return resp_body; 1544 + } 1545 + 1546 + // 401 — try refreshing the access token (once) 1547 + alloc.free(resp_body); 1548 + if (attempt > 0 or refreshed) { 1549 + return error.Unauthorized; 1497 1550 } 1498 1551 1499 - var redirect_buf: [1]u8 = undefined; 1500 - var response = req.receiveHead(&redirect_buf) catch return error.FetchFailed; 1552 + std.debug.print("access token expired for {s}, refreshing\n", .{session.did}); 1553 + const new_tokens = refreshAccessToken(alloc, session, &dpop_keypair) catch |err| { 1554 + std.debug.print("token refresh failed: {}\n", .{err}); 1555 + return error.Unauthorized; 1556 + }; 1557 + access_token = new_tokens.access_token; 1558 + refreshed = true; 1559 + } 1560 + 1561 + return error.Unauthorized; 1562 + } 1563 + 1564 + fn refreshAccessToken(alloc: std.mem.Allocator, session: db.Session, dpop_keypair: *const zat.Keypair) !TokenResult { 1565 + // fetch auth server metadata for token endpoint 1566 + var authserver_meta = try fetchAuthServerMeta(alloc, session.authserver_iss); 1567 + defer authserver_meta.deinit(); 1568 + 1569 + const token_url = jsonGetString(authserver_meta.value, "token_endpoint") orelse return error.MissingTokenEndpoint; 1570 + 1571 + const client_keypair = getClientKeypair() catch return error.InvalidSessionKey; 1572 + const client_id = getClientId(); 1573 + 1574 + const client_assertion = try oauth.createClientAssertion(alloc, &client_keypair, client_id, session.authserver_iss); 1575 + defer alloc.free(client_assertion); 1501 1576 1502 - // extract DPoP nonce from headers 1503 - var new_nonce: ?[]const u8 = null; 1504 - var header_iter = response.head.iterateHeaders(); 1505 - while (header_iter.next()) |header| { 1506 - if (std.ascii.eqlIgnoreCase(header.name, "dpop-nonce")) { 1507 - new_nonce = header.value; 1508 - break; 1509 - } 1510 - } 1577 + var authserver_nonce: ?[]const u8 = if (session.dpop_authserver_nonce.len > 0) session.dpop_authserver_nonce else null; 1578 + 1579 + // retry once for DPoP nonce 1580 + for (0..2) |_| { 1581 + const dpop_proof = try oauth.createDpopProof(alloc, dpop_keypair, "POST", token_url, authserver_nonce, null); 1582 + defer alloc.free(dpop_proof); 1511 1583 1512 - // read the response body 1513 - var aw: std.Io.Writer.Allocating = .init(alloc); 1514 - const reader = response.reader(&.{}); 1515 - _ = reader.streamRemaining(&aw.writer) catch { 1516 - aw.deinit(); 1517 - return error.FetchFailed; 1584 + const form_params = [_][2][]const u8{ 1585 + .{ "grant_type", "refresh_token" }, 1586 + .{ "refresh_token", session.refresh_token }, 1587 + .{ "client_id", client_id }, 1588 + .{ "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, 1589 + .{ "client_assertion", client_assertion }, 1518 1590 }; 1519 - const resp_body = aw.toOwnedSlice() catch return error.FetchFailed; 1520 1591 1521 - if (new_nonce) |n| { 1522 - db.updateSessionNonce(session.did, .pds, n); 1592 + const form_body = try oauth.formEncode(alloc, &form_params); 1593 + defer alloc.free(form_body); 1594 + 1595 + const result = try doPost(alloc, token_url, form_body, &.{ 1596 + .{ .name = "DPoP", .value = dpop_proof }, 1597 + }); 1598 + 1599 + if (result.dpop_nonce) |n| { 1600 + db.updateSessionNonce(session.did, .authserver, n); 1523 1601 } 1524 1602 1525 - // check body for DPoP nonce error (not just status code) 1526 - if (new_nonce != null and isDpopNonceError(response.head.status, resp_body)) { 1527 - alloc.free(resp_body); 1528 - nonce = try alloc.dupe(u8, new_nonce.?); 1529 - continue; // retry with new nonce 1603 + if (isDpopNonceError(result.status, result.body)) { 1604 + authserver_nonce = result.dpop_nonce; 1605 + alloc.free(result.body); 1606 + continue; 1607 + } 1608 + 1609 + defer alloc.free(result.body); 1610 + 1611 + if (result.status != .ok) { 1612 + std.debug.print("token refresh error: {s}\n", .{result.body}); 1613 + return error.TokenRefreshFailed; 1530 1614 } 1531 1615 1532 - // real auth failure (expired token, not a nonce issue) 1533 - if (response.head.status == .unauthorized) { 1534 - alloc.free(resp_body); 1535 - return error.Unauthorized; 1616 + const parsed = try json.parseFromSlice(json.Value, alloc, result.body, .{}); 1617 + defer parsed.deinit(); 1618 + 1619 + const new_access = try alloc.dupe(u8, jsonGetString(parsed.value, "access_token") orelse return error.MissingAccessToken); 1620 + const new_refresh = try alloc.dupe(u8, jsonGetString(parsed.value, "refresh_token") orelse return error.MissingRefreshToken); 1621 + 1622 + // persist new tokens 1623 + db.updateSessionTokens(session.did, new_access, new_refresh); 1624 + if (result.dpop_nonce) |n| { 1625 + db.updateSessionNonce(session.did, .authserver, n); 1536 1626 } 1537 1627 1538 - return resp_body; 1628 + std.debug.print("token refreshed for {s}\n", .{session.did}); 1629 + 1630 + return .{ 1631 + .access_token = new_access, 1632 + .refresh_token = new_refresh, 1633 + .sub = try alloc.dupe(u8, session.did), 1634 + .dpop_nonce = if (result.dpop_nonce) |n| try alloc.dupe(u8, n) else try alloc.dupe(u8, ""), 1635 + }; 1539 1636 } 1540 1637 1541 - return error.DpopNonceRetryExhausted; 1638 + return error.TokenRefreshFailed; 1542 1639 } 1543 1640 1544 1641 fn isDpopNonceError(status: http.Status, body: []const u8) bool {