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 OAuth DPoP binding

zzstoatzz (Jun 16, 2026, 12:49 PM -0500) dca2e4d0 60e02062

+548 -24
+1
README.md
··· 81 81 ZDS_PLC_ROTATION_KEY='64-hex-secp256k1-secret-or-private-multikey' \ 82 82 ZDS_RECOVERY_DID_KEY='did:key:optionalRecoveryKey' \ 83 83 ZDS_JWT_SECRET='at-least-32-random-bytes-here' \ 84 + ZDS_DPOP_SECRET='32-plus-random-bytes-for-dpop-nonces' \ 84 85 ZDS_ADMIN_TOKEN='another-random-secret' \ 85 86 ZDS_INVITE_REQUIRED=true \ 86 87 zig build run -- \
+1 -1
build.zig
··· 3 3 pub fn build(b: *std.Build) void { 4 4 const target = b.standardTargetOptions(.{}); 5 5 const optimize = b.standardOptimizeOption(.{}); 6 - const version = b.option([]const u8, "version", "Build version reported by /xrpc/_health") orelse "0.0.3"; 6 + const version = b.option([]const u8, "version", "Build version reported by /xrpc/_health") orelse "0.1.0"; 7 7 8 8 const zat = b.dependency("zat", .{ 9 9 .target = target,
+1 -1
build.zig.zon
··· 1 1 .{ 2 2 .name = .zds, 3 - .version = "0.0.3", 3 + .version = "0.1.0", 4 4 .fingerprint = 0x6ebabab1f62e1904, 5 5 .minimum_zig_version = "0.16.0", 6 6 .dependencies = .{
+3 -2
docs/architecture.md
··· 63 63 64 64 OAuth follows the ATProto OAuth profile: PAR is required, redirect URIs are 65 65 validated against client metadata, permission-set includes must resolve, tokens 66 - enforce granular repo/blob/rpc/account/identity scopes, and revocation affects 67 - resource-server checks. 66 + enforce granular repo/blob/rpc/account/identity scopes, DPoP-bound OAuth access 67 + tokens must present matching proof headers on resource requests, and revocation 68 + affects resource-server checks. 68 69 69 70 Password sessions and app passwords match the reference PDS account model. 70 71 Session JWTs are accepted only when their JTI is present in the active session
+4
docs/operations.md
··· 77 77 - `ZDS_RECOVERY_DID_KEY`: optional recovery `did:key` returned before the PDS 78 78 rotation key in recommended DID credentials. 79 79 - `ZDS_JWT_SECRET`: stable secret for access and refresh JWTs. 80 + - `ZDS_DPOP_SECRET`: stable secret for stateless DPoP nonce generation. If 81 + unset, ZDS uses `ZDS_JWT_SECRET`. A separate value is recommended for 82 + production. Multi-node deployments must configure every node with the same 83 + effective DPoP secret so each node accepts the same nonce window. 80 84 - `ZDS_HANDLE_DOMAINS`: comma-separated domains advertised by 81 85 `describeServer`. 82 86 - `ZDS_MAIL_PROVIDER`: email delivery provider. Default: `comail`. Supported:
+47 -7
src/atproto/oauth.zig
··· 1 1 const std = @import("std"); 2 2 const auth = @import("../auth/tokens.zig"); 3 3 const config = @import("../core/config.zig"); 4 + const dpop = @import("../internal/dpop.zig"); 4 5 const log = @import("../core/log.zig"); 5 6 const http_api = @import("../http/api.zig"); 6 7 const permission_sets = @import("oauth/permission_sets.zig"); ··· 121 122 const response_mode = try params.value(allocator, "response_mode") orelse "query"; 122 123 if (!validResponseMode(response_mode)) return oauthError(request, .bad_request, "invalid_request", "Invalid response_mode"); 123 124 const login_hint = try params.value(allocator, "login_hint"); 124 - const dpop_jkt = try params.value(allocator, "dpop_jkt"); 125 + var dpop_jkt = try params.value(allocator, "dpop_jkt"); 126 + const maybe_dpop_proof = dpop.maybeVerifyRequest(allocator, request, null, dpop_jkt) catch |err| { 127 + return handleAuthorizationDpopError(request, allocator, err); 128 + }; 129 + if (maybe_dpop_proof) |proof| { 130 + if (dpop_jkt == null) dpop_jkt = proof.jkt; 131 + } 125 132 if (try params.value(allocator, "prompt")) |prompt| { 126 133 if (!validPrompt(prompt)) return oauthError(request, .bad_request, "invalid_request", "Invalid prompt"); 127 134 } ··· 434 441 } 435 442 const did = oauth_request.sub orelse return oauthError(request, .bad_request, "invalid_grant", "Code was not authorized"); 436 443 const account = (try store.findAccount(allocator, did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); 437 - try issueTokenResponse(request, allocator, account, oauth_request.client_id, oauth_request.scope, null, oauth_request.auth_method); 444 + if (oauth_request.dpop_jkt) |expected_jkt| { 445 + _ = dpop.verifyRequest(allocator, request, null, expected_jkt) catch |err| { 446 + return handleAuthorizationDpopError(request, allocator, err); 447 + }; 448 + } 449 + try issueTokenResponse(request, allocator, account, oauth_request.client_id, oauth_request.scope, null, oauth_request.dpop_jkt, oauth_request.auth_method); 438 450 } 439 451 440 452 fn refreshToken(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { ··· 451 463 log.err("oauth refresh invalid_grant: revoked={} access_expires_at={d} client_id_match={} row_client={s} request_client={s} did={s}\n", .{ token_row.revoked, token_row.expires_at, client_id_match, token_row.client_id, client_id, token_row.did }); 452 464 return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 453 465 } 466 + if (token_row.dpop_jkt) |expected_jkt| { 467 + _ = dpop.verifyRequest(allocator, request, null, expected_jkt) catch |err| { 468 + return handleAuthorizationDpopError(request, allocator, err); 469 + }; 470 + } 454 471 const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); 455 - try issueTokenResponse(request, allocator, account, token_row.client_id, token_row.scope, refresh, token_row.auth_method); 472 + try issueTokenResponse(request, allocator, account, token_row.client_id, token_row.scope, refresh, token_row.dpop_jkt, token_row.auth_method); 456 473 } 457 474 458 - fn issueTokenResponse(request: *http_api.Request, allocator: std.mem.Allocator, account: auth.Account, client_id: []const u8, scope: []const u8, revoke_old: ?[]const u8, auth_method: ?[]const u8) !void { 475 + fn issueTokenResponse( 476 + request: *http_api.Request, 477 + allocator: std.mem.Allocator, 478 + account: auth.Account, 479 + client_id: []const u8, 480 + scope: []const u8, 481 + revoke_old: ?[]const u8, 482 + dpop_jkt: ?[]const u8, 483 + auth_method: ?[]const u8, 484 + ) !void { 459 485 if (revoke_old) |old| try store.revokeOAuthToken(old); 460 - const access = try auth.createSessionJwt(allocator, "access", account); 486 + const access = if (dpop_jkt) |jkt| 487 + try auth.createDpopSessionJwt(allocator, "access", account, jkt) 488 + else 489 + try auth.createSessionJwt(allocator, "access", account); 461 490 const refresh = try auth.createSessionJwt(allocator, "refresh", account); 462 491 const expires_at = now() + token_expires_in; 463 492 const token_scope = permission_sets.expandScopes(allocator, scope) catch |err| { 464 493 log.err("oauth token scope_expand failed client={s} did={s} scope={s} err={s}\n", .{ client_id, account.did, scope, @errorName(err) }); 465 494 return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); 466 495 }; 467 - try store.putOAuthToken(account.did, client_id, token_scope, access, refresh, expires_at, auth_method); 468 - const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":\"DPoP\",\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ 496 + try store.putOAuthToken(account.did, client_id, token_scope, access, refresh, expires_at, dpop_jkt, auth_method); 497 + const token_type = if (dpop_jkt != null) "DPoP" else "Bearer"; 498 + const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":{f},\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ 469 499 std.json.fmt(access, .{}), 470 500 std.json.fmt(refresh, .{}), 501 + std.json.fmt(token_type, .{}), 471 502 token_expires_in, 472 503 std.json.fmt(token_scope, .{}), 473 504 std.json.fmt(account.did, .{}), ··· 1118 1149 var buf: [512]u8 = undefined; 1119 1150 const body = try std.fmt.bufPrint(&buf, "{{\"error\":{f},\"error_description\":{f}}}", .{ std.json.fmt(err, .{}), std.json.fmt(description, .{}) }); 1120 1151 try http_api.json(request, status, body); 1152 + } 1153 + 1154 + fn handleAuthorizationDpopError(request: *http_api.Request, allocator: std.mem.Allocator, err: anyerror) !void { 1155 + return switch (err) { 1156 + error.UseDpopNonce, error.MissingProof => dpop.challengeAuthorizationServer(request, allocator), 1157 + error.KeyBindingMismatch => oauthError(request, .bad_request, "invalid_grant", "DPoP proof does not match the expected JKT"), 1158 + error.Replay, error.InvalidProof => oauthError(request, .unauthorized, "invalid_dpop_proof", "Invalid DPoP proof"), 1159 + else => err, 1160 + }; 1121 1161 } 1122 1162 1123 1163 fn respondHtml(request: *http_api.Request, status: http.Status, body: []const u8) !void {
+2
src/atproto/server.zig
··· 794 794 defer allocator.free(old_claims.did); 795 795 defer allocator.free(old_claims.scope); 796 796 defer allocator.free(old_claims.jti); 797 + defer if (old_claims.cnf_jkt) |jkt| allocator.free(jkt); 797 798 const auth_method = try store.sessionAuthMethod(allocator, account.did, old_claims.jti) orelse { 798 799 return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 799 800 }; ··· 1191 1192 defer allocator.free(claims.did); 1192 1193 defer allocator.free(claims.scope); 1193 1194 defer allocator.free(claims.jti); 1195 + defer if (claims.cnf_jkt) |jkt| allocator.free(jkt); 1194 1196 if (!std.mem.eql(u8, claims.did, did)) { 1195 1197 try http_api.xrpcError(request, .forbidden, "AuthFactorTokenRequired", "Password session required"); 1196 1198 return error.HandledResponse;
+56 -5
src/auth/tokens.zig
··· 13 13 did: []const u8, 14 14 scope: []const u8, 15 15 jti: []const u8, 16 + cnf_jkt: ?[]const u8 = null, 16 17 exp: i64, 17 18 }; 18 19 ··· 69 70 return token.token; 70 71 } 71 72 73 + pub fn createDpopSessionJwt(allocator: std.mem.Allocator, kind: []const u8, account: Account, dpop_jkt: []const u8) ![]const u8 { 74 + const token = try createSessionTokenWithScopeAndCnf(allocator, account, kind, tryScope(kind), dpop_jkt); 75 + allocator.free(token.jti); 76 + return token.token; 77 + } 78 + 72 79 pub fn createSessionPair(allocator: std.mem.Allocator, account: Account) !SessionPair { 73 80 return createSessionPairWithAccessScope(allocator, account, "com.atproto.access"); 74 81 } ··· 90 97 } 91 98 92 99 fn createSessionTokenWithScope(allocator: std.mem.Allocator, account: Account, kind: []const u8, scope: []const u8) !SessionToken { 100 + return createSessionTokenWithScopeAndCnf(allocator, account, kind, scope, null); 101 + } 102 + 103 + fn createSessionTokenWithScopeAndCnf( 104 + allocator: std.mem.Allocator, 105 + account: Account, 106 + kind: []const u8, 107 + scope: []const u8, 108 + dpop_jkt: ?[]const u8, 109 + ) !SessionToken { 93 110 const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; 94 111 const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); 95 112 var ts: std.posix.timespec = undefined; ··· 102 119 const exp = iat + lifetime; 103 120 const jti_text = try std.fmt.allocPrint(allocator, "zds-{d}-{d}-{d}", .{ timestamp.sec, timestamp.nsec, jti }); 104 121 errdefer allocator.free(jti_text); 105 - const payload = try std.fmt.allocPrint( 106 - allocator, 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, .{}) }, 109 - ); 122 + const payload = if (dpop_jkt) |jkt| 123 + try std.fmt.allocPrint( 124 + allocator, 125 + "{{\"sub\":{f},\"scope\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f},\"cnf\":{{\"jkt\":{f}}}}}", 126 + .{ std.json.fmt(account.did, .{}), std.json.fmt(scope, .{}), iat, exp, std.json.fmt(jti_text, .{}), std.json.fmt(jkt, .{}) }, 127 + ) 128 + else 129 + try std.fmt.allocPrint( 130 + allocator, 131 + "{{\"sub\":{f},\"scope\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", 132 + .{ std.json.fmt(account.did, .{}), std.json.fmt(scope, .{}), iat, exp, std.json.fmt(jti_text, .{}) }, 133 + ); 110 134 defer allocator.free(payload); 111 135 112 136 const header_b64 = try zat.jwt.base64UrlEncode(allocator, header); ··· 128 152 }; 129 153 } 130 154 155 + fn tryScope(kind: []const u8) []const u8 { 156 + if (std.mem.eql(u8, kind, "access")) return "com.atproto.access"; 157 + if (std.mem.eql(u8, kind, "refresh")) return "com.atproto.refresh"; 158 + return "com.atproto.access"; 159 + } 160 + 131 161 pub fn createServiceJwt( 132 162 allocator: std.mem.Allocator, 133 163 account: Account, ··· 194 224 const claims = claimsFromSessionJwt(allocator, token) orelse return null; 195 225 allocator.free(claims.scope); 196 226 allocator.free(claims.jti); 227 + if (claims.cnf_jkt) |jkt| allocator.free(jkt); 197 228 return claims.did; 198 229 } 199 230 ··· 236 267 const jti = zat.json.getString(parsed.value, "jti") orelse return null; 237 268 const exp = zat.json.getInt(parsed.value, "exp") orelse return null; 238 269 if (exp < unixNow()) return null; 270 + const cnf_jkt = if (zat.json.getPath(parsed.value, "cnf.jkt")) |jkt_value| switch (jkt_value) { 271 + .string => |jkt| allocator.dupe(u8, jkt) catch return null, 272 + else => return null, 273 + } else null; 239 274 return .{ 240 275 .did = allocator.dupe(u8, subject) catch return null, 241 276 .scope = allocator.dupe(u8, scope) catch return null, 242 277 .jti = allocator.dupe(u8, jti) catch return null, 278 + .cnf_jkt = cnf_jkt, 243 279 .exp = exp, 244 280 }; 245 281 } ··· 253 289 defer std.testing.allocator.free(claims.did); 254 290 defer std.testing.allocator.free(claims.scope); 255 291 defer std.testing.allocator.free(claims.jti); 292 + defer if (claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); 256 293 try std.testing.expectEqualStrings(alice.did, claims.did); 257 294 } 258 295 ··· 267 304 defer std.testing.allocator.free(access_claims.did); 268 305 defer std.testing.allocator.free(access_claims.scope); 269 306 defer std.testing.allocator.free(access_claims.jti); 307 + defer if (access_claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); 270 308 try std.testing.expectEqualStrings("com.atproto.appPass", access_claims.scope); 271 309 const refresh_claims = claimsFromSessionJwt(std.testing.allocator, pair.refresh.token).?; 272 310 defer std.testing.allocator.free(refresh_claims.did); 273 311 defer std.testing.allocator.free(refresh_claims.scope); 274 312 defer std.testing.allocator.free(refresh_claims.jti); 313 + defer if (refresh_claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); 275 314 try std.testing.expectEqualStrings("com.atproto.refresh", refresh_claims.scope); 315 + } 316 + 317 + test "dpop session token carries cnf jkt" { 318 + const alice = testAccount(); 319 + const token = try createDpopSessionJwt(std.testing.allocator, "access", alice, "test-jkt"); 320 + defer std.testing.allocator.free(token); 321 + const claims = claimsFromSessionJwt(std.testing.allocator, token).?; 322 + defer std.testing.allocator.free(claims.did); 323 + defer std.testing.allocator.free(claims.scope); 324 + defer std.testing.allocator.free(claims.jti); 325 + defer if (claims.cnf_jkt) |jkt| std.testing.allocator.free(jkt); 326 + try std.testing.expectEqualStrings("test-jkt", claims.cnf_jkt.?); 276 327 } 277 328 278 329 test "creates signed service auth token" {
+9
src/core/config.zig
··· 18 18 var proxy_service_id_value: []const u8 = "bsky_appview"; 19 19 var proxy_service_url_value: []const u8 = "https://api.bsky.app"; 20 20 var jwt_secret_value: []const u8 = "zds-local-development-jwt-secret-change-me"; 21 + var dpop_secret_value: ?[]const u8 = null; 21 22 var admin_token_value: ?[]const u8 = null; 22 23 var invite_required_value: bool = false; 23 24 var permissioned_data_value: bool = false; ··· 104 105 105 106 pub fn jwtSecret() []const u8 { 106 107 return jwt_secret_value; 108 + } 109 + 110 + pub fn dpopSecret() []const u8 { 111 + return dpop_secret_value orelse jwt_secret_value; 107 112 } 108 113 109 114 pub fn adminToken() ?[]const u8 { ··· 196 201 197 202 pub fn setJwtSecret(value: []const u8) void { 198 203 jwt_secret_value = value; 204 + } 205 + 206 + pub fn setDpopSecret(value: ?[]const u8) void { 207 + dpop_secret_value = value; 199 208 } 200 209 201 210 pub fn setAdminToken(value: ?[]const u8) void {
+38 -3
src/http/api.zig
··· 1 1 const std = @import("std"); 2 2 const auth = @import("../auth/tokens.zig"); 3 + const dpop = @import("../internal/dpop.zig"); 3 4 const log = @import("../core/log.zig"); 4 5 const store = @import("../storage/store.zig"); 5 6 const httpz = @import("httpz"); ··· 45 46 return error.AuthRequired; 46 47 }; 47 48 48 - const token_start = 49 + const token_scheme: enum { bearer, dpop } = 49 50 if (std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) 50 - "bearer ".len 51 + .bearer 51 52 else if (std.ascii.startsWithIgnoreCase(auth_header, "dpop ")) 52 - "dpop ".len 53 + .dpop 53 54 else 54 55 return error.AuthRequired; 56 + const token_start: usize = if (token_scheme == .bearer) "bearer ".len else "dpop ".len; 55 57 56 58 const token = std.mem.trim(u8, auth_header[token_start..], " \t"); 57 59 const claims = auth.claimsFromSessionJwt(allocator, token) orelse { ··· 61 63 defer allocator.free(claims.did); 62 64 defer allocator.free(claims.scope); 63 65 defer allocator.free(claims.jti); 66 + defer if (claims.cnf_jkt) |jkt| allocator.free(jkt); 64 67 if (!scopeAllows(claims.scope, scope)) { 65 68 log.err("bearer auth invalid: session scope reject claims_scope={s} required={s} did={s}\n", .{ claims.scope, scope, claims.did }); 66 69 return error.InvalidToken; ··· 87 90 log.err("bearer auth invalid: oauth row did mismatch row_did={s} claims_did={s}\n", .{ row.did, claims.did }); 88 91 return error.InvalidToken; 89 92 } 93 + if (row.dpop_jkt) |expected_jkt| { 94 + if (token_scheme != .dpop) { 95 + log.err("oauth auth invalid: dpop-bound token used without DPoP auth scheme did={s}\n", .{claims.did}); 96 + return error.InvalidToken; 97 + } 98 + if (claims.cnf_jkt == null or !std.mem.eql(u8, claims.cnf_jkt.?, expected_jkt)) { 99 + log.err("oauth auth invalid: token cnf mismatch did={s}\n", .{claims.did}); 100 + return error.InvalidToken; 101 + } 102 + _ = dpop.verifyRequest(allocator, request, token, expected_jkt) catch |err| switch (err) { 103 + error.UseDpopNonce, error.MissingProof => { 104 + dpop.challengeResource(@constCast(request), allocator) catch {}; 105 + return error.InvalidToken; 106 + }, 107 + else => { 108 + log.err("oauth auth invalid: dpop proof failed did={s} err={s}\n", .{ claims.did, @errorName(err) }); 109 + return error.InvalidToken; 110 + }, 111 + }; 112 + } else if (token_scheme != .bearer or claims.cnf_jkt != null) { 113 + log.err("oauth auth invalid: bearer token/proof binding mismatch did={s}\n", .{claims.did}); 114 + return error.InvalidToken; 115 + } 90 116 return .{ .account = account, .oauth_scope = row.scope, .oauth_client_id = row.client_id }; 117 + } 118 + 119 + if (claims.cnf_jkt != null) { 120 + log.err("bearer auth invalid: cnf-bound token had no oauth row did={s}\n", .{claims.did}); 121 + return error.InvalidToken; 91 122 } 92 123 93 124 const active = store.sessionTokenIsActive(claims.did, claims.jti, claims.scope) catch |err| { ··· 322 353 323 354 fn setHeaders(res: *Response, headers: []const http.Header) !void { 324 355 for (headers) |header| try addHeader(res, header.name, header.value); 356 + } 357 + 358 + pub fn addResponseHeader(name: []const u8, value: []const u8) !void { 359 + try addHeader(response(), name, value); 325 360 } 326 361 327 362 fn addHeader(res: *Response, name: []const u8, value: []const u8) !void {
+9
src/internal/cli.zig
··· 22 22 proxy_service_id: ?[]const u8 = null, 23 23 proxy_service_url: ?[]const u8 = null, 24 24 jwt_secret: ?[]const u8 = null, 25 + dpop_secret: ?[]const u8 = null, 25 26 admin_token: ?[]const u8 = null, 26 27 invite_required: bool = false, 27 28 permissioned_data: bool = false, ··· 43 44 MissingHost, 44 45 MissingAdminToken, 45 46 MissingJwtSecret, 47 + MissingDpopSecret, 46 48 MissingLogLevel, 47 49 MissingPlcDirectory, 48 50 MissingPlcRotationKey, ··· 80 82 .proxy_service_id = env("ZDS_PROXY_SERVICE_ID"), 81 83 .proxy_service_url = env("ZDS_PROXY_SERVICE_URL"), 82 84 .jwt_secret = env("ZDS_JWT_SECRET"), 85 + .dpop_secret = env("ZDS_DPOP_SECRET"), 83 86 .admin_token = env("ZDS_ADMIN_TOKEN"), 84 87 .invite_required = envBool("ZDS_INVITE_REQUIRED"), 85 88 .permissioned_data = envBool("ZDS_PERMISSIONED_DATA"), ··· 106 109 \\ [--mail-provider comail|resend] [--email-from ADDRESS] 107 110 \\ [--blob-upload-limit BYTES] [--blobstore-path PATH] 108 111 \\ [--handle-domains DOMAINS] [--crawlers URLS] [--jwt-secret SECRET] 112 + \\ [--dpop-secret SECRET] 109 113 \\ [--proxy-service-did DID] [--proxy-service-id ID] [--proxy-service-url URL] 110 114 \\ [--admin-token TOKEN] [--invite-required] 111 115 \\ [--log-level error|info|debug] [--debug] ··· 211 215 options.jwt_secret = args.next() orelse return error.MissingJwtSecret; 212 216 return true; 213 217 } 218 + if (std.mem.eql(u8, arg, "--dpop-secret")) { 219 + options.dpop_secret = args.next() orelse return error.MissingDpopSecret; 220 + return true; 221 + } 214 222 if (std.mem.eql(u8, arg, "--admin-token")) { 215 223 options.admin_token = args.next() orelse return error.MissingAdminToken; 216 224 return true; ··· 265 273 .{ .flag = "--proxy-service-id=", .field = "proxy_service_id" }, 266 274 .{ .flag = "--proxy-service-url=", .field = "proxy_service_url" }, 267 275 .{ .flag = "--jwt-secret=", .field = "jwt_secret" }, 276 + .{ .flag = "--dpop-secret=", .field = "dpop_secret" }, 268 277 .{ .flag = "--admin-token=", .field = "admin_token" }, 269 278 .{ .flag = "--log-level=", .field = "log_level" }, 270 279 };
+342
src/internal/dpop.zig
··· 1 + const std = @import("std"); 2 + const config = @import("../core/config.zig"); 3 + const http_api = @import("../http/api.zig"); 4 + const store = @import("../storage/store.zig"); 5 + const zat = @import("zat"); 6 + 7 + const ProofMaxAgeSecs: i64 = 300; 8 + const NonceRotationSecs: i64 = 60; 9 + 10 + pub const Error = error{ 11 + MissingProof, 12 + InvalidProof, 13 + UseDpopNonce, 14 + Replay, 15 + KeyBindingMismatch, 16 + } || std.mem.Allocator.Error; 17 + 18 + pub const Proof = struct { 19 + jkt: []const u8, 20 + jti: []const u8, 21 + }; 22 + 23 + pub fn nextNonce(allocator: std.mem.Allocator) ![]u8 { 24 + return nonceForCounter(allocator, nonceCounter(now()) + 1); 25 + } 26 + 27 + pub fn challengeResource(request: *http_api.Request, allocator: std.mem.Allocator) !void { 28 + const nonce = try nextNonce(allocator); 29 + try http_api.addResponseHeader("dpop-nonce", nonce); 30 + try http_api.addResponseHeader("www-authenticate", "DPoP error=\"use_dpop_nonce\", error_description=\"Resource server requires nonce in DPoP proof\""); 31 + try http_api.xrpcError(request, .unauthorized, "UseDpopNonce", "Resource server requires nonce in DPoP proof"); 32 + } 33 + 34 + pub fn challengeAuthorizationServer(request: *http_api.Request, allocator: std.mem.Allocator) !void { 35 + const nonce = try nextNonce(allocator); 36 + try http_api.addResponseHeader("dpop-nonce", nonce); 37 + const body = try allocator.dupe(u8, "{\"error\":\"use_dpop_nonce\",\"error_description\":\"Authorization server requires nonce in DPoP proof\"}"); 38 + try http_api.json(request, .bad_request, body); 39 + } 40 + 41 + pub fn verifyRequest( 42 + allocator: std.mem.Allocator, 43 + request: *const http_api.Request, 44 + access_token: ?[]const u8, 45 + expected_jkt: ?[]const u8, 46 + ) Error!Proof { 47 + const dpop_header = http_api.headerValue(request, "dpop") orelse return error.MissingProof; 48 + const htu = try htuForRequest(allocator, request); 49 + defer allocator.free(htu); 50 + return verifyProof(allocator, dpop_header, methodName(request.method), htu, access_token, expected_jkt); 51 + } 52 + 53 + pub fn maybeVerifyRequest( 54 + allocator: std.mem.Allocator, 55 + request: *const http_api.Request, 56 + access_token: ?[]const u8, 57 + expected_jkt: ?[]const u8, 58 + ) Error!?Proof { 59 + if (http_api.headerValue(request, "dpop") == null) return null; 60 + return try verifyRequest(allocator, request, access_token, expected_jkt); 61 + } 62 + 63 + fn verifyProof( 64 + allocator: std.mem.Allocator, 65 + proof: []const u8, 66 + method: []const u8, 67 + expected_htu: []const u8, 68 + access_token: ?[]const u8, 69 + expected_jkt: ?[]const u8, 70 + ) Error!Proof { 71 + var parts = std.mem.splitScalar(u8, proof, '.'); 72 + const header_part = parts.next() orelse return error.InvalidProof; 73 + const payload_part = parts.next() orelse return error.InvalidProof; 74 + const signature_part = parts.next() orelse return error.InvalidProof; 75 + if (parts.next() != null or header_part.len == 0 or payload_part.len == 0 or signature_part.len == 0) return error.InvalidProof; 76 + 77 + const header_json = zat.jwt.base64UrlDecode(allocator, header_part) catch return error.InvalidProof; 78 + defer allocator.free(header_json); 79 + const payload_json = zat.jwt.base64UrlDecode(allocator, payload_part) catch return error.InvalidProof; 80 + defer allocator.free(payload_json); 81 + const signature = zat.jwt.base64UrlDecode(allocator, signature_part) catch return error.InvalidProof; 82 + defer allocator.free(signature); 83 + 84 + const header = std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}) catch return error.InvalidProof; 85 + defer header.deinit(); 86 + const payload = std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}) catch return error.InvalidProof; 87 + defer payload.deinit(); 88 + 89 + if (!std.mem.eql(u8, zat.json.getString(header.value, "typ") orelse return error.InvalidProof, "dpop+jwt")) return error.InvalidProof; 90 + const alg_text = zat.json.getString(header.value, "alg") orelse return error.InvalidProof; 91 + const alg = zat.jwt.Algorithm.fromString(alg_text) orelse return error.InvalidProof; 92 + 93 + const jwk = zat.json.getPath(header.value, "jwk") orelse return error.InvalidProof; 94 + const public_key = try publicKeyFromJwk(allocator, alg, jwk); 95 + defer allocator.free(public_key); 96 + 97 + const signing_input = proof[0 .. header_part.len + 1 + payload_part.len]; 98 + zat.jwt.verifyJose(alg, signing_input, signature, public_key) catch return error.InvalidProof; 99 + 100 + const jti = zat.json.getString(payload.value, "jti") orelse return error.InvalidProof; 101 + if (jti.len == 0) return error.InvalidProof; 102 + if (!std.mem.eql(u8, zat.json.getString(payload.value, "htm") orelse return error.InvalidProof, method)) return error.InvalidProof; 103 + 104 + const htu = zat.json.getString(payload.value, "htu") orelse return error.InvalidProof; 105 + const normalized_htu = normalizeHtu(htu) orelse return error.InvalidProof; 106 + if (!std.mem.eql(u8, normalized_htu, expected_htu)) return error.InvalidProof; 107 + 108 + const iat = zat.json.getInt(payload.value, "iat") orelse return error.InvalidProof; 109 + const ts = now(); 110 + if (iat < ts - ProofMaxAgeSecs or iat > ts + ProofMaxAgeSecs) return error.InvalidProof; 111 + 112 + const nonce = zat.json.getString(payload.value, "nonce") orelse return error.UseDpopNonce; 113 + if (!validNonce(nonce, ts)) return error.UseDpopNonce; 114 + 115 + if (access_token) |token| { 116 + const expected_ath = try zat.oauth.accessTokenHash(allocator, token); 117 + defer allocator.free(expected_ath); 118 + if (!std.mem.eql(u8, zat.json.getString(payload.value, "ath") orelse return error.InvalidProof, expected_ath)) return error.InvalidProof; 119 + } else if (zat.json.getString(payload.value, "ath") != null) { 120 + return error.InvalidProof; 121 + } 122 + 123 + const jkt = try jwkThumbprint(allocator, alg, jwk); 124 + errdefer allocator.free(jkt); 125 + if (expected_jkt) |expected| { 126 + if (!std.mem.eql(u8, jkt, expected)) return error.KeyBindingMismatch; 127 + } 128 + const expires_at = @max(iat + ProofMaxAgeSecs, ts + ProofMaxAgeSecs); 129 + if (!(store.recordDpopJti(jti, expires_at) catch return error.InvalidProof)) return error.Replay; 130 + 131 + return .{ 132 + .jkt = jkt, 133 + .jti = try allocator.dupe(u8, jti), 134 + }; 135 + } 136 + 137 + fn publicKeyFromJwk(allocator: std.mem.Allocator, alg: zat.jwt.Algorithm, jwk: std.json.Value) ![]u8 { 138 + const crv = zat.json.getString(jwk, "crv") orelse return error.InvalidProof; 139 + switch (alg) { 140 + .ES256 => if (!std.mem.eql(u8, crv, "P-256")) return error.InvalidProof, 141 + .ES256K => if (!std.mem.eql(u8, crv, "secp256k1")) return error.InvalidProof, 142 + } 143 + if (!std.mem.eql(u8, zat.json.getString(jwk, "kty") orelse return error.InvalidProof, "EC")) return error.InvalidProof; 144 + const x = try decodeCoordinate(allocator, zat.json.getString(jwk, "x") orelse return error.InvalidProof); 145 + defer allocator.free(x); 146 + const y = try decodeCoordinate(allocator, zat.json.getString(jwk, "y") orelse return error.InvalidProof); 147 + defer allocator.free(y); 148 + const public_key = try allocator.alloc(u8, 33); 149 + public_key[0] = if ((y[31] & 1) == 0) 0x02 else 0x03; 150 + @memcpy(public_key[1..33], x); 151 + return public_key; 152 + } 153 + 154 + fn decodeCoordinate(allocator: std.mem.Allocator, text: []const u8) ![]u8 { 155 + const raw = zat.jwt.base64UrlDecode(allocator, text) catch return error.InvalidProof; 156 + errdefer allocator.free(raw); 157 + if (raw.len != 32) return error.InvalidProof; 158 + return raw; 159 + } 160 + 161 + fn jwkThumbprint(allocator: std.mem.Allocator, alg: zat.jwt.Algorithm, jwk: std.json.Value) ![]u8 { 162 + const crv = switch (alg) { 163 + .ES256 => "P-256", 164 + .ES256K => "secp256k1", 165 + }; 166 + const x = zat.json.getString(jwk, "x") orelse return error.InvalidProof; 167 + const y = zat.json.getString(jwk, "y") orelse return error.InvalidProof; 168 + const canonical = try std.fmt.allocPrint(allocator, "{{\"crv\":\"{s}\",\"kty\":\"EC\",\"x\":\"{s}\",\"y\":\"{s}\"}}", .{ crv, x, y }); 169 + defer allocator.free(canonical); 170 + var digest: [32]u8 = undefined; 171 + std.crypto.hash.sha2.Sha256.hash(canonical, &digest, .{}); 172 + return zat.jwt.base64UrlEncode(allocator, &digest); 173 + } 174 + 175 + fn htuForRequest(allocator: std.mem.Allocator, request: *const http_api.Request) ![]u8 { 176 + const path = stripQueryAndFragment(request.url.raw); 177 + return std.fmt.allocPrint(allocator, "{s}{s}", .{ config.publicUrl(), path }); 178 + } 179 + 180 + fn normalizeHtu(htu: []const u8) ?[]const u8 { 181 + if (!std.mem.startsWith(u8, htu, "http://") and !std.mem.startsWith(u8, htu, "https://")) return null; 182 + const start = if (std.mem.startsWith(u8, htu, "http://")) "http://".len else "https://".len; 183 + const slash = std.mem.indexOfScalarPos(u8, htu, start, '/') orelse return htu; 184 + const end = blk: { 185 + const query = std.mem.indexOfAnyPos(u8, htu, slash, "?#") orelse break :blk htu.len; 186 + break :blk query; 187 + }; 188 + return htu[0..end]; 189 + } 190 + 191 + fn validNonce(nonce: []const u8, ts: i64) bool { 192 + const counter = nonceCounter(ts); 193 + return nonceMatches(nonce, counter - 1) or nonceMatches(nonce, counter) or nonceMatches(nonce, counter + 1); 194 + } 195 + 196 + fn nonceMatches(nonce: []const u8, counter: i64) bool { 197 + var buf: [64]u8 = undefined; 198 + var stream = std.Io.Writer.fixed(&buf); 199 + writeNonceForCounter(&stream, counter) catch return false; 200 + return std.mem.eql(u8, nonce, stream.buffered()); 201 + } 202 + 203 + fn nonceForCounter(allocator: std.mem.Allocator, counter: i64) ![]u8 { 204 + var buf: [64]u8 = undefined; 205 + var stream = std.Io.Writer.fixed(&buf); 206 + try writeNonceForCounter(&stream, counter); 207 + return allocator.dupe(u8, stream.buffered()); 208 + } 209 + 210 + fn writeNonceForCounter(writer: *std.Io.Writer, counter: i64) !void { 211 + var counter_bytes: [8]u8 = undefined; 212 + std.mem.writeInt(i64, &counter_bytes, counter, .big); 213 + var mac: [std.crypto.auth.hmac.sha2.HmacSha256.mac_length]u8 = undefined; 214 + std.crypto.auth.hmac.sha2.HmacSha256.create(&mac, &counter_bytes, config.dpopSecret()); 215 + const encoded = try zat.jwt.base64UrlEncode(std.heap.page_allocator, &mac); 216 + defer std.heap.page_allocator.free(encoded); 217 + try writer.writeAll(encoded); 218 + } 219 + 220 + fn nonceCounter(ts: i64) i64 { 221 + return @divFloor(ts, NonceRotationSecs); 222 + } 223 + 224 + fn stripQueryAndFragment(value: []const u8) []const u8 { 225 + const end = std.mem.indexOfAny(u8, value, "?#") orelse value.len; 226 + return value[0..end]; 227 + } 228 + 229 + fn methodName(method: anytype) []const u8 { 230 + return switch (method) { 231 + .GET => "GET", 232 + .HEAD => "HEAD", 233 + .POST => "POST", 234 + .PUT => "PUT", 235 + .PATCH => "PATCH", 236 + .DELETE => "DELETE", 237 + .OPTIONS => "OPTIONS", 238 + else => "GET", 239 + }; 240 + } 241 + 242 + fn now() i64 { 243 + var ts: std.posix.timespec = undefined; 244 + return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 245 + .SUCCESS => ts.sec, 246 + else => 0, 247 + }; 248 + } 249 + 250 + test "validates DPoP proof and rejects replay" { 251 + const allocator = std.testing.allocator; 252 + try store.init(std.Options.debug_io, ":memory:"); 253 + defer store.close(); 254 + 255 + const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 256 + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 257 + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 258 + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 259 + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 260 + }); 261 + const access_token = "access-token"; 262 + const ath = try zat.oauth.accessTokenHash(allocator, access_token); 263 + defer allocator.free(ath); 264 + const nonce = try nextNonce(allocator); 265 + defer allocator.free(nonce); 266 + const proof = try zat.oauth.createDpopProof( 267 + allocator, 268 + std.Options.debug_io, 269 + &keypair, 270 + "GET", 271 + "https://pds.example/xrpc/com.atproto.repo.getRecord?repo=x", 272 + nonce, 273 + ath, 274 + ); 275 + defer allocator.free(proof); 276 + const expected_jkt = try keypair.jwkThumbprint(allocator); 277 + defer allocator.free(expected_jkt); 278 + 279 + const verified = try verifyProof(allocator, proof, "GET", "https://pds.example/xrpc/com.atproto.repo.getRecord", access_token, expected_jkt); 280 + defer allocator.free(verified.jkt); 281 + defer allocator.free(verified.jti); 282 + try std.testing.expectEqualStrings(expected_jkt, verified.jkt); 283 + try std.testing.expectError(error.Replay, verifyProof(allocator, proof, "GET", "https://pds.example/xrpc/com.atproto.repo.getRecord", access_token, expected_jkt)); 284 + } 285 + 286 + test "rejects DPoP proof with wrong token binding" { 287 + const allocator = std.testing.allocator; 288 + try store.init(std.Options.debug_io, ":memory:"); 289 + defer store.close(); 290 + 291 + const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 292 + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 293 + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 294 + 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 295 + 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, 296 + }); 297 + const nonce = try nextNonce(allocator); 298 + defer allocator.free(nonce); 299 + const ath = try zat.oauth.accessTokenHash(allocator, "access-token-a"); 300 + defer allocator.free(ath); 301 + const proof = try zat.oauth.createDpopProof( 302 + allocator, 303 + std.Options.debug_io, 304 + &keypair, 305 + "POST", 306 + "https://pds.example/xrpc/com.atproto.repo.createRecord", 307 + nonce, 308 + ath, 309 + ); 310 + defer allocator.free(proof); 311 + const expected_jkt = try keypair.jwkThumbprint(allocator); 312 + defer allocator.free(expected_jkt); 313 + 314 + try std.testing.expectError(error.InvalidProof, verifyProof(allocator, proof, "POST", "https://pds.example/xrpc/com.atproto.repo.createRecord", "access-token-b", expected_jkt)); 315 + } 316 + 317 + test "rejects DPoP proof with mismatched JKT" { 318 + const allocator = std.testing.allocator; 319 + try store.init(std.Options.debug_io, ":memory:"); 320 + defer store.close(); 321 + 322 + const keypair = try zat.Keypair.fromSecretKey(.p256, .{ 323 + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 324 + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 325 + 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 326 + 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, 0x80, 327 + }); 328 + const nonce = try nextNonce(allocator); 329 + defer allocator.free(nonce); 330 + const proof = try zat.oauth.createDpopProof( 331 + allocator, 332 + std.Options.debug_io, 333 + &keypair, 334 + "POST", 335 + "https://pds.example/oauth/token", 336 + nonce, 337 + null, 338 + ); 339 + defer allocator.free(proof); 340 + 341 + try std.testing.expectError(error.KeyBindingMismatch, verifyProof(allocator, proof, "POST", "https://pds.example/oauth/token", null, "not-the-key")); 342 + }
+1
src/main.zig
··· 43 43 if (options.proxy_service_id) |value| zds.core.config.setProxyServiceId(value); 44 44 if (options.proxy_service_url) |value| zds.core.config.setProxyServiceUrl(value); 45 45 if (options.jwt_secret) |value| zds.core.config.setJwtSecret(value); 46 + zds.core.config.setDpopSecret(options.dpop_secret); 46 47 zds.core.config.setAdminToken(options.admin_token); 47 48 zds.core.config.setInviteRequired(options.invite_required); 48 49 zds.core.config.setPermissionedData(options.permissioned_data);
+1
src/root.zig
··· 35 35 pub const api_reference = @import("internal/api_reference/root.zig"); 36 36 pub const cli = @import("internal/cli.zig"); 37 37 pub const email_tokens = @import("internal/email_tokens.zig"); 38 + pub const dpop = @import("internal/dpop.zig"); 38 39 pub const passkeys = @import("internal/passkeys.zig"); 39 40 pub const permissioned_data = @import("internal/permissioned_data.zig"); 40 41 pub const scopes = @import("internal/scopes.zig");
+33 -5
src/storage/store.zig
··· 89 89 refresh_token: []const u8, 90 90 expires_at: i64, 91 91 revoked: bool, 92 + dpop_jkt: ?[]const u8, 92 93 auth_method: ?[]const u8, 93 94 }; 94 95 ··· 1012 1013 access_token: []const u8, 1013 1014 refresh_token: []const u8, 1014 1015 expires_at: i64, 1016 + dpop_jkt: ?[]const u8, 1015 1017 auth_method: ?[]const u8, 1016 1018 ) !void { 1017 1019 db_mutex.lockUncancelable(store_io); 1018 1020 defer db_mutex.unlock(store_io); 1019 1021 try requireInitialized(); 1020 1022 try conn.exec( 1021 - \\INSERT INTO oauth_tokens (access_token, refresh_token, did, client_id, scope, expires_at, auth_method) 1022 - \\VALUES (?, ?, ?, ?, ?, ?, ?) 1023 - , .{ access_token, refresh_token, did, client_id, scope, expires_at, auth_method }); 1023 + \\INSERT INTO oauth_tokens (access_token, refresh_token, did, client_id, scope, expires_at, dpop_jkt, auth_method) 1024 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?) 1025 + , .{ access_token, refresh_token, did, client_id, scope, expires_at, dpop_jkt, auth_method }); 1024 1026 } 1025 1027 1026 1028 pub fn getOAuthToken(allocator: std.mem.Allocator, token: []const u8) !?OAuthToken { ··· 1028 1030 defer db_mutex.unlock(store_io); 1029 1031 try requireInitialized(); 1030 1032 const row = try conn.row( 1031 - \\SELECT did, client_id, scope, access_token, refresh_token, expires_at, revoked_at, auth_method 1033 + \\SELECT did, client_id, scope, access_token, refresh_token, expires_at, revoked_at, dpop_jkt, auth_method 1032 1034 \\FROM oauth_tokens 1033 1035 \\WHERE access_token = ? OR refresh_token = ? 1034 1036 \\ORDER BY created_at DESC ··· 1044 1046 .refresh_token = try allocator.dupe(u8, row.?.text(4)), 1045 1047 .expires_at = row.?.int(5), 1046 1048 .revoked = row.?.nullableInt(6) != null, 1047 - .auth_method = if (row.?.nullableText(7)) |text| try allocator.dupe(u8, text) else null, 1049 + .dpop_jkt = if (row.?.nullableText(7)) |text| try allocator.dupe(u8, text) else null, 1050 + .auth_method = if (row.?.nullableText(8)) |text| try allocator.dupe(u8, text) else null, 1048 1051 }; 1049 1052 } 1050 1053 1054 + pub fn recordDpopJti(jti: []const u8, expires_at: i64) !bool { 1055 + db_mutex.lockUncancelable(store_io); 1056 + defer db_mutex.unlock(store_io); 1057 + try requireInitialized(); 1058 + try conn.exec("DELETE FROM dpop_jtis WHERE expires_at < unixepoch()", .{}); 1059 + const row = try conn.row("SELECT 1 FROM dpop_jtis WHERE jti = ?", .{jti}); 1060 + if (row) |found| { 1061 + found.deinit(); 1062 + return false; 1063 + } 1064 + try conn.exec( 1065 + \\INSERT INTO dpop_jtis (jti, expires_at) 1066 + \\VALUES (?, ?) 1067 + , .{ jti, expires_at }); 1068 + return true; 1069 + } 1070 + 1051 1071 pub fn revokeOAuthToken(token: []const u8) !void { 1052 1072 db_mutex.lockUncancelable(store_io); 1053 1073 defer db_mutex.unlock(store_io); ··· 3308 3328 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN response_mode TEXT NOT NULL DEFAULT 'query'") catch {}; 3309 3329 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN auth_method TEXT") catch {}; 3310 3330 conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN auth_method TEXT") catch {}; 3331 + conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN dpop_jkt TEXT") catch {}; 3311 3332 conn.execNoArgs("ALTER TABLE session_tokens ADD COLUMN app_password_name TEXT") catch {}; 3312 3333 conn.execNoArgs("ALTER TABLE permissioned_space_records ADD COLUMN repo_rev TEXT NOT NULL DEFAULT ''") catch {}; 3313 3334 try migrateBlobTable(); ··· 4860 4881 \\ client_id TEXT NOT NULL, 4861 4882 \\ scope TEXT NOT NULL, 4862 4883 \\ expires_at INTEGER NOT NULL, 4884 + \\ dpop_jkt TEXT, 4863 4885 \\ auth_method TEXT, 4864 4886 \\ revoked_at INTEGER, 4887 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 4888 + \\) 4889 + , 4890 + \\CREATE TABLE IF NOT EXISTS dpop_jtis ( 4891 + \\ jti TEXT PRIMARY KEY, 4892 + \\ expires_at INTEGER NOT NULL, 4865 4893 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 4866 4894 \\) 4867 4895 ,