From 3da17598aa65ab7eeb9d149eb3a04cf3f751c736 Mon Sep 17 00:00:00 2001 From: iacore Date: Sun, 2 Aug 2026 13:30:12 +0800 Subject: [PATCH] fix(oauth): request rpc scope for external service-auth minting bsky refuses getServiceAuth unless the session token holds the matching rpc scope (403 ScopeMissingError on bootstrap step 2). externalScope() now yields 'rpc:com.atproto.server.createAccount?aud=' alone; the PAR leg composes 'atproto ' at the call site and the client metadata merges it after the linji scope, so the 'atproto' token is never declared twice (bsky rejects duplicate scopes in client metadata). space host DID resolved up front in serve(). --- src/core/oauth.zig | 54 ++++++++++++++++++++++++++++++++-------------- src/server.zig | 6 +++++- 2 files changed, 43 insertions(+), 17 deletions(-) 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, -- 2.51.2