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

Configure Feed

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

implement app password lifecycle

zzstoatzz (May 29, 2026, 12:27 PM -0500) 905c52e2 b639afe5

+456 -24
+3
README.md
··· 13 13 ## docs 14 14 15 15 - [architecture](docs/architecture.md) 16 + - [account security](docs/account-security.md) 16 17 - [development](docs/development.md) 17 18 - [operations](docs/operations.md) 18 19 - [invite codes](docs/invite-codes.md) ··· 102 103 - Invite codes use the official PDS table shape: code metadata plus recorded 103 104 uses. When invites are required, `createAccount` rejects missing, disabled, or 104 105 exhausted codes. 106 + - App passwords use durable account-owned rows and can be listed or revoked. 107 + Revocation also invalidates active sessions created with that app password. 105 108 - Passkeys are optional account credentials for the OAuth login page. ZDS stores 106 109 credential IDs, public keys, counters, names, and last-use timestamps. 107 110
+43
docs/account-security.md
··· 1 + # account security 2 + 3 + ZDS keeps account security state in SQLite so credentials and sessions can be 4 + listed, revoked, and audited without trusting self-contained tokens alone. 5 + 6 + ## sessions 7 + 8 + Password login creates a durable session row keyed by the access and refresh JWT 9 + IDs. Refresh rotates that row to new JWT IDs, and revocation marks the stored 10 + row revoked. A session JWT is accepted only when its JTI still has an active 11 + storage row. 12 + 13 + The session row also records `auth_method` and optional `app_password_name`. 14 + Account-management endpoints that create, list, or revoke app passwords require 15 + a real password session, matching the official PDS boundary that app passwords 16 + cannot mint or manage other app passwords. 17 + 18 + ## app passwords 19 + 20 + App passwords follow the official PDS model: each row belongs to an account DID 21 + and name, stores a hashed password, creation time, privileged bit, and optional 22 + scope/controller metadata. The raw generated password is returned once from 23 + `com.atproto.server.createAppPassword`. 24 + 25 + Supported routes: 26 + 27 + - `com.atproto.server.createAppPassword` 28 + - `com.atproto.server.listAppPasswords` 29 + - `com.atproto.server.revokeAppPassword` 30 + 31 + `createSession` accepts either the account password or a stored app password. 32 + App-password access JWTs use the official `com.atproto.appPass` or 33 + `com.atproto.appPassPrivileged` scope, while refresh JWTs keep 34 + `com.atproto.refresh`. Sessions created through app passwords are attributed to 35 + the app-password name. Revoking an app password deletes the credential and 36 + revokes active sessions that were created with it. 37 + 38 + ## references 39 + 40 + - official PDS: app passwords are account-owned rows keyed by DID and name, and 41 + management requires password auth rather than OAuth or app-password auth. 42 + - Tranquil: extends the same account-security plane with scopes, controller DID 43 + fields, session attribution, and delegation-oriented identity boundaries.
+5 -3
docs/architecture.md
··· 62 62 63 63 Password sessions and app passwords match the reference PDS account model. 64 64 Session JWTs are accepted only when their JTI is present in the active session 65 - table, so revocation and refresh rotation can be enforced by storage. The 66 - session schema already carries an optional `controller_did` column for future 67 - delegated-account flows, following Tranquil's controller-vs-subject split. 65 + table, so revocation and refresh rotation can be enforced by storage. 66 + App-password sessions are attributed to the app-password name, and revoking the 67 + credential revokes those sessions too. The session schema already carries an 68 + optional `controller_did` column for future delegated-account flows, following 69 + Tranquil's controller-vs-subject split. 68 70 69 71 Passkeys are an additional OAuth login credential inspired by Tranquil, backed 70 72 by the local `webauthn` dependency and stored in SQLite. Discoverable passkey
+144 -8
src/atproto/server.zig
··· 162 162 }; 163 163 log.debug("xrpc createAccount ok did={s} handle={s}\n", .{ account.did, account.handle }); 164 164 165 - const issued = try issueSessionTokens(allocator, account, "account_create"); 165 + const issued = try issueSessionTokens(allocator, account, "account_create", null); 166 166 const body_out = try sessionJson(allocator, account, issued.access.token, issued.refresh.token); 167 167 try http_api.json(request, .ok, body_out); 168 168 } ··· 256 256 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 257 257 defer arena.deinit(); 258 258 const allocator = arena.allocator(); 259 - _ = requireAccount(request, allocator) catch return; 260 - return http_api.json(request, .ok, "{\"passwords\":[]}"); 259 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 260 + try requirePasswordSession(request, allocator, auth_ctx.account.did); 261 + const passwords = try store.listAppPasswords(allocator, auth_ctx.account.did); 262 + var out: std.Io.Writer.Allocating = .init(allocator); 263 + defer out.deinit(); 264 + try out.writer.writeAll("{\"passwords\":["); 265 + for (passwords, 0..) |password, idx| { 266 + if (idx != 0) try out.writer.writeByte(','); 267 + const created_at = try isoTimestamp(allocator, password.created_at); 268 + try out.writer.print( 269 + "{{\"name\":{f},\"createdAt\":{f},\"privileged\":{}}}", 270 + .{ std.json.fmt(password.name, .{}), std.json.fmt(created_at, .{}), password.privileged }, 271 + ); 272 + } 273 + try out.writer.writeAll("]}"); 274 + return http_api.json(request, .ok, try out.toOwnedSlice()); 275 + } 276 + 277 + pub fn createAppPassword(request: *http.Server.Request) !void { 278 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 279 + defer arena.deinit(); 280 + const allocator = arena.allocator(); 281 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 282 + try requirePasswordSession(request, allocator, auth_ctx.account.did); 283 + 284 + const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); 285 + const parsed = try http_api.parseJsonBody(request, allocator, body); 286 + const raw_name = zat.json.getString(parsed.value, "name") orelse { 287 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); 288 + }; 289 + const name = std.mem.trim(u8, raw_name, " \t\r\n"); 290 + if (name.len == 0) { 291 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); 292 + } 293 + if (try store.getAppPasswordByName(allocator, auth_ctx.account.did, name) != null) { 294 + return http_api.xrpcError(request, .bad_request, "DuplicateAppPassword", "An app password with this name already exists"); 295 + } 296 + const privileged = jsonBool(parsed.value, "privileged") orelse false; 297 + const app_password = try generateAppPassword(allocator); 298 + var salt: [16]u8 = undefined; 299 + store.randomBytes(&salt); 300 + const password_hash = try auth.hashPassword(allocator, app_password, salt); 301 + const app_password_scopes: []const u8 = if (privileged) "transition:generic transition:chat.bsky" else "transition:generic"; 302 + const row = try store.createAppPassword(allocator, auth_ctx.account.did, name, password_hash, privileged, app_password_scopes, null); 303 + try store.recordAuditEvent(auth_ctx.account.did, auth_ctx.account.did, null, "app_password_created", "{}"); 304 + const created_at = try isoTimestamp(allocator, row.created_at); 305 + const body_out = try std.fmt.allocPrint( 306 + allocator, 307 + "{{\"name\":{f},\"password\":{f},\"createdAt\":{f},\"privileged\":{}}}", 308 + .{ std.json.fmt(row.name, .{}), std.json.fmt(app_password, .{}), std.json.fmt(created_at, .{}), row.privileged }, 309 + ); 310 + return http_api.json(request, .ok, body_out); 311 + } 312 + 313 + pub fn revokeAppPassword(request: *http.Server.Request) !void { 314 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 315 + defer arena.deinit(); 316 + const allocator = arena.allocator(); 317 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 318 + try requirePasswordSession(request, allocator, auth_ctx.account.did); 319 + 320 + const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); 321 + const parsed = try http_api.parseJsonBody(request, allocator, body); 322 + const raw_name = zat.json.getString(parsed.value, "name") orelse { 323 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); 324 + }; 325 + const name = std.mem.trim(u8, raw_name, " \t\r\n"); 326 + if (name.len == 0) { 327 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); 328 + } 329 + try store.revokeAppPassword(auth_ctx.account.did, name); 330 + try store.recordAuditEvent(auth_ctx.account.did, auth_ctx.account.did, null, "app_password_revoked", "{}"); 331 + return http_api.json(request, .ok, "{}"); 261 332 } 262 333 263 334 fn jsonStringArray(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { ··· 294 365 const raw = value.object.get(key) orelse return null; 295 366 return switch (raw) { 296 367 .integer => |n| n, 368 + else => null, 369 + }; 370 + } 371 + 372 + fn jsonBool(value: std.json.Value, key: []const u8) ?bool { 373 + if (value != .object) return null; 374 + const raw = value.object.get(key) orelse return null; 375 + return switch (raw) { 376 + .bool => |n| n, 297 377 else => null, 298 378 }; 299 379 } ··· 547 627 log.debug("xrpc createSession rejected account_not_found identifier={s}\n", .{identifier}); 548 628 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); 549 629 }; 550 - if (!auth.passwordMatches(account, password)) { 630 + const primary_password_matches = auth.passwordMatches(account, password); 631 + const app_password = if (primary_password_matches) 632 + null 633 + else 634 + try store.findMatchingAppPassword(arena.allocator(), account.did, password); 635 + if (!primary_password_matches and app_password == null) { 551 636 log.debug("xrpc createSession rejected password_mismatch identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 552 637 return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); 553 638 } 554 639 log.debug("xrpc createSession ok identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 555 640 556 - const issued = try issueSessionTokens(arena.allocator(), account, "password"); 641 + const auth_method: []const u8 = if (app_password) |value| 642 + if (value.privileged) "app_password_privileged" else "app_password" 643 + else 644 + "password"; 645 + const issued = try issueSessionTokens(arena.allocator(), account, auth_method, if (app_password) |value| value.name else null); 557 646 const info = store.getEmailInfo(arena.allocator(), account.did) orelse { 558 647 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 559 648 }; ··· 579 668 defer allocator.free(old_claims.did); 580 669 defer allocator.free(old_claims.scope); 581 670 defer allocator.free(old_claims.jti); 582 - const issued = try auth.createSessionPair(allocator, account); 671 + const auth_method = try store.sessionAuthMethod(allocator, account.did, old_claims.jti) orelse { 672 + return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 673 + }; 674 + const issued = try auth.createSessionPairWithAccessScope(allocator, account, accessScopeForAuthMethod(auth_method)); 583 675 try store.rotateSessionToken(account.did, old_claims.jti, issued.access.jti, issued.refresh.jti, issued.access.exp, issued.refresh.exp); 584 676 try store.recordAuditEvent(account.did, account.did, null, "session_refreshed", "{}"); 585 677 const info = store.getEmailInfo(allocator, account.did) orelse { ··· 618 710 return sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, store.isAccountActive(account.did), access, refresh); 619 711 } 620 712 621 - fn issueSessionTokens(allocator: std.mem.Allocator, account: auth.Account, auth_method: []const u8) !auth.SessionPair { 622 - const issued = try auth.createSessionPair(allocator, account); 713 + fn issueSessionTokens(allocator: std.mem.Allocator, account: auth.Account, auth_method: []const u8, app_password_name: ?[]const u8) !auth.SessionPair { 714 + const issued = try auth.createSessionPairWithAccessScope(allocator, account, accessScopeForAuthMethod(auth_method)); 623 715 _ = try store.createSessionTokenRow( 624 716 allocator, 625 717 account.did, ··· 629 721 issued.refresh.exp, 630 722 auth_method, 631 723 null, 724 + app_password_name, 632 725 ); 633 726 try store.recordAuditEvent(account.did, account.did, null, "session_created", "{}"); 634 727 return issued; 635 728 } 636 729 730 + fn accessScopeForAuthMethod(auth_method: []const u8) []const u8 { 731 + if (std.mem.eql(u8, auth_method, "app_password_privileged")) return "com.atproto.appPassPrivileged"; 732 + if (std.mem.eql(u8, auth_method, "app_password")) return "com.atproto.appPass"; 733 + return "com.atproto.access"; 734 + } 735 + 637 736 fn currentBearerClaims(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.TokenClaims { 638 737 const auth_header = http_api.headerValue(request, "authorization") orelse return error.AuthRequired; 639 738 if (!std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) return error.AuthRequired; ··· 953 1052 return error.HandledResponse; 954 1053 } 955 1054 1055 + fn requirePasswordSession(request: *http.Server.Request, allocator: std.mem.Allocator, did: []const u8) !void { 1056 + const claims = currentBearerClaims(request, allocator) catch { 1057 + try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); 1058 + return error.HandledResponse; 1059 + }; 1060 + defer allocator.free(claims.did); 1061 + defer allocator.free(claims.scope); 1062 + defer allocator.free(claims.jti); 1063 + if (!std.mem.eql(u8, claims.did, did)) { 1064 + try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); 1065 + return error.HandledResponse; 1066 + } 1067 + const method = try store.sessionAuthMethod(allocator, did, claims.jti) orelse { 1068 + try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); 1069 + return error.HandledResponse; 1070 + }; 1071 + if (!std.mem.eql(u8, method, "password") and !std.mem.eql(u8, method, "account_create")) { 1072 + try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); 1073 + return error.HandledResponse; 1074 + } 1075 + } 1076 + 956 1077 fn requireAdminToken(request: *http.Server.Request) !void { 957 1078 const expected = config.adminToken() orelse { 958 1079 try http_api.xrpcError(request, .forbidden, "AdminRequired", "Admin token not configured"); ··· 987 1108 } 988 1109 out[5] = '-'; 989 1110 return out[0..]; 1111 + } 1112 + 1113 + fn generateAppPassword(allocator: std.mem.Allocator) ![]const u8 { 1114 + const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; 1115 + var random: [16]u8 = undefined; 1116 + store.randomBytes(&random); 1117 + var out: [19]u8 = undefined; 1118 + for (random, 0..) |byte, idx| { 1119 + const write_idx = idx + @divTrunc(idx, 4); 1120 + out[write_idx] = alphabet[byte & 31]; 1121 + } 1122 + out[4] = '-'; 1123 + out[9] = '-'; 1124 + out[14] = '-'; 1125 + return allocator.dupe(u8, out[0..]); 990 1126 } 991 1127 992 1128 fn sendCode(
+37 -8
src/auth/tokens.zig
··· 19 19 var jwt_counter: usize = 0; 20 20 21 21 pub fn passwordMatches(account: Account, password: []const u8) bool { 22 - if (verifyPasswordHash(account.password, password)) return true; 22 + if (passwordHashMatches(account.password, password)) return true; 23 23 return std.mem.eql(u8, password, account.password); 24 24 } 25 25 ··· 31 31 return std.fmt.allocPrint(allocator, "pbkdf2-sha256:{d}:{s}:{s}", .{ password_hash_rounds, &salt_hex, &derived_hex }); 32 32 } 33 33 34 - fn verifyPasswordHash(stored: []const u8, password: []const u8) bool { 34 + pub fn passwordHashMatches(stored: []const u8, password: []const u8) bool { 35 35 var parts = std.mem.splitScalar(u8, stored, ':'); 36 36 const scheme = parts.next() orelse return false; 37 37 if (!std.mem.eql(u8, scheme, "pbkdf2-sha256")) return false; ··· 63 63 refresh: SessionToken, 64 64 }; 65 65 66 - pub fn createSessionJwt(allocator: std.mem.Allocator, comptime kind: []const u8, account: Account) ![]const u8 { 66 + pub fn createSessionJwt(allocator: std.mem.Allocator, kind: []const u8, account: Account) ![]const u8 { 67 67 const token = try createSessionToken(allocator, kind, account); 68 68 allocator.free(token.jti); 69 69 return token.token; 70 70 } 71 71 72 72 pub fn createSessionPair(allocator: std.mem.Allocator, account: Account) !SessionPair { 73 - const access = try createSessionToken(allocator, "access", account); 73 + return createSessionPairWithAccessScope(allocator, account, "com.atproto.access"); 74 + } 75 + 76 + pub fn createSessionPairWithAccessScope(allocator: std.mem.Allocator, account: Account, access_scope: []const u8) !SessionPair { 77 + const access = try createSessionTokenWithScope(allocator, account, "access", access_scope); 74 78 errdefer { 75 79 allocator.free(access.token); 76 80 allocator.free(access.jti); 77 81 } 78 - const refresh = try createSessionToken(allocator, "refresh", account); 82 + const refresh = try createSessionTokenWithScope(allocator, account, "refresh", "com.atproto.refresh"); 79 83 return .{ .access = access, .refresh = refresh }; 80 84 } 81 85 82 - pub fn createSessionToken(allocator: std.mem.Allocator, comptime kind: []const u8, account: Account) !SessionToken { 86 + pub fn createSessionToken(allocator: std.mem.Allocator, kind: []const u8, account: Account) !SessionToken { 87 + const scope = try std.fmt.allocPrint(allocator, "com.atproto.{s}", .{kind}); 88 + defer allocator.free(scope); 89 + return createSessionTokenWithScope(allocator, account, kind, scope); 90 + } 91 + 92 + fn createSessionTokenWithScope(allocator: std.mem.Allocator, account: Account, kind: []const u8, scope: []const u8) !SessionToken { 83 93 const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; 84 94 const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); 85 95 var ts: std.posix.timespec = undefined; ··· 94 104 errdefer allocator.free(jti_text); 95 105 const payload = try std.fmt.allocPrint( 96 106 allocator, 97 - "{{\"sub\":\"{s}\",\"scope\":\"com.atproto.{s}\",\"iat\":{d},\"exp\":{d},\"jti\":\"{s}\"}}", 98 - .{ account.did, kind, iat, exp, jti_text }, 107 + "{{\"sub\":{f},\"scope\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", 108 + .{ std.json.fmt(account.did, .{}), std.json.fmt(scope, .{}), iat, exp, std.json.fmt(jti_text, .{}) }, 99 109 ); 100 110 defer allocator.free(payload); 101 111 ··· 244 254 defer std.testing.allocator.free(claims.scope); 245 255 defer std.testing.allocator.free(claims.jti); 246 256 try std.testing.expectEqualStrings(alice.did, claims.did); 257 + } 258 + 259 + test "app-password session pair uses appPass access scope" { 260 + const alice = testAccount(); 261 + const pair = try createSessionPairWithAccessScope(std.testing.allocator, alice, "com.atproto.appPass"); 262 + defer std.testing.allocator.free(pair.access.token); 263 + defer std.testing.allocator.free(pair.access.jti); 264 + defer std.testing.allocator.free(pair.refresh.token); 265 + defer std.testing.allocator.free(pair.refresh.jti); 266 + const access_claims = claimsFromSessionJwt(std.testing.allocator, pair.access.token).?; 267 + defer std.testing.allocator.free(access_claims.did); 268 + defer std.testing.allocator.free(access_claims.scope); 269 + defer std.testing.allocator.free(access_claims.jti); 270 + try std.testing.expectEqualStrings("com.atproto.appPass", access_claims.scope); 271 + const refresh_claims = claimsFromSessionJwt(std.testing.allocator, pair.refresh.token).?; 272 + defer std.testing.allocator.free(refresh_claims.did); 273 + defer std.testing.allocator.free(refresh_claims.scope); 274 + defer std.testing.allocator.free(refresh_claims.jti); 275 + try std.testing.expectEqualStrings("com.atproto.refresh", refresh_claims.scope); 247 276 } 248 277 249 278 test "creates signed service auth token" {
+9 -2
src/http/api.zig
··· 35 35 defer allocator.free(claims.did); 36 36 defer allocator.free(claims.scope); 37 37 defer allocator.free(claims.jti); 38 - if (!std.mem.eql(u8, claims.scope, scope)) return error.InvalidToken; 38 + if (!scopeAllows(claims.scope, scope)) return error.InvalidToken; 39 39 const account = (store.findAccount(allocator, claims.did) catch null) orelse return error.InvalidToken; 40 40 41 41 const oauth_token = store.getOAuthToken(allocator, token) catch return error.InvalidToken; ··· 45 45 return .{ .account = account, .oauth_scope = row.scope }; 46 46 } 47 47 48 - if (!(store.sessionTokenIsActive(claims.did, claims.jti, scope) catch false)) return error.InvalidToken; 48 + if (!(store.sessionTokenIsActive(claims.did, claims.jti, claims.scope) catch false)) return error.InvalidToken; 49 49 return .{ .account = account, .oauth_scope = null }; 50 + } 51 + 52 + fn scopeAllows(actual: []const u8, required: []const u8) bool { 53 + if (std.mem.eql(u8, actual, required)) return true; 54 + if (!std.mem.eql(u8, required, "com.atproto.access")) return false; 55 + return std.mem.eql(u8, actual, "com.atproto.appPass") or 56 + std.mem.eql(u8, actual, "com.atproto.appPassPrivileged"); 50 57 } 51 58 52 59 pub fn optionalBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) ?auth.Account {
+6
src/http/router.zig
··· 30 30 create_invite_codes, 31 31 get_account_invite_codes, 32 32 list_app_passwords, 33 + create_app_password, 34 + revoke_app_password, 33 35 start_passkey_registration, 34 36 finish_passkey_registration, 35 37 list_passkeys, ··· 104 106 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createInviteCodes")) return .create_invite_codes; 105 107 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.getAccountInviteCodes")) return .get_account_invite_codes; 106 108 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.listAppPasswords")) return .list_app_passwords; 109 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.createAppPassword")) return .create_app_password; 110 + if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.revokeAppPassword")) return .revoke_app_password; 107 111 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.startPasskeyRegistration")) return .start_passkey_registration; 108 112 if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.server.finishPasskeyRegistration")) return .finish_passkey_registration; 109 113 if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.server.listPasskeys")) return .list_passkeys; ··· 184 188 try std.testing.expectEqual(Route.create_invite_codes, route(.POST, "/xrpc/com.atproto.server.createInviteCodes")); 185 189 try std.testing.expectEqual(Route.get_account_invite_codes, route(.GET, "/xrpc/com.atproto.server.getAccountInviteCodes")); 186 190 try std.testing.expectEqual(Route.list_app_passwords, route(.GET, "/xrpc/com.atproto.server.listAppPasswords")); 191 + try std.testing.expectEqual(Route.create_app_password, route(.POST, "/xrpc/com.atproto.server.createAppPassword")); 192 + try std.testing.expectEqual(Route.revoke_app_password, route(.POST, "/xrpc/com.atproto.server.revokeAppPassword")); 187 193 try std.testing.expectEqual(Route.start_passkey_registration, route(.POST, "/xrpc/com.atproto.server.startPasskeyRegistration")); 188 194 try std.testing.expectEqual(Route.finish_passkey_registration, route(.POST, "/xrpc/com.atproto.server.finishPasskeyRegistration")); 189 195 try std.testing.expectEqual(Route.list_passkeys, route(.GET, "/xrpc/com.atproto.server.listPasskeys"));
+2
src/http/server.zig
··· 114 114 .create_invite_codes => try atproto_server.createInviteCodes(request), 115 115 .get_account_invite_codes => try atproto_server.getAccountInviteCodes(request), 116 116 .list_app_passwords => try atproto_server.listAppPasswords(request), 117 + .create_app_password => try atproto_server.createAppPassword(request), 118 + .revoke_app_password => try atproto_server.revokeAppPassword(request), 117 119 .start_passkey_registration => try passkeys.xrpcStartRegistration(request), 118 120 .finish_passkey_registration => try passkeys.xrpcFinishRegistration(request), 119 121 .list_passkeys => try passkeys.xrpcList(request),
+207 -3
src/storage/store.zig
··· 101 101 revoked_at: ?i64, 102 102 }; 103 103 104 + pub const AppPassword = struct { 105 + name: []const u8, 106 + password_hash: []const u8, 107 + created_at: i64, 108 + privileged: bool, 109 + scopes: ?[]const u8, 110 + created_by_controller_did: ?[]const u8, 111 + }; 112 + 104 113 pub const AuditEvent = struct { 105 114 id: []const u8, 106 115 subject_did: []const u8, ··· 950 959 refresh_expires_at: i64, 951 960 auth_method: []const u8, 952 961 controller_did: ?[]const u8, 962 + app_password_name: ?[]const u8, 953 963 ) ![]const u8 { 954 964 db_mutex.lockUncancelable(store_io); 955 965 defer db_mutex.unlock(store_io); ··· 958 968 errdefer allocator.free(id); 959 969 try conn.exec( 960 970 \\INSERT INTO session_tokens 961 - \\ (id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did) 962 - \\VALUES (?, ?, ?, ?, ?, ?, ?, ?) 963 - , .{ id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did }); 971 + \\ (id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did, app_password_name) 972 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 973 + , .{ id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did, app_password_name }); 964 974 return id; 965 975 } 966 976 977 + pub fn createAppPassword( 978 + allocator: std.mem.Allocator, 979 + did: []const u8, 980 + name: []const u8, 981 + password_hash: []const u8, 982 + privileged: bool, 983 + scopes: ?[]const u8, 984 + created_by_controller_did: ?[]const u8, 985 + ) !AppPassword { 986 + db_mutex.lockUncancelable(store_io); 987 + defer db_mutex.unlock(store_io); 988 + try requireInitialized(); 989 + try conn.exec( 990 + \\INSERT INTO app_passwords 991 + \\ (did, name, password_hash, privileged, scopes, created_by_controller_did) 992 + \\VALUES (?, ?, ?, ?, ?, ?) 993 + , .{ did, name, password_hash, privileged, scopes, created_by_controller_did }); 994 + const row = (try conn.row( 995 + \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did 996 + \\FROM app_passwords 997 + \\WHERE did = ? AND name = ? 998 + \\LIMIT 1 999 + , .{ did, name })) orelse return error.MissingRecord; 1000 + defer row.deinit(); 1001 + return appPasswordFromRow(row, allocator); 1002 + } 1003 + 1004 + pub fn listAppPasswords(allocator: std.mem.Allocator, did: []const u8) ![]AppPassword { 1005 + db_mutex.lockUncancelable(store_io); 1006 + defer db_mutex.unlock(store_io); 1007 + try requireInitialized(); 1008 + var rows = try conn.rows( 1009 + \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did 1010 + \\FROM app_passwords 1011 + \\WHERE did = ? 1012 + \\ORDER BY created_at DESC, name ASC 1013 + , .{did}); 1014 + defer rows.deinit(); 1015 + var out: std.ArrayList(AppPassword) = .empty; 1016 + while (rows.next()) |row| { 1017 + try out.append(allocator, try appPasswordFromRow(row, allocator)); 1018 + } 1019 + return out.toOwnedSlice(allocator); 1020 + } 1021 + 1022 + pub fn getAppPasswordByName(allocator: std.mem.Allocator, did: []const u8, name: []const u8) !?AppPassword { 1023 + db_mutex.lockUncancelable(store_io); 1024 + defer db_mutex.unlock(store_io); 1025 + try requireInitialized(); 1026 + const row = try conn.row( 1027 + \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did 1028 + \\FROM app_passwords 1029 + \\WHERE did = ? AND name = ? 1030 + \\LIMIT 1 1031 + , .{ did, name }); 1032 + if (row == null) return null; 1033 + defer row.?.deinit(); 1034 + return try appPasswordFromRow(row.?, allocator); 1035 + } 1036 + 1037 + pub fn findMatchingAppPassword(allocator: std.mem.Allocator, did: []const u8, password: []const u8) !?AppPassword { 1038 + db_mutex.lockUncancelable(store_io); 1039 + defer db_mutex.unlock(store_io); 1040 + try requireInitialized(); 1041 + var rows = try conn.rows( 1042 + \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did 1043 + \\FROM app_passwords 1044 + \\WHERE did = ? 1045 + \\ORDER BY created_at DESC 1046 + , .{did}); 1047 + defer rows.deinit(); 1048 + while (rows.next()) |row| { 1049 + const candidate = try appPasswordFromRow(row, allocator); 1050 + if (auth.passwordHashMatches(candidate.password_hash, password)) return candidate; 1051 + } 1052 + return null; 1053 + } 1054 + 1055 + pub fn sessionAuthMethod(allocator: std.mem.Allocator, did: []const u8, jti: []const u8) !?[]const u8 { 1056 + db_mutex.lockUncancelable(store_io); 1057 + defer db_mutex.unlock(store_io); 1058 + try requireInitialized(); 1059 + const row = try conn.row( 1060 + \\SELECT auth_method 1061 + \\FROM session_tokens 1062 + \\WHERE did = ? AND (access_jti = ? OR refresh_jti = ?) AND revoked_at IS NULL 1063 + \\LIMIT 1 1064 + , .{ did, jti, jti }); 1065 + if (row == null) return null; 1066 + defer row.?.deinit(); 1067 + return try allocator.dupe(u8, row.?.text(0)); 1068 + } 1069 + 1070 + pub fn revokeAppPassword(did: []const u8, name: []const u8) !void { 1071 + db_mutex.lockUncancelable(store_io); 1072 + defer db_mutex.unlock(store_io); 1073 + try requireInitialized(); 1074 + try conn.exec( 1075 + \\UPDATE session_tokens 1076 + \\SET revoked_at = unixepoch(), updated_at = unixepoch() 1077 + \\WHERE did = ? AND app_password_name = ? AND revoked_at IS NULL 1078 + , .{ did, name }); 1079 + try conn.exec( 1080 + \\DELETE FROM app_passwords 1081 + \\WHERE did = ? AND name = ? 1082 + , .{ did, name }); 1083 + } 1084 + 967 1085 pub fn sessionTokenIsActive(did: []const u8, jti: []const u8, scope: []const u8) !bool { 968 1086 db_mutex.lockUncancelable(store_io); 969 1087 defer db_mutex.unlock(store_io); ··· 2223 2341 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key BLOB") catch {}; 2224 2342 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN login_hint TEXT") catch {}; 2225 2343 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN response_mode TEXT NOT NULL DEFAULT 'query'") catch {}; 2344 + conn.execNoArgs("ALTER TABLE session_tokens ADD COLUMN app_password_name TEXT") catch {}; 2226 2345 try migrateBlobTable(); 2227 2346 try migrateAppPreferencesTable(); 2228 2347 inline for (post_schema_statements) |sql| try conn.execNoArgs(sql); ··· 3080 3199 }; 3081 3200 } 3082 3201 3202 + fn appPasswordFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !AppPassword { 3203 + return .{ 3204 + .name = try allocator.dupe(u8, row.text(0)), 3205 + .password_hash = try allocator.dupe(u8, row.text(1)), 3206 + .created_at = row.int(2), 3207 + .privileged = row.int(3) != 0, 3208 + .scopes = if (row.nullableText(4)) |text| try allocator.dupe(u8, text) else null, 3209 + .created_by_controller_did = if (row.nullableText(5)) |text| try allocator.dupe(u8, text) else null, 3210 + }; 3211 + } 3212 + 3083 3213 fn stringifyValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 3084 3214 var out: std.Io.Writer.Allocating = .init(allocator); 3085 3215 defer out.deinit(); ··· 3685 3815 \\ refresh_expires_at INTEGER NOT NULL, 3686 3816 \\ auth_method TEXT NOT NULL, 3687 3817 \\ controller_did TEXT, 3818 + \\ app_password_name TEXT, 3688 3819 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 3689 3820 \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), 3690 3821 \\ last_used_at INTEGER, ··· 3695 3826 "CREATE INDEX IF NOT EXISTS session_tokens_access_jti_idx ON session_tokens (access_jti)", 3696 3827 "CREATE INDEX IF NOT EXISTS session_tokens_refresh_jti_idx ON session_tokens (refresh_jti)", 3697 3828 "CREATE INDEX IF NOT EXISTS session_tokens_controller_idx ON session_tokens (controller_did) WHERE controller_did IS NOT NULL", 3829 + "CREATE INDEX IF NOT EXISTS session_tokens_app_password_idx ON session_tokens (did, app_password_name) WHERE app_password_name IS NOT NULL", 3830 + \\CREATE TABLE IF NOT EXISTS app_passwords ( 3831 + \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 3832 + \\ name TEXT NOT NULL, 3833 + \\ password_hash TEXT NOT NULL, 3834 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 3835 + \\ privileged INTEGER NOT NULL DEFAULT 0, 3836 + \\ scopes TEXT, 3837 + \\ created_by_controller_did TEXT, 3838 + \\ PRIMARY KEY (did, name) 3839 + \\) 3840 + , 3841 + "CREATE INDEX IF NOT EXISTS app_passwords_did_created_idx ON app_passwords (did, created_at DESC)", 3698 3842 \\CREATE TABLE IF NOT EXISTS account_audit_log ( 3699 3843 \\ id TEXT PRIMARY KEY, 3700 3844 \\ subject_did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, ··· 4073 4217 4102444800, 4074 4218 "password", 4075 4219 null, 4220 + null, 4076 4221 ); 4077 4222 try std.testing.expect(std.mem.startsWith(u8, session_id, "ses-")); 4078 4223 try std.testing.expect(try sessionTokenIsActive(account.did, "access-1", "com.atproto.access")); ··· 4086 4231 try revokeSessionToken(account.did, "refresh-2"); 4087 4232 try std.testing.expect(!try sessionTokenIsActive(account.did, "access-2", "com.atproto.access")); 4088 4233 try std.testing.expect(!try sessionTokenIsActive(account.did, "refresh-2", "com.atproto.refresh")); 4234 + } 4235 + 4236 + test "app passwords are durable matchable and revoke their sessions" { 4237 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 4238 + defer arena.deinit(); 4239 + const allocator = arena.allocator(); 4240 + 4241 + try init(std.Options.debug_io, ":memory:"); 4242 + defer close(); 4243 + 4244 + const account = try createAccount( 4245 + allocator, 4246 + "app-pass.test", 4247 + "app-pass@test.com", 4248 + "password", 4249 + "did:plc:apppass", 4250 + true, 4251 + ); 4252 + const salt: [16]u8 = .{0} ** 16; 4253 + const password_hash = try auth.hashPassword(allocator, "aaaa-bbbb-cccc-dddd", salt); 4254 + const created = try createAppPassword( 4255 + allocator, 4256 + account.did, 4257 + "device one", 4258 + password_hash, 4259 + false, 4260 + "transition:generic", 4261 + null, 4262 + ); 4263 + try std.testing.expectEqualStrings("device one", created.name); 4264 + try std.testing.expectEqual(false, created.privileged); 4265 + 4266 + const listed = try listAppPasswords(allocator, account.did); 4267 + try std.testing.expectEqual(@as(usize, 1), listed.len); 4268 + try std.testing.expectEqualStrings("device one", listed[0].name); 4269 + try std.testing.expectEqualStrings("transition:generic", listed[0].scopes.?); 4270 + 4271 + const matched = (try findMatchingAppPassword(allocator, account.did, "aaaa-bbbb-cccc-dddd")) orelse return error.MissingRecord; 4272 + try std.testing.expectEqualStrings("device one", matched.name); 4273 + try std.testing.expect(try findMatchingAppPassword(allocator, account.did, "wrong-password") == null); 4274 + 4275 + _ = try createSessionTokenRow( 4276 + allocator, 4277 + account.did, 4278 + "app-access-1", 4279 + "app-refresh-1", 4280 + 4102444800, 4281 + 4102444800, 4282 + "app_password", 4283 + null, 4284 + "device one", 4285 + ); 4286 + const method = (try sessionAuthMethod(allocator, account.did, "app-access-1")) orelse return error.MissingRecord; 4287 + try std.testing.expectEqualStrings("app_password", method); 4288 + 4289 + try revokeAppPassword(account.did, "device one"); 4290 + try std.testing.expect(try getAppPasswordByName(allocator, account.did, "device one") == null); 4291 + try std.testing.expect(try sessionAuthMethod(allocator, account.did, "app-access-1") == null); 4292 + try std.testing.expect(!try sessionTokenIsActive(account.did, "app-access-1", "com.atproto.access")); 4089 4293 } 4090 4294 4091 4295 test "account audit log stores subject actor and controller fields" {