Skip to content

Commit e03131c

Browse files
ascorbicclaude
andauthored
feat(pds): implement service auth for AppView proxy (#11)
Add service authentication to proxy authenticated requests to Bluesky AppView. The PDS now creates a service JWT signed with its signing key to vouch for the user when forwarding requests like getTimeline. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 290c12b commit e03131c

3 files changed

Lines changed: 215 additions & 3 deletions

File tree

packages/pds/src/index.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ export { SqliteRepoStorage } from "./storage";
33
export { AccountDurableObject } from "./account-do";
44
export { BlobStore, type BlobRef } from "./blobs";
55
export { Sequencer } from "./sequencer";
6+
export { createServiceJwt } from "./service-auth";
67

78
import { Hono } from "hono";
89
import { cors } from "hono/cors";
9-
import { proxy } from "hono/proxy";
1010
import { env } from "cloudflare:workers";
11+
import { Secp256k1Keypair } from "@atproto/crypto";
1112
import { ensureValidDid, ensureValidHandle } from "@atproto/syntax";
1213
import { requireAuth } from "./middleware/auth";
14+
import { createServiceJwt } from "./service-auth";
15+
import { verifyAccessToken } from "./session";
1316
import * as sync from "./xrpc/sync";
1417
import * as repo from "./xrpc/repo";
1518
import * as server from "./xrpc/server";
@@ -42,6 +45,18 @@ try {
4245
);
4346
}
4447

48+
// Bluesky AppView DID for service auth
49+
const APPVIEW_DID = "did:web:api.bsky.app";
50+
51+
// Lazy-loaded keypair for service auth
52+
let keypairPromise: Promise<Secp256k1Keypair> | null = null;
53+
function getKeypair(): Promise<Secp256k1Keypair> {
54+
if (!keypairPromise) {
55+
keypairPromise = Secp256k1Keypair.import(env.SIGNING_KEY);
56+
}
57+
return keypairPromise;
58+
}
59+
4560
const app = new Hono<{ Bindings: Env }>();
4661

4762
// CORS middleware for all routes
@@ -180,11 +195,68 @@ app.get("/xrpc/app.bsky.ageassurance.getState", requireAuth, (c) => {
180195
});
181196

