atproto pds in zig
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392const std = @import("std");const auth = @import("../auth/tokens.zig");const clock = @import("../core/clock.zig");const config = @import("../core/config.zig");const log = @import("../core/log.zig");const mail = @import("../core/mail.zig");const plc = @import("plc.zig");const sync = @import("sync.zig");const http_api = @import("../http/api.zig");const email_tokens = @import("../internal/email_tokens.zig");const handles = @import("../internal/handles.zig");const scopes = @import("../internal/scopes.zig");const store = @import("../storage/store.zig");const zat = @import("zat");
const http = std.http;
pub fn describeServer(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const domains = try jsonStringArray(arena.allocator(), config.handleDomains()); const body = try std.fmt.allocPrint( arena.allocator(), "{{\"did\":{f},\"availableUserDomains\":{s},\"inviteCodeRequired\":{}}}", .{ std.json.fmt(config.serverDid(), .{}), domains, config.inviteRequired() }, ); return http_api.json(request, .ok, body);}
pub fn didJson(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const body = try std.fmt.allocPrint( arena.allocator(), "{{\"@context\":[\"https://www.w3.org/ns/did/v1\"],\"id\":{f},\"service\":[{{\"id\":\"#atproto_pds\",\"type\":\"AtprotoPersonalDataServer\",\"serviceEndpoint\":{f}}}]}}", .{ std.json.fmt(config.serverDid(), .{}), std.json.fmt(config.publicUrl(), .{}) }, ); return http_api.json(request, .ok, body);}
pub fn atprotoDid(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const host = requestHost(request) orelse { return plain(request, .not_found, "User not found"); }; const handle = handles.normalize(allocator, stripPort(host)) catch { return plain(request, .not_found, "User not found"); }; if (!handles.isHosted(handle, config.handleDomains())) { return plain(request, .not_found, "User not found"); } const account = store.findActiveAccount(allocator, handle) catch null orelse { return plain(request, .not_found, "User not found"); }; return plain(request, .ok, account.did);}
pub fn reserveSigningKey(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); const did = if (body.len == 0) null else did: { const parsed = try http_api.parseJsonBody(request, allocator, body); break :did zat.json.getString(parsed.value, "did"); }; if (did) |value| { if (zat.Did.parse(value) == null) { return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); } } const reserved = try store.reserveSigningKey(allocator, did); const body_out = try std.fmt.allocPrint(allocator, "{{\"signingKey\":{f}}}", .{std.json.fmt(reserved.signing_key, .{})}); return http_api.json(request, .ok, body_out);}
pub fn createAccount(request: *http_api.Request) !void { const authorization = http_api.headerValue(request, "authorization");
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const raw_handle = zat.json.getString(parsed.value, "handle") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); }; const handle = handles.normalize(allocator, raw_handle) catch { return http_api.xrpcError(request, .bad_request, "InvalidHandle", "invalid handle"); }; const email = zat.json.getString(parsed.value, "email") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email"); }; const password = zat.json.getString(parsed.value, "password") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing password"); }; if (!isValidEmail(email)) { return http_api.xrpcError(request, .bad_request, "InvalidEmail", "invalid email"); } const invite_code = zat.json.getString(parsed.value, "inviteCode"); if (invite_code) |code| { if (!(store.inviteCodeIsAvailable(code) catch false)) { return http_api.xrpcError(request, .bad_request, "InvalidInviteCode", "Provided invite code not available"); } } else if (config.inviteRequired()) { return http_api.xrpcError(request, .bad_request, "InvalidInviteCode", "No invite code provided"); }
const existing_did = zat.json.getString(parsed.value, "did"); if (handles.isHosted(handle, config.handleDomains())) { handles.validateHosted(handle, config.handleDomains()) catch |err| { return http_api.xrpcError(request, .bad_request, "InvalidHandle", switch (err) { error.ReservedHandle => "reserved hosted handle", else => "invalid hosted handle", }); }; } else if (existing_did) |did| { var resolver = zat.HandleResolver.init(store.currentIo(), allocator); defer resolver.deinit(); const resolved_did = resolver.resolve(zat.Handle.parse(handle).?) catch { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); }; if (!std.mem.eql(u8, resolved_did, did)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); } } else { return http_api.xrpcError(request, .bad_request, "UnsupportedDomain", "not a supported handle domain"); } const plc_op = jsonObjectField(parsed.value, "plcOp"); const signing_key_input = zat.json.getString(parsed.value, "signingKey"); const account = if (existing_did) |did| account: { if (zat.Did.parse(did) == null) { log.debug("xrpc createAccount rejected invalid_did did={s} handle={s}\n", .{ did, handle }); return http_api.xrpcError(request, .bad_request, "InvalidDid", "invalid did"); } log.debug("xrpc createAccount migration attempt did={s} handle={s}\n", .{ did, handle }); const signing_key = if (plc_op) |operation| key: { const expected_key = signing_key_input orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "signingKey is required when plcOp is provided"); try validateCreateAccountPlcOperation(request, allocator, handle, operation, expected_key); const reserved_key = store.consumeReservedSigningKey(expected_key, did) catch |err| switch (err) { error.MissingReservedSigningKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "reserved signing key does not exist"), else => return err, }; break :key reserved_key; } else key: { try verifyCreateAccountServiceAuth(request, allocator, authorization, did); break :key try store.generateAccountSigningKey(); }; break :account store.createAccountWithSigningKeyAndInvite(allocator, handle, email, password, did, plc_op != null, signing_key, invite_code) catch |err| switch (err) { error.InvalidInviteCode => return http_api.xrpcError(request, .bad_request, "InvalidInviteCode", "Provided invite code not available"), else => { log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ did, handle }); return http_api.xrpcError(request, .bad_request, "InvalidRequest", "account already exists"); }, }; } else account: { log.debug("xrpc createAccount genesis attempt handle={s}\n", .{handle}); const signing_key = store.generateAccountSigningKey() catch { log.err("xrpc createAccount failed signing_key handle={s}\n", .{handle}); return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to generate account signing key"); }; const keypair = zat.Keypair.fromSecretKey(.secp256k1, signing_key) catch { log.err("xrpc createAccount failed signing_key_parse handle={s}\n", .{handle}); return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to prepare account signing key"); }; var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), }; const operation = plc.createGenesisOperation(allocator, handle, &keypair, &rotation_keypair) catch { log.err("xrpc createAccount failed plc_operation handle={s}\n", .{handle}); return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "failed to create DID operation"); }; plc.submitOperation(allocator, operation.did, operation.json) catch { log.err("xrpc createAccount failed plc_submit did={s} handle={s}\n", .{ operation.did, handle }); return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "PLC directory rejected account DID operation"); }; break :account store.createAccountWithSigningKeyAndInvite(allocator, handle, email, password, operation.did, true, signing_key, invite_code) catch |err| switch (err) { error.InvalidInviteCode => return http_api.xrpcError(request, .bad_request, "InvalidInviteCode", "Provided invite code not available"), else => { log.debug("xrpc createAccount rejected account_exists_or_store_error did={s} handle={s}\n", .{ operation.did, handle }); return http_api.xrpcError(request, .bad_request, "InvalidRequest", "account already exists"); }, }; }; log.debug("xrpc createAccount ok did={s} handle={s}\n", .{ account.did, account.handle });
const issued = try issueSessionTokens(allocator, account, "account_create", null); const body_out = try sessionJson(allocator, account, issued.access.token, issued.refresh.token); try http_api.json(request, .ok, body_out);}
pub fn createInviteCode(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); requireAdminToken(request) catch return; var body_buf: [4096]u8 = undefined; const body = try http_api.readBody(request, &body_buf); const parsed = try http_api.parseJsonBody(request, allocator, body); const use_count = jsonInt(parsed.value, "useCount") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing useCount"); }; const for_account = zat.json.getString(parsed.value, "forAccount") orelse "admin"; const code = store.createInviteCode(allocator, config.publicUrl(), use_count, for_account, "admin") catch |err| switch (err) { error.InvalidUseCount => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "useCount must be greater than zero"), else => return err, }; const body_out = try std.fmt.allocPrint(allocator, "{{\"code\":{f}}}", .{std.json.fmt(code, .{})}); return http_api.json(request, .ok, body_out);}
pub fn createInviteCodes(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); requireAdminToken(request) catch return; var body_buf: [8192]u8 = undefined; const body = try http_api.readBody(request, &body_buf); const parsed = try http_api.parseJsonBody(request, allocator, body); const use_count = jsonInt(parsed.value, "useCount") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing useCount"); }; const code_count = jsonInt(parsed.value, "codeCount") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing codeCount"); };
var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"codes\":["); if (jsonStringArrayValue(allocator, parsed.value, "forAccounts") catch |err| switch (err) { error.InvalidJsonStringArray => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "forAccounts must be an array of strings"), else => return err, }) |accounts| { if (accounts.len == 0) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "forAccounts must not be empty"); } for (accounts, 0..) |for_account, idx| { if (idx != 0) try out.writer.writeByte(','); writeInviteCodeGroup(allocator, &out.writer, for_account, code_count, use_count, "admin") catch |err| switch (err) { error.InvalidInviteInput => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "codeCount and useCount must be greater than zero"), else => return err, }; } } else { writeInviteCodeGroup(allocator, &out.writer, "admin", code_count, use_count, "admin") catch |err| switch (err) { error.InvalidInviteInput => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "codeCount and useCount must be greater than zero"), else => return err, }; } try out.writer.writeAll("]}"); return http_api.json(request, .ok, try out.toOwnedSlice());}
pub fn getAccountInviteCodes(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const account = requireAccount(request, allocator) catch return; var include_buf: [8]u8 = undefined; const include_used = if (http_api.queryParam(request.url.raw, "includeUsed", &include_buf)) |value| std.ascii.eqlIgnoreCase(value, "true") else true; const codes = try store.getAccountInviteCodes(allocator, account.did, include_used);
var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"codes\":["); for (codes, 0..) |code, idx| { if (idx != 0) try out.writer.writeByte(','); try writeInviteCodeJson(allocator, &out.writer, code); } try out.writer.writeAll("]}"); return http_api.json(request, .ok, try out.toOwnedSlice());}
pub fn listAppPasswords(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requirePasswordSession(request, allocator, auth_ctx.account.did); const passwords = try store.listAppPasswords(allocator, auth_ctx.account.did); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"passwords\":["); for (passwords, 0..) |password, idx| { if (idx != 0) try out.writer.writeByte(','); const created_at = try isoTimestamp(allocator, password.created_at); try out.writer.print( "{{\"name\":{f},\"createdAt\":{f},\"privileged\":{}}}", .{ std.json.fmt(password.name, .{}), std.json.fmt(created_at, .{}), password.privileged }, ); } try out.writer.writeAll("]}"); return http_api.json(request, .ok, try out.toOwnedSlice());}
pub fn listSessions(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requirePasswordSession(request, allocator, auth_ctx.account.did); const options = sessionListOptions(request); const sessions = try store.listSessionsForAccount(allocator, auth_ctx.account.did, options.active_only, options.limit); const grants = try store.listOAuthGrantsForAccount(allocator, auth_ctx.account.did, options.active_only, options.limit); const body = try sessionsJson(allocator, sessions, grants); return http_api.json(request, .ok, body);}
pub fn adminListSessions(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); requireAdminToken(request) catch return; const options = sessionListOptions(request); const sessions = try store.listSessionsForAllAccounts(allocator, options.active_only, options.limit); const grants = try store.listOAuthGrantsForAllAccounts(allocator, options.active_only, options.limit); const body = try sessionsJson(allocator, sessions, grants); return http_api.json(request, .ok, body);}
pub fn createAppPassword(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requirePasswordSession(request, allocator, auth_ctx.account.did);
const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const raw_name = zat.json.getString(parsed.value, "name") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); }; const name = std.mem.trim(u8, raw_name, " \t\r\n"); if (name.len == 0) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); } if (try store.getAppPasswordByName(allocator, auth_ctx.account.did, name) != null) { return http_api.xrpcError(request, .bad_request, "DuplicateAppPassword", "An app password with this name already exists"); } const privileged = jsonBool(parsed.value, "privileged") orelse false; const app_password = try generateAppPassword(allocator); var salt: [16]u8 = undefined; store.randomBytes(&salt); const password_hash = try auth.hashPassword(allocator, app_password, salt); const app_password_scopes: []const u8 = if (privileged) "transition:generic transition:chat.bsky" else "transition:generic"; const row = try store.createAppPassword(allocator, auth_ctx.account.did, name, password_hash, privileged, app_password_scopes, null); try store.recordAuditEvent(auth_ctx.account.did, auth_ctx.account.did, null, "app_password_created", "{}"); const created_at = try isoTimestamp(allocator, row.created_at); const body_out = try std.fmt.allocPrint( allocator, "{{\"name\":{f},\"password\":{f},\"createdAt\":{f},\"privileged\":{}}}", .{ std.json.fmt(row.name, .{}), std.json.fmt(app_password, .{}), std.json.fmt(created_at, .{}), row.privileged }, ); return http_api.json(request, .ok, body_out);}
pub fn revokeAppPassword(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requirePasswordSession(request, allocator, auth_ctx.account.did);
const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const raw_name = zat.json.getString(parsed.value, "name") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); }; const name = std.mem.trim(u8, raw_name, " \t\r\n"); if (name.len == 0) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "name is required"); } try store.revokeAppPassword(auth_ctx.account.did, name); try store.recordAuditEvent(auth_ctx.account.did, auth_ctx.account.did, null, "app_password_revoked", "{}"); return http_api.json(request, .ok, "{}");}
fn jsonStringArray(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeByte('['); var parts = std.mem.splitScalar(u8, raw, ','); var index: usize = 0; while (parts.next()) |part| { const trimmed = std.mem.trim(u8, part, " \t\r\n"); if (trimmed.len == 0) continue; if (index != 0) try out.writer.writeByte(','); try out.writer.print("{f}", .{std.json.fmt(trimmed, .{})}); index += 1; } try out.writer.writeByte(']'); return out.toOwnedSlice();}
fn jsonStringArrayValue(allocator: std.mem.Allocator, value: std.json.Value, key: []const u8) !?[][]const u8 { if (value != .object) return null; const raw = value.object.get(key) orelse return null; if (raw != .array) return error.InvalidJsonStringArray; var out: std.ArrayList([]const u8) = .empty; for (raw.array.items) |item| { if (item != .string) return error.InvalidJsonStringArray; try out.append(allocator, item.string); } return try out.toOwnedSlice(allocator);}
fn jsonInt(value: std.json.Value, key: []const u8) ?i64 { if (value != .object) return null; const raw = value.object.get(key) orelse return null; return switch (raw) { .integer => |n| n, else => null, };}
fn jsonBool(value: std.json.Value, key: []const u8) ?bool { if (value != .object) return null; const raw = value.object.get(key) orelse return null; return switch (raw) { .bool => |n| n, else => null, };}
fn jsonObjectField(value: std.json.Value, key: []const u8) ?std.json.Value { return switch (value) { .object => |object| object.get(key), else => null, };}
fn validateCreateAccountPlcOperation( request: *http_api.Request, allocator: std.mem.Allocator, handle: []const u8, operation: std.json.Value, signing_did_key: []const u8,) !void { const object = switch (operation) { .object => |object| object, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid plc operation"), }; if (!jsonObjectStringEquals(operation, "type", "plc_operation")) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "invalid plc operation type"); } if (!jsonObjectStringEquals(object.get("verificationMethods") orelse .null, "atproto", signing_did_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation does not use reserved signing key"); } var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), }; const server_rotation_key = try rotation_keypair.did(allocator); if (!jsonArrayContainsString(object.get("rotationKeys"), server_rotation_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation does not include server rotation key"); } if (jsonArrayContainsString(object.get("rotationKeys"), signing_did_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation must not include reserved signing key as a rotation key"); } const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{handle}); if (!jsonArrayFirstStringEquals(object.get("alsoKnownAs"), also_known_as)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation handle does not match account handle"); } const services = object.get("services") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation missing services"); }; const atproto_pds = switch (services) { .object => |services_object| services_object.get("atproto_pds") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation missing atproto_pds service"); }, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation services must be an object"), }; if (!jsonObjectStringEquals(atproto_pds, "type", "AtprotoPersonalDataServer") or !jsonObjectStringEquals(atproto_pds, "endpoint", config.publicUrl())) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PLC operation service does not match this PDS"); }}
fn jsonObjectStringEquals(value: std.json.Value, key: []const u8, expected: []const u8) bool { return switch (value) { .object => |object| switch (object.get(key) orelse return false) { .string => |string| std.mem.eql(u8, string, expected), else => false, }, else => false, };}
fn jsonArrayContainsString(value: ?std.json.Value, expected: []const u8) bool { const items = switch (value orelse return false) { .array => |array| array.items, else => return false, }; for (items) |item| switch (item) { .string => |string| if (std.mem.eql(u8, string, expected)) return true, else => {}, }; return false;}
fn jsonArrayFirstStringEquals(value: ?std.json.Value, expected: []const u8) bool { const items = switch (value orelse return false) { .array => |array| array.items, else => return false, }; if (items.len == 0) return false; return switch (items[0]) { .string => |string| std.mem.eql(u8, string, expected), else => false, };}
fn writeInviteCodeGroup(allocator: std.mem.Allocator, writer: anytype, for_account: []const u8, code_count: i64, use_count: i64, created_by: []const u8) !void { const codes = store.createInviteCodes(allocator, config.publicUrl(), code_count, use_count, for_account, created_by) catch |err| switch (err) { error.InvalidUseCount => return error.InvalidInviteInput, else => return err, }; try writer.print("{{\"account\":{f},\"codes\":[", .{std.json.fmt(for_account, .{})}); for (codes, 0..) |code, idx| { if (idx != 0) try writer.writeByte(','); try writer.print("{f}", .{std.json.fmt(code, .{})}); } try writer.writeAll("]}");}
fn writeInviteCodeJson(allocator: std.mem.Allocator, writer: anytype, code: store.InviteCode) !void { const created_at = try isoTimestamp(allocator, code.created_at); try writer.print( "{{\"code\":{f},\"available\":{d},\"disabled\":{},\"forAccount\":{f},\"createdBy\":{f},\"createdAt\":{f},\"uses\":[", .{ std.json.fmt(code.code, .{}), code.available, code.disabled, std.json.fmt(code.for_account, .{}), std.json.fmt(code.created_by, .{}), std.json.fmt(created_at, .{}), }, ); for (code.uses, 0..) |use, idx| { if (idx != 0) try writer.writeByte(','); const used_at = try isoTimestamp(allocator, use.used_at); try writer.print("{{\"usedBy\":{f},\"usedAt\":{f}}}", .{ std.json.fmt(use.used_by, .{}), std.json.fmt(used_at, .{}) }); } try writer.writeAll("]}");}
const SessionListOptions = struct { active_only: bool = true, limit: i64 = 100,};
fn sessionListOptions(request: *http_api.Request) SessionListOptions { var active_buf: [16]u8 = undefined; const active_only = if (http_api.queryParam(request.url.raw, "active", &active_buf)) |value| !(std.mem.eql(u8, value, "false") or std.mem.eql(u8, value, "0")) else true; var limit_buf: [16]u8 = undefined; const parsed_limit = if (http_api.queryParam(request.url.raw, "limit", &limit_buf)) |value| std.fmt.parseInt(i64, value, 10) catch 100 else 100; return .{ .active_only = active_only, .limit = std.math.clamp(parsed_limit, 1, 500) };}
fn sessionsJson(allocator: std.mem.Allocator, sessions: []const store.SessionInfo, grants: []const store.OAuthGrantInfo) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"sessions\":["); for (sessions, 0..) |session, idx| { if (idx != 0) try out.writer.writeByte(','); try writeSessionJson(allocator, &out.writer, session); } try out.writer.writeAll("],\"oauthGrants\":["); for (grants, 0..) |grant, idx| { if (idx != 0) try out.writer.writeByte(','); try writeOAuthGrantJson(allocator, &out.writer, grant); } try out.writer.writeAll("]}"); return out.toOwnedSlice();}
fn writeSessionJson(allocator: std.mem.Allocator, writer: anytype, session: store.SessionInfo) !void { const created_at = try isoTimestamp(allocator, session.created_at); const updated_at = try isoTimestamp(allocator, session.updated_at); const access_expires_at = try isoTimestamp(allocator, session.access_expires_at); const refresh_expires_at = try isoTimestamp(allocator, session.refresh_expires_at); try writer.print( "{{\"id\":{f},\"did\":{f},\"handle\":{f},\"authMethod\":{f},\"appPasswordName\":", .{ std.json.fmt(session.id, .{}), std.json.fmt(session.did, .{}), std.json.fmt(session.handle, .{}), std.json.fmt(session.auth_method, .{}), }, ); try writeOptionalString(writer, session.app_password_name); try writer.writeAll(",\"controllerDid\":"); try writeOptionalString(writer, session.controller_did); try writer.print( ",\"createdAt\":{f},\"updatedAt\":{f},\"lastUsedAt\":", .{ std.json.fmt(created_at, .{}), std.json.fmt(updated_at, .{}) }, ); try writeOptionalTimestamp(allocator, writer, session.last_used_at); try writer.print( ",\"accessExpiresAt\":{f},\"refreshExpiresAt\":{f},\"revokedAt\":", .{ std.json.fmt(access_expires_at, .{}), std.json.fmt(refresh_expires_at, .{}) }, ); try writeOptionalTimestamp(allocator, writer, session.revoked_at); try writer.print(",\"active\":{}}}", .{session.active});}
fn writeOAuthGrantJson(allocator: std.mem.Allocator, writer: anytype, grant: store.OAuthGrantInfo) !void { const created_at = try isoTimestamp(allocator, grant.created_at); const expires_at = try isoTimestamp(allocator, grant.expires_at); try writer.print( "{{\"did\":{f},\"handle\":{f},\"clientId\":{f},\"scope\":{f},\"createdAt\":{f},\"expiresAt\":{f},\"authMethod\":", .{ std.json.fmt(grant.did, .{}), std.json.fmt(grant.handle, .{}), std.json.fmt(grant.client_id, .{}), std.json.fmt(grant.scope, .{}), std.json.fmt(created_at, .{}), std.json.fmt(expires_at, .{}), }, ); try writeOptionalString(writer, grant.auth_method); try writer.writeAll(",\"revokedAt\":"); try writeOptionalTimestamp(allocator, writer, grant.revoked_at); try writer.print(",\"active\":{}}}", .{grant.active});}
fn writeOptionalString(writer: anytype, value: ?[]const u8) !void { if (value) |text| { try writer.print("{f}", .{std.json.fmt(text, .{})}); } else { try writer.writeAll("null"); }}
fn writeOptionalTimestamp(allocator: std.mem.Allocator, writer: anytype, value: ?i64) !void { if (value) |seconds| { const text = try isoTimestamp(allocator, seconds); try writer.print("{f}", .{std.json.fmt(text, .{})}); } else { try writer.writeAll("null"); }}
fn isoTimestamp(allocator: std.mem.Allocator, seconds: i64) ![]const u8 { const safe_seconds: u64 = @intCast(@max(seconds, 0)); const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = safe_seconds }; const year_day = epoch_seconds.getEpochDay().calculateYearDay(); const month_day = year_day.calculateMonthDay(); const day_seconds = epoch_seconds.getDaySeconds(); return std.fmt.allocPrint( allocator, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.000Z", .{ year_day.year, month_day.month.numeric(), month_day.day_index + 1, day_seconds.getHoursIntoDay(), day_seconds.getMinutesIntoHour(), day_seconds.getSecondsIntoMinute(), }, );}
fn requestHost(request: *const http_api.Request) ?[]const u8 { return http_api.headerValue(request, "host") orelse http_api.headerValue(request, ":authority");}
fn stripPort(host: []const u8) []const u8 { if (std.mem.startsWith(u8, host, "[")) return host; if (std.mem.lastIndexOfScalar(u8, host, ':')) |idx| return host[0..idx]; return host;}
fn plain(request: *http_api.Request, status: http.Status, body: []const u8) !void { try http_api.respond(request, status, body, &plain_headers);}
const plain_headers = [_]http.Header{ .{ .name = "content-type", .value = "text/plain; charset=utf-8" }, .{ .name = "access-control-allow-origin", .value = "*" }, .{ .name = "connection", .value = "close" },};
fn verifyCreateAccountServiceAuth(request: *http_api.Request, allocator: std.mem.Allocator, maybe_authorization: ?[]const u8, did: []const u8) !void { const raw_header = maybe_authorization orelse { log.debug("xrpc createAccount service_auth missing did={s}\n", .{did}); return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did"); }; if (!std.ascii.startsWithIgnoreCase(raw_header, "bearer ")) { log.debug("xrpc createAccount service_auth malformed did={s}\n", .{did}); return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "service auth required to migrate an existing did"); } const token = std.mem.trim(u8, raw_header["bearer ".len..], " \t"); var jwt = zat.Jwt.parse(allocator, token) catch { log.debug("xrpc createAccount service_auth invalid_jwt did={s}\n", .{did}); return http_api.xrpcError(request, .unauthorized, "InvalidToken", "invalid service auth token"); }; defer jwt.deinit();
const issuer_did = issuerDid(jwt.payload.iss); if (!std.mem.eql(u8, issuer_did, did)) { log.debug("xrpc createAccount service_auth issuer_mismatch did={s} issuer={s}\n", .{ did, issuer_did }); return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt issuer does not match did"); } if (!std.mem.eql(u8, jwt.payload.aud, config.serverDid())) { log.debug("xrpc createAccount service_auth aud_mismatch did={s} aud={s} expected={s}\n", .{ did, jwt.payload.aud, config.serverDid() }); return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt audience does not match service did"); } if (jwt.isExpired(store.currentIo())) { log.debug("xrpc createAccount service_auth expired did={s}\n", .{did}); return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"); } if (jwt.payload.lxm) |lxm| { if (!std.mem.eql(u8, lxm, "com.atproto.server.createAccount") and !std.mem.eql(u8, lxm, "*")) { log.debug("xrpc createAccount service_auth lxm_mismatch did={s} lxm={s}\n", .{ did, lxm }); return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm does not match com.atproto.server.createAccount"); } } else { log.debug("xrpc createAccount service_auth missing_lxm did={s}\n", .{did}); return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt lxm is required"); }
var resolver = zat.DidResolver.init(store.currentIo(), allocator); defer resolver.deinit(); var doc = resolver.resolve(zat.Did.parse(issuer_did).?) catch { log.debug("xrpc createAccount service_auth did_resolution_failed did={s}\n", .{did}); return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve jwt issuer did"); }; defer doc.deinit(); const signing_key = doc.signingKey() orelse { log.debug("xrpc createAccount service_auth missing_signing_key did={s}\n", .{did}); return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "missing signing key in issuer did doc"); }; jwt.verify(signing_key.public_key_multibase) catch { log.debug("xrpc createAccount service_auth signature_mismatch did={s}\n", .{did}); return http_api.xrpcError(request, .unauthorized, "InvalidToken", "jwt signature does not match jwt issuer"); }; log.debug("xrpc createAccount service_auth ok did={s}\n", .{did});}
fn issuerDid(iss: []const u8) []const u8 { return if (std.mem.indexOfScalar(u8, iss, '#')) |idx| iss[0..idx] else iss;}
pub fn createSession(request: *http_api.Request) !void { var body_buf: [4096]u8 = undefined; const body = try http_api.readBody(request, &body_buf);
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit();
const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), body, .{}) catch { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected JSON body with identifier and password"); }; const identifier = zat.json.getString(parsed.value, "identifier") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing identifier"); }; const password = zat.json.getString(parsed.value, "password") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing password"); };
log.debug("xrpc createSession attempt identifier={s}\n", .{identifier}); const account = (store.findAccount(arena.allocator(), identifier) catch null) orelse { log.debug("xrpc createSession rejected account_not_found identifier={s}\n", .{identifier}); return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); }; const account_status = try store.accountStatus(account.did); switch (account_status) { .active, .deactivated => {}, .takendown => return http_api.xrpcError(request, .unauthorized, "AccountTakedown", "Account has been taken down"), .suspended => return http_api.xrpcError(request, .unauthorized, "AccountSuspended", "Account is suspended"), .deleted => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"), } const primary_password_matches = auth.passwordMatches(account, password); const app_password = if (primary_password_matches) null else try store.findMatchingAppPassword(arena.allocator(), account.did, password); if (!primary_password_matches and app_password == null) { log.debug("xrpc createSession rejected password_mismatch identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Invalid identifier or password"); } log.debug("xrpc createSession ok identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle });
const auth_method: []const u8 = if (app_password) |value| if (value.privileged) "app_password_privileged" else "app_password" else "password"; const issued = try issueSessionTokens(arena.allocator(), account, auth_method, if (app_password) |value| value.name else null); const info = store.getEmailInfo(arena.allocator(), account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; const status = try store.accountStatus(account.did); const body_out = try sessionJsonWithInfo(arena.allocator(), account, info.email, info.email_confirmed, status, issued.access.token, issued.refresh.token); try http_api.json(request, .ok, body_out);}
pub fn refreshSession(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const auth_ctx = http_api.requireBearerAccountWithScope(request, allocator, "com.atproto.refresh") catch |err| switch (err) { error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), }; const account = auth_ctx.account; const old_claims = currentBearerClaims(request, allocator) catch { return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); }; defer allocator.free(old_claims.did); defer allocator.free(old_claims.scope); defer allocator.free(old_claims.jti); defer if (old_claims.cnf_jkt) |jkt| allocator.free(jkt); const auth_method = try store.sessionAuthMethod(allocator, account.did, old_claims.jti) orelse { return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); }; const issued = try auth.createSessionPairWithAccessScope(allocator, account, accessScopeForAuthMethod(auth_method)); try store.rotateSessionToken(account.did, old_claims.jti, issued.access.jti, issued.refresh.jti, issued.access.exp, issued.refresh.exp); try store.recordAuditEvent(account.did, account.did, null, "session_refreshed", "{}"); const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; const status = try store.accountStatus(account.did); const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, status, issued.access.token, issued.refresh.token); try http_api.json(request, .ok, body_out);}
pub fn getSession(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const account = http_api.requireBearerAccount(request, allocator) catch |err| switch (err) { error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), };
const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; const status = try store.accountStatus(account.did); const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, status, null, null); return http_api.json(request, .ok, body_out);}
fn sessionJson( allocator: std.mem.Allocator, account: auth.Account, access: []const u8, refresh: []const u8,) ![]const u8 { const info = store.getEmailInfo(allocator, account.did) orelse return error.AccountNotFound; return sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, try store.accountStatus(account.did), access, refresh);}
fn issueSessionTokens(allocator: std.mem.Allocator, account: auth.Account, auth_method: []const u8, app_password_name: ?[]const u8) !auth.SessionPair { const issued = try auth.createSessionPairWithAccessScope(allocator, account, accessScopeForAuthMethod(auth_method)); _ = try store.createSessionTokenRow( allocator, account.did, issued.access.jti, issued.refresh.jti, issued.access.exp, issued.refresh.exp, auth_method, null, app_password_name, ); try store.recordAuditEvent(account.did, account.did, null, "session_created", "{}"); return issued;}
fn accessScopeForAuthMethod(auth_method: []const u8) []const u8 { if (std.mem.eql(u8, auth_method, "app_password_privileged")) return "com.atproto.appPassPrivileged"; if (std.mem.eql(u8, auth_method, "app_password")) return "com.atproto.appPass"; return "com.atproto.access";}
fn currentBearerClaims(request: *http_api.Request, allocator: std.mem.Allocator) !auth.TokenClaims { const auth_header = http_api.headerValue(request, "authorization") orelse return error.AuthRequired; if (!std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) return error.AuthRequired; const token = std.mem.trim(u8, auth_header["bearer ".len..], " \t"); return auth.claimsFromSessionJwt(allocator, token) orelse error.InvalidToken;}
fn sessionJsonWithInfo( allocator: std.mem.Allocator, account: auth.Account, email: []const u8, email_confirmed: bool, status: store.AccountStatus, access: ?[]const u8, refresh: ?[]const u8,) ![]const u8 { const did_doc = try sessionDidDocJson(allocator, account); const parsed_did_doc = try std.json.parseFromSlice(std.json.Value, allocator, did_doc, .{});
const Response = struct { accessJwt: ?[]const u8 = null, refreshJwt: ?[]const u8 = null, did: []const u8, didDoc: std.json.Value, handle: []const u8, email: []const u8, emailConfirmed: bool, active: bool, status: ?[]const u8 = null, }; return std.json.Stringify.valueAlloc(allocator, Response{ .accessJwt = access, .refreshJwt = refresh, .did = account.did, .didDoc = parsed_did_doc.value, .handle = account.handle, .email = email, .emailConfirmed = email_confirmed, .active = status.isActive(), .status = if (status.isActive()) null else status.asString(), }, .{ .emit_null_optional_fields = false });}
fn sessionDidDocJson(allocator: std.mem.Allocator, account: auth.Account) ![]const u8 { var keypair = try store.signingKeypair(account.did); const signing_did_key = try keypair.did(allocator); const also_known_as = try std.fmt.allocPrint(allocator, "at://{s}", .{account.handle}); return std.fmt.allocPrint( allocator, "{{\"@context\":[\"https://www.w3.org/ns/did/v1\"],\"id\":{f},\"alsoKnownAs\":[{f}],\"verificationMethod\":[{{\"id\":\"#atproto\",\"type\":\"Multikey\",\"controller\":{f},\"publicKeyMultibase\":{f}}}],\"service\":[{{\"id\":\"#atproto_pds\",\"type\":\"AtprotoPersonalDataServer\",\"serviceEndpoint\":{f}}}]}}", .{ std.json.fmt(account.did, .{}), std.json.fmt(also_known_as, .{}), std.json.fmt(account.did, .{}), std.json.fmt(signing_did_key["did:key:".len..], .{}), std.json.fmt(config.publicUrl(), .{}), }, );}
pub fn activateAccount(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const account = requireAccount(request, allocator) catch return; store.setAccountActive(account.did, true) catch |err| switch (err) { error.InvalidAccountStatus => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account cannot be activated while takendown, suspended, or deleted"), else => return err, }; try store.sequenceAccountEvent(allocator, account.did, .active); try store.sequenceIdentityEvent(allocator, account.did, account.handle); try store.sequenceSyncEvent(allocator, account.did); sync.notifyCrawlers(true); return http_api.json(request, .ok, "{}");}
pub fn deactivateAccount(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const account = requireAccount(request, allocator) catch return; try store.setAccountActive(account.did, false); try store.sequenceAccountEvent(allocator, account.did, .deactivated); sync.notifyCrawlers(true); return http_api.json(request, .ok, "{}");}
pub fn updateSubjectStatus(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
requireAdminToken(request) catch return; var body_buf: [8192]u8 = undefined; const body = try http_api.readBody(request, &body_buf); const parsed = try http_api.parseJsonBody(request, allocator, body); const subject = switch (parsed.value) { .object => |object| object.get("subject") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing subject"); }, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), }; const did = switch (subject) { .object => |object| switch (object.get("did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing subject did")) { .string => |value| value, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid subject did"), }, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid subject"), }; if (zat.Did.parse(did) == null) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid subject did"); }
const takedown = objectValue(parsed.value, "takedown"); const deactivated = objectValue(parsed.value, "deactivated"); const takedown_applied = if (takedown) |value| valueBool(value, "applied") orelse false else false; const deactivated_applied = if (deactivated) |value| valueBool(value, "applied") else null; if (takedown_applied and deactivated_applied != null and deactivated_applied.? == false) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot activate and takedown an account at the same time"); }
if (takedown) |value| { const applied = valueBool(value, "applied") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing takedown.applied"); }; if (!applied) { try store.setAccountTakendown(did, false, null); } } if (!takedown_applied) { if (deactivated) |value| { const applied = valueBool(value, "applied") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing deactivated.applied"); }; store.setAccountActive(did, !applied) catch |err| switch (err) { error.InvalidAccountStatus => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account cannot be activated while takendown, suspended, or deleted"), else => return err, }; } } if (takedown) |value| { const applied = valueBool(value, "applied") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing takedown.applied"); }; if (applied) { const status_ref = zat.json.getString(value, "ref"); try store.setAccountTakendown(did, true, status_ref); } } if (takedown == null and deactivated == null) { _ = try store.accountStatus(did); }
const status = try store.accountStatus(did); try store.sequenceAccountEvent(allocator, did, status); sync.notifyCrawlers(true); const body_out = try std.fmt.allocPrint( allocator, "{{\"subject\":{{\"$type\":\"com.atproto.admin.defs#repoRef\",\"did\":{f}}},\"active\":{},\"status\":{f}}}", .{ std.json.fmt(did, .{}), status.isActive(), std.json.fmt(status.asString(), .{}) }, ); return http_api.json(request, .ok, body_out);}
pub fn getServiceAuth(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const auth_ctx = http_api.requireBearerAccess(request, allocator) catch |err| switch (err) { error.AuthRequired => return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), }; const account = auth_ctx.account;
var aud_buf: [256]u8 = undefined; const audience = http_api.queryParam(request.url.raw, "aud", &aud_buf) orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing aud"); }; if (!validServiceAuthAudience(audience)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid aud"); }
var lxm_buf: [256]u8 = undefined; const lxm = http_api.queryParam(request.url.raw, "lxm", &lxm_buf); if (lxm) |method| { if (zat.Nsid.parse(method) == null) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid lxm"); } if (serviceAuthProtectedMethod(method)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot request service auth for protected method"); } if (!scopes.rpcAllows(auth_ctx.oauth_scope, audience, method)) { return http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); } } else if (auth_ctx.oauth_scope != null and !scopes.hasFullAccess(auth_ctx.oauth_scope)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "OAuth tokens with granular scopes must specify an lxm parameter"); }
const requested_exp = try requestedServiceAuthExpiration(request, lxm != null);
var keypair = try store.signingKeypair(account.did); const token = try auth.createServiceJwtWithKeypair(allocator, account, audience, lxm, requested_exp, &keypair); const body_out = try std.fmt.allocPrint(allocator, "{{\"token\":\"{s}\"}}", .{token}); return http_api.json(request, .ok, body_out);}
fn validServiceAuthAudience(audience: []const u8) bool { if (zat.Did.parse(audience) != null) return true; const hash = std.mem.indexOfScalar(u8, audience, '#') orelse return false; if (hash == 0 or hash + 1 >= audience.len) return false; if (std.mem.indexOfScalarPos(u8, audience, hash + 1, '#') != null) return false; return zat.Did.parse(audience[0..hash]) != null;}
fn requestedServiceAuthExpiration(request: *http_api.Request, has_lxm: bool) !?i64 { var exp_buf: [32]u8 = undefined; const raw = http_api.queryParam(request.url.raw, "exp", &exp_buf) orelse return null; const exp = std.fmt.parseInt(i64, raw, 10) catch { try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid exp"); return error.HandledResponse; }; const now = unixNow(); const diff = exp - now; if (diff < 0) { try http_api.xrpcError(request, .bad_request, "BadExpiration", "expiration is in past"); return error.HandledResponse; } if (diff > 60 * 60) { try http_api.xrpcError(request, .bad_request, "BadExpiration", "cannot request a token with an expiration more than an hour in the future"); return error.HandledResponse; } if (!has_lxm and diff > 60) { try http_api.xrpcError(request, .bad_request, "BadExpiration", "cannot request a method-less token with an expiration more than a minute in the future"); return error.HandledResponse; } return exp;}
fn unixNow() i64 { return clock.now();}
fn serviceAuthProtectedMethod(method: []const u8) bool { const protected = [_][]const u8{ "com.atproto.admin.sendEmail", "com.atproto.identity.requestPlcOperationSignature", "com.atproto.identity.signPlcOperation", "com.atproto.identity.updateHandle", "com.atproto.server.activateAccount", "com.atproto.server.confirmEmail", "com.atproto.server.createAppPassword", "com.atproto.server.deactivateAccount", "com.atproto.server.getAccountInviteCodes", "com.atproto.server.getSession", "com.atproto.server.listAppPasswords", "com.atproto.server.requestAccountDelete", "com.atproto.server.requestEmailConfirmation", "com.atproto.server.requestEmailUpdate", "com.atproto.server.revokeAppPassword", "com.atproto.server.updateEmail", }; for (protected) |item| { if (std.ascii.eqlIgnoreCase(method, item)) return true; } return false;}
pub fn requestEmailConfirmation(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); const account = auth_ctx.account; const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; if (info.email_confirmed) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email already confirmed"); } var code_buf: [11]u8 = undefined; const code = email_tokens.makeCode(&code_buf); try store.setAuthCode(account.did, code, expiresInTenMinutes()); try sendCode(allocator, info.email, account.handle, "Confirm email", "email confirmation", code); return http_api.json(request, .ok, "{}");}
pub fn confirmEmail(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); const account = auth_ctx.account; var body_buf: [4096]u8 = undefined; const body = try http_api.readBody(request, &body_buf); const parsed = try http_api.parseJsonBody(request, allocator, body); const email = zat.json.getString(parsed.value, "email") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email"); }; const token = zat.json.getString(parsed.value, "token") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing token"); }; const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; if (!std.ascii.eqlIgnoreCase(info.email, email)) { return http_api.xrpcError(request, .bad_request, "InvalidEmail", "email does not match"); } switch (store.validateAuthCode(account.did, token, store.nowMs())) { .valid => try store.confirmEmail(account.did), .expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"), .invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"), } return http_api.json(request, .ok, "{}");}
pub fn requestEmailUpdate(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); const account = auth_ctx.account; const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; if (!info.email_confirmed) { return http_api.json(request, .ok, "{\"tokenRequired\":false}"); } var code_buf: [11]u8 = undefined; const code = email_tokens.makeCode(&code_buf); try store.setAuthCode(account.did, code, expiresInTenMinutes()); try sendCode(allocator, info.email, account.handle, "Update email", "email update", code); return http_api.json(request, .ok, "{\"tokenRequired\":true}");}
pub fn updateEmail(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccountAccess(request, allocator) catch return; try requireAccountScope(request, auth_ctx.oauth_scope, .email, .manage); const account = auth_ctx.account; var body_buf: [4096]u8 = undefined; const body = try http_api.readBody(request, &body_buf); const parsed = try http_api.parseJsonBody(request, allocator, body); const email = zat.json.getString(parsed.value, "email") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing email"); }; if (!isValidEmail(email)) { return http_api.xrpcError(request, .bad_request, "InvalidEmail", "invalid email"); } const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; if (info.email_confirmed) { const token = zat.json.getString(parsed.value, "token") orelse { return http_api.xrpcError(request, .bad_request, "TokenRequired", "confirmation token required"); }; switch (store.validateAuthCode(account.did, token, store.nowMs())) { .valid => {}, .expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"), .invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"), } } try store.updateEmail(account.did, email); return http_api.json(request, .ok, "{}");}
pub fn checkAccountStatus(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const account = requireAccount(request, allocator) catch return; const body = try store.writeAccountStatusJson(allocator, account.did); return http_api.json(request, .ok, body);}
fn requireAccount(request: *http_api.Request, allocator: std.mem.Allocator) !auth.Account { return (try requireAccountAccess(request, allocator)).account;}
fn requireAccountAccess(request: *http_api.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { return http_api.requireBearerAccess(request, allocator) catch |err| { switch (err) { error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), } return error.HandledResponse; };}
fn requireAccountScope(request: *http_api.Request, maybe_scope: ?[]const u8, attr: scopes.AccountAttr, action: scopes.AccountAction) !void { if (scopes.accountAllows(maybe_scope, attr, action)) return; try http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); return error.HandledResponse;}
fn requirePasswordSession(request: *http_api.Request, allocator: std.mem.Allocator, did: []const u8) !void { const claims = currentBearerClaims(request, allocator) catch { try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); return error.HandledResponse; }; defer allocator.free(claims.did); defer allocator.free(claims.scope); defer allocator.free(claims.jti); defer if (claims.cnf_jkt) |jkt| allocator.free(jkt); if (!std.mem.eql(u8, claims.did, did)) { try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); return error.HandledResponse; } const method = try store.sessionAuthMethod(allocator, did, claims.jti) orelse { try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); return error.HandledResponse; }; if (!std.mem.eql(u8, method, "password") and !std.mem.eql(u8, method, "account_create")) { try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); return error.HandledResponse; }}
fn requireAdminToken(request: *http_api.Request) !void { const expected = config.adminToken() orelse { try http_api.xrpcError(request, .forbidden, "AdminRequired", "Admin token not configured"); return error.HandledResponse; }; const auth_header = http_api.headerValue(request, "authorization") orelse { try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); return error.HandledResponse; }; if (!std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) { try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); return error.HandledResponse; } const provided = std.mem.trim(u8, auth_header["bearer ".len..], " \t"); if (!std.mem.eql(u8, provided, expected)) { try http_api.xrpcError(request, .forbidden, "AdminRequired", "Admin token required"); return error.HandledResponse; }}
fn objectValue(value: std.json.Value, key: []const u8) ?std.json.Value { return switch (value) { .object => |object| object.get(key), else => null, };}
fn valueBool(value: std.json.Value, key: []const u8) ?bool { return switch (value) { .object => |object| switch (object.get(key) orelse return null) { .bool => |boolean| boolean, else => null, }, else => null, };}
fn expiresInTenMinutes() i64 { return store.nowMs() + (10 * 60 * 1000);}
fn generateAppPassword(allocator: std.mem.Allocator) ![]const u8 { const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; var random: [16]u8 = undefined; store.randomBytes(&random); var out: [19]u8 = undefined; for (random, 0..) |byte, idx| { const write_idx = idx + @divTrunc(idx, 4); out[write_idx] = alphabet[byte & 31]; } out[4] = '-'; out[9] = '-'; out[14] = '-'; return allocator.dupe(u8, out[0..]);}
fn sendCode( allocator: std.mem.Allocator, email: []const u8, handle: []const u8, subject: []const u8, label: []const u8, code: []const u8,) !void { return mail.sendCode(store.currentIo(), allocator, email, handle, subject, label, code) catch { return error.EmailDeliveryFailed; };}
fn isValidEmail(email: []const u8) bool { const at = std.mem.indexOfScalar(u8, email, '@') orelse return false; if (at == 0 or at + 1 >= email.len) return false; return std.mem.indexOfScalar(u8, email[at + 1 ..], '.') != null;}