From 2a092787f53b5e5a779c659c5be54fa8fdfc08fc Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Thu, 3 Sep 2026 12:06:59 -0500 Subject: [PATCH] oauth: accept inline jwks for private_key_jwt client assertions verifyClientAssertion read only jwks_uri from the client document, so a client publishing its keys inline as jwks (atcr.io does) failed with InvalidClientMetadata and PAR answered 400 invalid_client. Client metadata carries keys either way; the reference provider accepts either and refuses both at once. src/internal/client_attestation.zig already handled both; the OAuth token path now does the same. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Th6389kN3H2AyUae7yEJAW --- CHANGELOG.md | 4 +++ src/atproto/oauth.zig | 57 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d073ca6..97ad557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Reconstructed from git history for everything up to `v0.1.1`; kept by hand from ## unreleased +- **fix**: a `private_key_jwt` client whose metadata carries its keys inline as `jwks` can + authenticate; before, only `jwks_uri` was read and such a client got `400 invalid_client` at + PAR. Both fields at once are refused, as in the reference provider. Observed with atcr.io, + whose document publishes `jwks` inline, against `pds.zat.dev`. - **fix**: `/xrpc/_health` reports the version in `build.zig.zon`. `build.zig` and the Dockerfile each carried the literal `0.3.0` as a default, so a deploy that did not pass `-Dversion` (the Fly build never does) reported `0.3.0` for every release; v0.3.1 shipped diff --git a/src/atproto/oauth.zig b/src/atproto/oauth.zig index 11bdffe..c1a9391 100644 --- a/src/atproto/oauth.zig +++ b/src/atproto/oauth.zig @@ -721,15 +721,43 @@ fn verifyClientAssertion(allocator: std.mem.Allocator, metadata: std.json.Value, return error.StaleClientAssertion; } - const jwks_uri = zat.json.getString(metadata, "jwks_uri") orelse return error.InvalidClientMetadata; - const jwks_doc = try fetchJson(allocator, jwks_uri, 256 * 1024); - const public_key = try clientPublicKeyFromJwks(allocator, jwks_doc.value, kid, alg); + var fetched_jwks: ?std.json.Parsed(std.json.Value) = null; + defer if (fetched_jwks) |parsed| parsed.deinit(); + const client_keys = switch (try clientJwksSource(metadata)) { + .inline_keys => |keys| keys, + .uri => |jwks_uri| blk: { + fetched_jwks = try fetchJson(allocator, jwks_uri, 256 * 1024); + break :blk fetched_jwks.?.value; + }, + }; + const public_key = try clientPublicKeyFromJwks(allocator, client_keys, kid, alg); defer allocator.free(public_key); const signature = try zat.jwt.base64UrlDecode(allocator, signature_part); defer allocator.free(signature); try zat.jwt.verifyJose(jwt_alg, signing_input, signature, public_key); } +const ClientJwksSource = union(enum) { + inline_keys: std.json.Value, + uri: []const u8, +}; + +/// client metadata carries its signing keys either inline as `jwks` or by +/// reference as `jwks_uri`; the reference provider treats the two as mutually +/// exclusive and requires one of them for private_key_jwt +fn clientJwksSource(metadata: std.json.Value) !ClientJwksSource { + const object = switch (metadata) { + .object => |object| object, + else => return error.InvalidClientMetadata, + }; + const inline_keys = object.get("jwks"); + const uri = zat.json.getString(metadata, "jwks_uri"); + if (inline_keys != null and uri != null) return error.InvalidClientMetadata; + if (inline_keys) |keys| return .{ .inline_keys = keys }; + if (uri) |value| return .{ .uri = value }; + return error.InvalidClientMetadata; +} + fn clientPublicKeyFromJwks(allocator: std.mem.Allocator, jwks_doc: std.json.Value, kid: ?[]const u8, alg: []const u8) ![]u8 { const keys = zat.json.getArray(jwks_doc, "keys") orelse return error.InvalidJwks; for (keys) |key| { @@ -1394,3 +1422,26 @@ test "redirect_uri registration honours application_type for the loopback except try std.testing.expect(web_client.redirectUriRegistered("http://127.0.0.1/callback")); try std.testing.expect(!web_client.redirectUriRegistered("http://127.0.0.1:61234/callback")); } + +test "private_key_jwt clients may publish keys inline as jwks or by jwks_uri" { + const allocator = std.testing.allocator; + const inline_doc = try std.json.parseFromSlice(std.json.Value, allocator, "{\"jwks\":{\"keys\":[{\"kty\":\"EC\",\"crv\":\"P-256\",\"kid\":\"k1\",\"x\":\"meMgEL0CxvtJZgInPqXwHPPCczrXIuVhcaHCE1CXfy0\",\"y\":\"meMgEL0CxvtJZgInPqXwHPPCczrXIuVhcaHCE1CXfy0\"}]}}", .{}); + defer inline_doc.deinit(); + const inline_source = try clientJwksSource(inline_doc.value); + try std.testing.expect(inline_source == .inline_keys); + const key = try clientPublicKeyFromJwks(allocator, inline_source.inline_keys, "k1", "ES256"); + defer allocator.free(key); + try std.testing.expectEqual(@as(usize, 33), key.len); + + const uri_doc = try std.json.parseFromSlice(std.json.Value, allocator, "{\"jwks_uri\":\"https://client.example/jwks.json\"}", .{}); + defer uri_doc.deinit(); + try std.testing.expectEqualStrings("https://client.example/jwks.json", (try clientJwksSource(uri_doc.value)).uri); + + const both = try std.json.parseFromSlice(std.json.Value, allocator, "{\"jwks\":{\"keys\":[]},\"jwks_uri\":\"https://client.example/jwks.json\"}", .{}); + defer both.deinit(); + try std.testing.expectError(error.InvalidClientMetadata, clientJwksSource(both.value)); + + const neither = try std.json.parseFromSlice(std.json.Value, allocator, "{\"token_endpoint_auth_method\":\"private_key_jwt\"}", .{}); + defer neither.deinit(); + try std.testing.expectError(error.InvalidClientMetadata, clientJwksSource(neither.value)); +} -- 2.51.2