182197
// Proxy unhandled XRPC requests to Bluesky AppView
183-
app.all("/xrpc/*", (c) => {
198+
app.all("/xrpc/*", async (c) => {
184199
const url = new URL(c.req.url);
185200
url.host = "api.bsky.app";
186201
url.protocol = "https:";
187-
return proxy(url, c.req);
202+
203+
// Extract XRPC method name from path (e.g., "app.bsky.feed.getTimeline")
204+
const lxm = url.pathname.replace("/xrpc/", "");
205+
206+
// Check for authorization header
207+
const auth = c.req.header("Authorization");
208+
let headers: Record<string, string> = {};
209+
210+
if (auth?.startsWith("Bearer ")) {
211+
const token = auth.slice(7);
212+
const serviceDid = `did:web:${c.env.PDS_HOSTNAME}`;
213+
214+
// Try to verify the token - if valid, create a service JWT
215+
try {
216+
// Check static token first
217+
let userDid: string;
218+
if (token === c.env.AUTH_TOKEN) {
219+
userDid = c.env.DID;
220+
} else {
221+
// Verify JWT
222+
const payload = await verifyAccessToken(
223+
token,
224+
c.env.JWT_SECRET,
225+
serviceDid,
226+
);
227+
userDid = payload.sub;
228+
}
229+
230+
// Create service JWT for AppView
231+
const keypair = await getKeypair();
232+
const serviceJwt = await createServiceJwt({
233+
iss: userDid,
234+
aud: APPVIEW_DID,
235+
lxm,
236+
keypair,
237+
});
238+
headers["Authorization"] = `Bearer ${serviceJwt}`;
239+
} catch {
240+
// Token verification failed - forward without auth
241+
// AppView will return appropriate error
242+
}
243+
}
244+
245+
// Forward request with potentially replaced auth header
246+
const reqInit: RequestInit = {
247+
method: c.req.method,
248+
headers: {
249+
...Object.fromEntries(c.req.raw.headers),
250+
...headers,
251+
},
252+
};
253+
254+
// Include body for non-GET requests
255+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
256+
reqInit.body = c.req.raw.body;
257+
}
258+
259+
return fetch(url.toString(), reqInit);
188260
});
189261

190262
export default app;

packages/pds/src/service-auth.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Secp256k1Keypair, randomStr } from "@atproto/crypto";
2+
3+
const MINUTE = 60 * 1000;
4+
5+
type ServiceJwtParams = {
6+
iss: string;
7+
aud: string;
8+
lxm: string | null;
9+
keypair: Secp256k1Keypair;
10+
};
11+
12+
function jsonToB64Url(json: Record<string, unknown>): string {
13+
return Buffer.from(JSON.stringify(json)).toString("base64url");
14+
}
15+
16+
function noUndefinedVals<T extends Record<string, unknown>>(
17+
obj: T,
18+
): Partial<T> {
19+
const result: Partial<T> = {};
20+
for (const [key, val] of Object.entries(obj)) {
21+
if (val !== undefined) {
22+
result[key as keyof T] = val as T[keyof T];
23+
}
24+
}
25+
return result;
26+
}
27+
28+
/**
29+
* Create a service JWT for proxied requests to AppView.
30+
* The JWT asserts that the PDS vouches for the user identified by `iss`.
31+
*/
32+
export async function createServiceJwt(
33+
params: ServiceJwtParams,
34+
): Promise<string> {
35+
const { iss, aud, keypair } = params;
36+
const iat = Math.floor(Date.now() / 1000);
37+
const exp = iat + MINUTE / 1000;
38+
const lxm = params.lxm ?? undefined;
39+
const jti = randomStr(16, "hex");
40+
41+
const header = {
42+
typ: "JWT",
43+
alg: keypair.jwtAlg,
44+
};
45+
46+
const payload = noUndefinedVals({
47+
iat,
48+
iss,
49+
aud,
50+
exp,
51+
lxm,
52+
jti,
53+
});
54+
55+
const toSignStr = `${jsonToB64Url(header)}.${jsonToB64Url(payload as Record<string, unknown>)}`;
56+
const toSign = Buffer.from(toSignStr, "utf8");
57+
const sig = Buffer.from(await keypair.sign(toSign));
58+
59+
return `${toSignStr}.${sig.toString("base64url")}`;
60+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect } from "vitest";
2+
import { Secp256k1Keypair } from "@atproto/crypto";
3+
import { createServiceJwt } from "../src/service-auth";
4+
5+
describe("Service Auth", () => {
6+
it("creates valid service JWT", async () => {
7+
const keypair = await Secp256k1Keypair.create({ exportable: true });
8+
9+
const jwt = await createServiceJwt({
10+
iss: "did:web:alice.test",
11+
aud: "did:web:api.bsky.app",
12+
lxm: "app.bsky.feed.getTimeline",
13+
keypair,
14+
});
15+
16+
// JWT should have three parts
17+
const parts = jwt.split(".");
18+
expect(parts).toHaveLength(3);
19+
20+
// Decode header
21+
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString());
22+
expect(header.typ).toBe("JWT");
23+
expect(header.alg).toBe("ES256K");
24+
25+
// Decode payload
26+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
27+
expect(payload.iss).toBe("did:web:alice.test");
28+
expect(payload.aud).toBe("did:web:api.bsky.app");
29+
expect(payload.lxm).toBe("app.bsky.feed.getTimeline");
30+
expect(payload.iat).toBeTypeOf("number");
31+
expect(payload.exp).toBeTypeOf("number");
32+
expect(payload.jti).toBeTypeOf("string");
33+
34+
// Expiry should be ~60 seconds from iat
35+
expect(payload.exp - payload.iat).toBe(60);
36+
});
37+
38+
it("creates JWT without lxm when null", async () => {
39+
const keypair = await Secp256k1Keypair.create({ exportable: true });
40+
41+
const jwt = await createServiceJwt({
42+
iss: "did:web:alice.test",
43+
aud: "did:web:api.bsky.app",
44+
lxm: null,
45+
keypair,
46+
});
47+
48+
const parts = jwt.split(".");
49+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
50+
51+
// lxm should not be present when null
52+
expect(payload.lxm).toBeUndefined();
53+
});
54+
55+
it("creates verifiable signature", async () => {
56+
const keypair = await Secp256k1Keypair.create({ exportable: true });
57+
58+
const jwt = await createServiceJwt({
59+
iss: "did:web:alice.test",
60+
aud: "did:web:api.bsky.app",
61+
lxm: "com.atproto.sync.getRepo",
62+
keypair,
63+
});
64+
65+
const parts = jwt.split(".");
66+
const msgBytes = Buffer.from(parts.slice(0, 2).join("."), "utf8");
67+
const sigBytes = Buffer.from(parts[2], "base64url");
68+
69+
// Import verify function and use did:key format
70+
const { verifySignature } = await import("@atproto/crypto");
71+
const didKey = keypair.did();
72+
73+
const isValid = await verifySignature(didKey, msgBytes, sigBytes, {
74+
jwtAlg: "ES256K",
75+
allowMalleableSig: true,
76+
});
77+
78+
expect(isValid).toBe(true);
79+
});
80+
});

0 commit comments

Comments
 (0)