diff --git a/src/core/oauth.zig b/src/core/oauth.zig index cfd6377..79f427d 100644 --- a/src/core/oauth.zig +++ b/src/core/oauth.zig @@ -21,16 +21,13 @@ const session = @import("session.zig"); // collections (threads, posts, invites, join events). pub const scope = "atproto repo:* space:at.linji.space?authority=*&action=read&action=create&action=update&action=delete&collection=at.linji.post&collection=at.linji.thread&collection=at.linji.invite&collection=at.linji.join&manage=create&manage=update&manage=delete"; -/// External PDSes (bsky.social) reject unknown scopes, so the first leg -/// asks for the plain legacy scope. Identity proof only - the space -/// authority lives on our zds, which the bootstrap leg logs into with the -/// full scope above. -pub const external_scope = "atproto"; - -/// Full scope only when talking to the space host; anything else is an -/// external home PDS. -pub fn scopeFor(pds_url: []const u8, space_pds: []const u8) []const u8 { - return if (std.mem.eql(u8, pds_url, space_pds)) scope else external_scope; +/// The granular rpc permission bsky.social requires before it mints a +/// service-auth JWT (getServiceAuth) that lets the bootstrap adopt the DID +/// on the space host. The aud is the space host's DID. The legacy +/// `atproto` scope is composed at the call sites (PAR requests need it; +/// the client metadata must not declare it twice). +pub fn externalScope(allocator: std.mem.Allocator, space_did: []const u8) ![]u8 { + return std.fmt.allocPrint(allocator, "rpc:com.atproto.server.createAccount?aud={s}", .{space_did}); } const AsEndpoints = struct { @@ -195,21 +192,28 @@ pub const PendingLogin = struct { }; /// Serve this at {base_url}/client-metadata.json (stable client identity). +/// The metadata declares every scope this client may request, including the +/// rpc scope external PDSes check before minting service-auth JWTs. pub fn clientMetadata( allocator: std.mem.Allocator, store: *accounts.Store, io: std.Io, base_url: []const u8, + space_did: []const u8, ) ![]u8 { const client_kp = try clientKeypair(store, io); const client_id = try std.fmt.allocPrint(allocator, "{s}/client-metadata.json", .{base_url}); const redirect_uri = try std.fmt.allocPrint(allocator, "{s}/oauth/callback", .{base_url}); + const ext = try externalScope(allocator, space_did); + defer allocator.free(ext); + const metadata_scope = try std.fmt.allocPrint(allocator, "{s} {s}", .{ scope, ext }); + defer allocator.free(metadata_scope); return zat.oauth.clientMetadataJson(allocator, .{ .client_id = client_id, .client_name = "linji", .client_uri = client_id, .redirect_uris = &.{redirect_uri}, - .scope = scope, + .scope = metadata_scope, .keypair = &client_kp, .token_endpoint_auth_method = "private_key_jwt", }); @@ -261,12 +265,28 @@ pub fn beginLoginAt( const client_id = try std.fmt.allocPrint(allocator, "{s}/client-metadata.json", .{base_url}); const redirect_uri = try std.fmt.allocPrint(allocator, "{s}/oauth/callback", .{base_url}); + // External home PDS: request the rpc scope it demands for minting the + // service-auth JWT (see externalScope); the space host itself gets the + // full linji scope. + var ext_scope: ?[]u8 = null; + defer if (ext_scope) |s| allocator.free(s); + const requested_scope: []const u8 = if (std.mem.eql(u8, pds_url, space_pds)) + scope + else blk: { + const space_did = try describeServerDid(allocator, io, space_pds); + defer allocator.free(space_did); + const rpc_scope = try externalScope(allocator, space_did); + defer allocator.free(rpc_scope); + ext_scope = try std.fmt.allocPrint(allocator, "atproto {s}", .{rpc_scope}); + break :blk ext_scope.?; + }; + var par = try zat.oauth.sendParRequest(allocator, io, &transport, .{ .par_url = as.par, .authserver_issuer = as.issuer, .client_id = client_id, .redirect_uri = redirect_uri, - .scope = scopeFor(pds_url, space_pds), + .scope = requested_scope, .state = secrets.state, .pkce_challenge = secrets.pkce_challenge, .login_hint = handle, @@ -574,7 +594,7 @@ fn derivedEmail(secret: *const session.Secret, did: []const u8, allocator: std.m } /// The space host's own DID (service-auth audience). -fn describeServerDid(allocator: std.mem.Allocator, io: std.Io, space_pds: []const u8) ![]u8 { +pub fn describeServerDid(allocator: std.mem.Allocator, io: std.Io, space_pds: []const u8) ![]u8 { const url = try std.fmt.allocPrint(allocator, "{s}/xrpc/com.atproto.server.describeServer", .{space_pds}); var transport = zat.HttpTransport.init(io, allocator); defer transport.deinit(); @@ -661,9 +681,11 @@ fn createExternalAccount( return error.BootstrapFailed; } -test "scopeFor gives the full scope only to the space host" { - try std.testing.expectEqualStrings(scope, scopeFor("https://zds.linji.at", "https://zds.linji.at")); - try std.testing.expectEqualStrings(external_scope, scopeFor("https://bsky.social", "https://zds.linji.at")); +test "externalScope carries the space host audience" { + const allocator = std.testing.allocator; + const s = try externalScope(allocator, "did:web:zds.linji.at"); + defer allocator.free(s); + try std.testing.expectEqualStrings("rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at", s); } test "isHostedHandle only matches our own domain" { diff --git a/src/server.zig b/src/server.zig index 4c0f0b8..ea72488 100644 --- a/src/server.zig +++ b/src/server.zig @@ -81,7 +81,11 @@ pub fn serve( port: u16, ) !void { const zds_invite: ?[]const u8 = if (std.c.getenv("LINJI_ZDS_INVITE")) |v| std.mem.span(v) else null; - const metadata = try oauth.clientMetadata(allocator, store, io, base_url); + // The client metadata must declare the rpc scope external PDSes check + // before minting service-auth JWTs, so resolve the space host's DID up + // front. The app is useless without zds anyway - fail fast and clearly. + const space_did = try oauth.describeServerDid(allocator, io, pds); + const metadata = try oauth.clientMetadata(allocator, store, io, base_url, space_did); var app: App = .{ .allocator = allocator, .io = io,