atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

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

Support discoverable passkey login

zzstoatzz (May 27, 2026, 2:52 PM -0500) fe946715 bc411ac9

+137 -8
+56 -8
src/internal/passkeys.zig
··· 11 11 const request_uri_prefix = "urn:ietf:params:oauth:request_uri:"; 12 12 const challenge_ttl_seconds: i64 = 300; 13 13 14 + const LoginChallenge = struct { 15 + value: []const u8, 16 + expires_at: i64, 17 + named: bool, 18 + }; 19 + 14 20 pub const CredentialKey = struct { 15 21 bytes: []const u8, 16 22 ··· 198 204 const request_id = requestIdFromUri(request_uri) orelse return jsonError(request, .bad_request, "Invalid request_uri"); 199 205 const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return jsonError(request, .bad_request, "Unknown request_uri"); 200 206 if (oauth_request.expires_at < now()) return jsonError(request, .bad_request, "Expired request_uri"); 201 - const identifier = requiredString(parsed.value, "identifier") orelse oauth_request.login_hint orelse return jsonError(request, .bad_request, "Enter an account before using a passkey"); 202 - const account = (try store.findAccount(allocator, identifier)) orelse return jsonError(request, .not_found, "Account not found"); 207 + const identifier = nonEmptyString(parsed.value, "identifier") orelse oauth_request.login_hint; 208 + const challenge = try newChallenge(allocator); 209 + if (identifier == null) { 210 + try store.putDiscoverableWebAuthnChallenge(request_id, challenge, now() + challenge_ttl_seconds); 211 + const body = try std.fmt.allocPrint(allocator, 212 + \\{{"publicKey":{{"challenge":{f},"rpId":{f},"timeout":60000,"userVerification":"required"}}}} 213 + , .{ std.json.fmt(challenge, .{}), std.json.fmt(try rpId(allocator), .{}) }); 214 + try http_api.json(request, .ok, body); 215 + return; 216 + } 217 + 218 + const account = (try store.findAccount(allocator, identifier.?)) orelse return jsonError(request, .not_found, "Account not found"); 203 219 const passkeys = try store.listPasskeys(allocator, account.did); 204 220 if (passkeys.len == 0) return jsonError(request, .bad_request, "No passkeys are registered for this account"); 205 - const challenge = try newChallenge(allocator); 206 221 try store.putWebAuthnChallenge(account.did, "login", challenge, request_id, now() + challenge_ttl_seconds); 207 222 const allow = try credentialsJson(allocator, passkeys); 208 223 const body = try std.fmt.allocPrint(allocator, ··· 224 239 const credential = try assertionResponse(parsed.value); 225 240 const credential_id = try webauthn.base64url.decodeAlloc(allocator, credential.raw_id); 226 241 const passkey = (try store.getPasskeyByCredentialId(allocator, credential_id)) orelse return jsonError(request, .unauthorized, "Unknown passkey"); 227 - const challenge = (try store.getWebAuthnChallenge(allocator, passkey.did, "login")) orelse return jsonError(request, .bad_request, "Missing login challenge"); 228 - if (challenge.expires_at < now()) return jsonError(request, .bad_request, "Login challenge expired"); 229 - if (!std.mem.eql(u8, challenge.state_json, oauth_request.request_id)) return jsonError(request, .bad_request, "Login challenge does not match this request"); 242 + const login_challenge = findLoginChallenge(allocator, passkey.did, request_id) catch |err| switch (err) { 243 + error.MissingLoginChallenge => return jsonError(request, .bad_request, "Missing login challenge"), 244 + else => return err, 245 + }; 246 + if (login_challenge.expires_at < now()) return jsonError(request, .bad_request, "Login challenge expired"); 230 247 231 248 const assertion = webauthn.assertion.verify(allocator, credential, .{ 232 - .challenge = challenge.challenge, 249 + .challenge = login_challenge.value, 233 250 .origin = config.publicUrl(), 234 251 .rp_id = try rpId(allocator), 235 252 .credential_public_key = passkey.public_key, ··· 239 256 240 257 const code = try store.randomToken(allocator, "", 16); 241 258 try store.updatePasskeyUse(passkey.id, assertion.recommended_sign_count); 242 - try store.deleteWebAuthnChallenge(passkey.did, "login"); 259 + if (login_challenge.named) { 260 + try store.deleteWebAuthnChallenge(passkey.did, "login"); 261 + } else { 262 + try store.deleteDiscoverableWebAuthnChallenge(request_id); 263 + } 243 264 try store.authorizeOAuthRequest(oauth_request.request_id, passkey.did, code); 244 265 const redirect_uri = try authorizationRedirect(allocator, oauth_request.redirect_uri, code, oauth_request.state, oauth_request.response_mode); 245 266 const body = try std.fmt.allocPrint(allocator, "{{\"redirect_uri\":{f}}}", .{std.json.fmt(redirect_uri, .{})}); ··· 256 277 257 278 fn requiredString(value: std.json.Value, key: []const u8) ?[]const u8 { 258 279 return zat.json.getString(value, key); 280 + } 281 + 282 + fn nonEmptyString(value: std.json.Value, key: []const u8) ?[]const u8 { 283 + const raw = requiredString(value, key) orelse return null; 284 + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); 285 + if (trimmed.len == 0) return null; 286 + return trimmed; 287 + } 288 + 289 + fn findLoginChallenge(allocator: std.mem.Allocator, did: []const u8, request_id: []const u8) !LoginChallenge { 290 + if (try store.getWebAuthnChallenge(allocator, did, "login")) |candidate| { 291 + if (std.mem.eql(u8, candidate.state_json, request_id)) { 292 + return .{ 293 + .value = candidate.challenge, 294 + .expires_at = candidate.expires_at, 295 + .named = true, 296 + }; 297 + } 298 + } 299 + if (try store.getDiscoverableWebAuthnChallenge(allocator, request_id)) |discoverable| { 300 + return .{ 301 + .value = discoverable.challenge, 302 + .expires_at = discoverable.expires_at, 303 + .named = false, 304 + }; 305 + } 306 + return error.MissingLoginChallenge; 259 307 } 260 308 261 309 fn registrationResponse(value: std.json.Value) !webauthn.registration.Response {
+81
src/storage/store.zig
··· 108 108 expires_at: i64, 109 109 }; 110 110 111 + pub const DiscoverableWebAuthnChallenge = struct { 112 + request_id: []const u8, 113 + challenge: []const u8, 114 + expires_at: i64, 115 + }; 116 + 111 117 pub const InviteCode = struct { 112 118 code: []const u8, 113 119 available: i64, ··· 897 903 defer db_mutex.unlock(store_io); 898 904 try requireInitialized(); 899 905 try conn.exec("DELETE FROM webauthn_challenges WHERE did = ? AND kind = ?", .{ did, kind }); 906 + } 907 + 908 + pub fn putDiscoverableWebAuthnChallenge(request_id: []const u8, challenge: []const u8, expires_at: i64) !void { 909 + db_mutex.lockUncancelable(store_io); 910 + defer db_mutex.unlock(store_io); 911 + try requireInitialized(); 912 + try conn.exec("DELETE FROM webauthn_discoverable_challenges WHERE request_id = ?", .{request_id}); 913 + try conn.exec( 914 + \\INSERT INTO webauthn_discoverable_challenges (request_id, challenge, expires_at) 915 + \\VALUES (?, ?, ?) 916 + , .{ request_id, challenge, expires_at }); 917 + } 918 + 919 + pub fn getDiscoverableWebAuthnChallenge(allocator: std.mem.Allocator, request_id: []const u8) !?DiscoverableWebAuthnChallenge { 920 + db_mutex.lockUncancelable(store_io); 921 + defer db_mutex.unlock(store_io); 922 + try requireInitialized(); 923 + const row = try conn.row( 924 + \\SELECT request_id, challenge, expires_at 925 + \\FROM webauthn_discoverable_challenges 926 + \\WHERE request_id = ? 927 + , .{request_id}); 928 + if (row == null) return null; 929 + defer row.?.deinit(); 930 + return .{ 931 + .request_id = try allocator.dupe(u8, row.?.text(0)), 932 + .challenge = try allocator.dupe(u8, row.?.text(1)), 933 + .expires_at = row.?.int(2), 934 + }; 935 + } 936 + 937 + pub fn deleteDiscoverableWebAuthnChallenge(request_id: []const u8) !void { 938 + db_mutex.lockUncancelable(store_io); 939 + defer db_mutex.unlock(store_io); 940 + try requireInitialized(); 941 + try conn.exec("DELETE FROM webauthn_discoverable_challenges WHERE request_id = ?", .{request_id}); 900 942 } 901 943 902 944 pub fn savePasskey(allocator: std.mem.Allocator, did: []const u8, credential_id: []const u8, public_key: []const u8, sign_count: u32, friendly_name: ?[]const u8) ![]const u8 { ··· 3474 3516 \\) 3475 3517 , 3476 3518 "CREATE INDEX IF NOT EXISTS webauthn_challenges_did_kind_idx ON webauthn_challenges (did, kind)", 3519 + \\CREATE TABLE IF NOT EXISTS webauthn_discoverable_challenges ( 3520 + \\ request_id TEXT PRIMARY KEY REFERENCES oauth_requests(request_id) ON DELETE CASCADE, 3521 + \\ challenge TEXT NOT NULL, 3522 + \\ expires_at INTEGER NOT NULL, 3523 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 3524 + \\) 3525 + , 3477 3526 \\CREATE TABLE IF NOT EXISTS invite_codes ( 3478 3527 \\ code TEXT PRIMARY KEY, 3479 3528 \\ available_uses INTEGER NOT NULL, ··· 3750 3799 const consumed = try consumeReservedSigningKey(reserved.signing_key, did); 3751 3800 try std.testing.expectEqualSlices(u8, &reserved.secret_key, &consumed); 3752 3801 try std.testing.expectError(error.MissingReservedSigningKey, consumeReservedSigningKey(reserved.signing_key, did)); 3802 + } 3803 + 3804 + test "stores discoverable passkey challenges by oauth request" { 3805 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 3806 + defer arena.deinit(); 3807 + const allocator = arena.allocator(); 3808 + 3809 + try init(std.Options.debug_io, ":memory:"); 3810 + defer close(); 3811 + 3812 + try putOAuthRequest( 3813 + "req-discoverable", 3814 + "https://client.example.com/oauth-client-metadata.json", 3815 + "https://client.example.com/callback", 3816 + "atproto", 3817 + "state", 3818 + "challenge", 3819 + "S256", 3820 + "query", 3821 + null, 3822 + null, 3823 + 4102444800, 3824 + ); 3825 + try putDiscoverableWebAuthnChallenge("req-discoverable", "webauthn-challenge", 4102444800); 3826 + 3827 + const stored = (try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable")) orelse return error.MissingRecord; 3828 + try std.testing.expectEqualStrings("req-discoverable", stored.request_id); 3829 + try std.testing.expectEqualStrings("webauthn-challenge", stored.challenge); 3830 + try std.testing.expectEqual(@as(i64, 4102444800), stored.expires_at); 3831 + 3832 + try deleteDiscoverableWebAuthnChallenge("req-discoverable"); 3833 + try std.testing.expect(try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable") == null); 3753 3834 } 3754 3835 3755 3836 test "commit event encoding owns returned firehose op entries" {