Skip to content

Commit 226f6f3

Browse files
ascorbicclaude
andauthored
feat(pds): implement JWT session authentication for Bluesky app login (#6)
Add session endpoints (createSession, refreshSession, getSession, deleteSession) with HS256 JWT signing using jose library and bcrypt password verification. Auth middleware now accepts both static AUTH_TOKEN and JWT access tokens. - createSession: login with identifier (handle/DID) + password - refreshSession: token rotation with refresh JWT - getSession: get current session info - Setup scripts for PASSWORD_HASH and JWT_SECRET secrets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 23e575a commit 226f6f3

12 files changed

Lines changed: 1215 additions & 8 deletions

File tree

EDGE_PDS_PLAN.md

Lines changed: 303 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Build a single-user AT Protocol Personal Data Server (PDS) on Cloudflare Workers
1212

1313
**Live at: https://pds.mk.gg**
1414

15-
### Completed (Phase 1 + Phase 2 + Phase 3 + Phase 4 + Phase 5 + Phase 6 + Phase 7)
15+
### Completed (Phase 1-8)
1616

1717
-**Storage Layer** (Phase 1) - `SqliteRepoStorage` implementing `@atproto/repo` RepoStorage interface
1818
-**Durable Object** (Phase 2) - `AccountDurableObject` with Repo integration
@@ -47,11 +47,12 @@ Build a single-user AT Protocol Personal Data Server (PDS) on Cloudflare Workers
4747
- `com.atproto.sync.getBlob` endpoint (public read access)
4848
- Direct R2 access in endpoint (R2ObjectBody cannot be serialized across RPC)
4949
- Blobs stored with DID prefix for isolation
50-
-**Testing** - Migrated to vitest 4, all 58 tests passing
50+
-**Testing** - Migrated to vitest 4, all 73 tests passing
5151
- 16 storage tests
5252
- 26 XRPC tests (auth, concurrency, error handling, CAR validation)
5353
- 6 firehose tests (event sequencing, cursor validation, backfill)
5454
- 10 blob tests (upload, retrieval, size limits, content types)
55+
- 15 session tests (login, refresh, getSession, JWT validation)
5556
-**TypeScript** - All diagnostic errors resolved, proper type declarations for cloudflare:test
5657
-**Protocol Helpers** - All protocol operations use official @atproto utilities
5758
- Record keys: `TID.nextStr()` from `@atproto/common-web`
@@ -65,6 +66,15 @@ Build a single-user AT Protocol Personal Data Server (PDS) on Cloudflare Workers
6566
- Removed: `varint`, `@types/varint`, `cborg`, `uint8arrays`, `@ipld/dag-cbor`, `multiformats`
6667
- Added: `@atproto/lex-data`, `@atproto/lex-cbor`, `@atproto/common-web`
6768
- Net reduction: 116 lines, better standards compliance
69+
-**Session Authentication** (Phase 8) - JWT-based login for Bluesky app compatibility
70+
- `com.atproto.server.createSession` - login with identifier + password
71+
- `com.atproto.server.refreshSession` - token rotation with refresh JWT
72+
- `com.atproto.server.getSession` - get current session info
73+
- `com.atproto.server.deleteSession` - logout (stateless, client-side)
74+
- HS256 JWT signing with `jose` library (matches reference implementation)
75+
- bcrypt password hashing with `bcryptjs`
76+
- Auth middleware accepts both static `AUTH_TOKEN` and JWT access tokens
77+
- 15 new tests for session endpoints
6878

6979
### Not Started
7080

@@ -1577,6 +1587,292 @@ describe("Authentication", () => {
15771587

15781588
---
15791589

1590+
### Phase 8: Session Authentication
1591+
1592+
**Goal:** Enable login from Bluesky app and other AT Protocol clients via JWT sessions.
1593+
1594+
#### Overview
1595+
1596+
For single-user PDS, we simplify session auth:
1597+
- Password stored as bcrypt hash in environment variable (`PASSWORD_HASH`)
1598+
- JWTs signed with existing signing key (secp256k1)
1599+
- Access tokens short-lived (2 hours), refresh tokens longer (90 days)
1600+
- No email, no MFA, no complex account states
1601+
1602+
#### Required Endpoints
1603+
1604+
```typescript
1605+
// com.atproto.server.createSession
1606+
// POST - authenticate with identifier + password
1607+
Input: { identifier: string, password: string }
1608+
Output: { accessJwt, refreshJwt, handle, did, didDoc?, active: true }
1609+
1610+
// com.atproto.server.refreshSession
1611+
// POST - refresh tokens using refresh JWT (in Authorization header)
1612+
Output: { accessJwt, refreshJwt, handle, did, didDoc?, active: true }
1613+
1614+
// com.atproto.server.getSession
1615+
// GET - get current session info (requires access JWT)
1616+
Output: { handle, did, email?, didDoc?, active: true }
1617+
1618+
// com.atproto.server.deleteSession
1619+
// POST - logout (requires refresh JWT)
1620+
Output: {}
1621+
```
1622+
1623+
#### JWT Token Format
1624+
1625+
Per AT Protocol spec, use RFC 9068 token types:
1626+
1627+
```typescript
1628+
// Access Token (short-lived: 2 hours)
1629+
{
1630+
typ: "at+jwt",
1631+
alg: "ES256K", // secp256k1
1632+
}
1633+
{
1634+
iss: "did:web:pds.example.com", // PDS DID
1635+
aud: "did:web:pds.example.com", // Same for self-issued
1636+
sub: "did:web:user.example.com", // User DID
1637+
iat: 1234567890,
1638+
exp: 1234575090, // +2 hours
1639+
scope: "atproto",
1640+
}
1641+
1642+
// Refresh Token (long-lived: 90 days)
1643+
{
1644+
typ: "refresh+jwt",
1645+
alg: "ES256K",
1646+
}
1647+
{
1648+
iss: "did:web:pds.example.com",
1649+
aud: "did:web:pds.example.com",
1650+
sub: "did:web:user.example.com",
1651+
iat: 1234567890,
1652+
exp: 1242343890, // +90 days
1653+
jti: "unique-token-id", // For revocation
1654+
scope: "com.atproto.refresh",
1655+
}
1656+
```
1657+
1658+
#### Implementation
1659+
1660+
```typescript
1661+
// src/session.ts
1662+
import { Secp256k1Keypair } from "@atproto/crypto";
1663+
1664+
const ACCESS_TOKEN_LIFETIME = 2 * 60 * 60; // 2 hours
1665+
const REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60; // 90 days
1666+
1667+
export async function createAccessToken(
1668+
keypair: Secp256k1Keypair,
1669+
did: string,
1670+
pdsDid: string,
1671+
): Promise<string> {
1672+
const now = Math.floor(Date.now() / 1000);
1673+
const payload = {
1674+
iss: pdsDid,
1675+
aud: pdsDid,
1676+
sub: did,
1677+
iat: now,
1678+
exp: now + ACCESS_TOKEN_LIFETIME,
1679+
scope: "atproto",
1680+
};
1681+
return signJwt(keypair, payload, "at+jwt");
1682+
}
1683+
1684+
export async function createRefreshToken(
1685+
keypair: Secp256k1Keypair,
1686+
did: string,
1687+
pdsDid: string,
1688+
): Promise<string> {
1689+
const now = Math.floor(Date.now() / 1000);
1690+
const jti = crypto.randomUUID();
1691+
const payload = {
1692+
iss: pdsDid,
1693+
aud: pdsDid,
1694+
sub: did,
1695+
iat: now,
1696+
exp: now + REFRESH_TOKEN_LIFETIME,
1697+
jti,
1698+
scope: "com.atproto.refresh",
1699+
};
1700+
return signJwt(keypair, payload, "refresh+jwt");
1701+
}
1702+
1703+
async function signJwt(
1704+
keypair: Secp256k1Keypair,
1705+
payload: Record<string, unknown>,
1706+
typ: string,
1707+
): Promise<string> {
1708+
const header = { alg: "ES256K", typ };
1709+
const headerB64 = base64url(JSON.stringify(header));
1710+
const payloadB64 = base64url(JSON.stringify(payload));
1711+
const signingInput = `${headerB64}.${payloadB64}`;
1712+
const signature = await keypair.sign(new TextEncoder().encode(signingInput));
1713+
return `${signingInput}.${base64url(signature)}`;
1714+
}
1715+
```
1716+
1717+
#### Password Verification
1718+
1719+
```typescript
1720+
// Use bcrypt for password hashing (via Web Crypto compatible library)
1721+
import { compare } from "bcryptjs"; // Works in Workers
1722+
1723+
export async function verifyPassword(
1724+
password: string,
1725+
hash: string,
1726+
): Promise<boolean> {
1727+
return compare(password, hash);
1728+
}
1729+
```
1730+
1731+
#### Auth Middleware Update
1732+
1733+
```typescript
1734+
// src/middleware/auth.ts
1735+
export async function requireAuth(c: Context, next: Next) {
1736+
const authHeader = c.req.header("Authorization");
1737+
1738+
if (!authHeader?.startsWith("Bearer ")) {
1739+
return c.json({ error: "AuthRequired" }, 401);
1740+
}
1741+
1742+
const token = authHeader.slice(7);
1743+
1744+
// Try static token first (backwards compat)
1745+
if (token === c.env.AUTH_TOKEN) {
1746+
return next();
1747+
}
1748+
1749+
// Try JWT verification
1750+
try {
1751+
const payload = await verifyAccessToken(token, c.env);
1752+
c.set("auth", { did: payload.sub, scope: payload.scope });
1753+
return next();
1754+
} catch {
1755+
return c.json({ error: "InvalidToken" }, 401);
1756+
}
1757+
}
1758+
```
1759+
1760+
#### Configuration
1761+
1762+
New environment variable:
1763+
- `PASSWORD_HASH` - bcrypt hash of user password (generate with `npx bcryptjs hash "password"`)
1764+
1765+
#### Testing Strategy
1766+
1767+
```typescript
1768+
// test/session.test.ts
1769+
describe("Session Authentication", () => {
1770+
it("creates session with valid credentials", async () => {
1771+
const response = await SELF.fetch(
1772+
"https://pds.test/xrpc/com.atproto.server.createSession",
1773+
{
1774+
method: "POST",
1775+
headers: { "Content-Type": "application/json" },
1776+
body: JSON.stringify({
1777+
identifier: "alice.test",
1778+
password: "test-password",
1779+
}),
1780+
}
1781+
);
1782+
1783+
expect(response.status).toBe(200);
1784+
const body = await response.json();
1785+
expect(body.accessJwt).toBeDefined();
1786+
expect(body.refreshJwt).toBeDefined();
1787+
expect(body.did).toBe("did:web:pds.test");
1788+
expect(body.handle).toBe("alice.test");
1789+
});
1790+
1791+
it("rejects invalid password", async () => {
1792+
const response = await SELF.fetch(
1793+
"https://pds.test/xrpc/com.atproto.server.createSession",
1794+
{
1795+
method: "POST",
1796+
headers: { "Content-Type": "application/json" },
1797+
body: JSON.stringify({
1798+
identifier: "alice.test",
1799+
password: "wrong-password",
1800+
}),
1801+
}
1802+
);
1803+
1804+
expect(response.status).toBe(401);
1805+
});
1806+
1807+
it("uses access token for authenticated requests", async () => {
1808+
// Login
1809+
const loginRes = await SELF.fetch(
1810+
"https://pds.test/xrpc/com.atproto.server.createSession",
1811+
{
1812+
method: "POST",
1813+
headers: { "Content-Type": "application/json" },
1814+
body: JSON.stringify({
1815+
identifier: "alice.test",
1816+
password: "test-password",
1817+
}),
1818+
}
1819+
);
1820+
const { accessJwt } = await loginRes.json();
1821+
1822+
// Use token
1823+
const response = await SELF.fetch(
1824+
"https://pds.test/xrpc/com.atproto.repo.createRecord",
1825+
{
1826+
method: "POST",
1827+
headers: {
1828+
"Content-Type": "application/json",
1829+
Authorization: `Bearer ${accessJwt}`,
1830+
},
1831+
body: JSON.stringify({
1832+
repo: "did:web:pds.test",
1833+
collection: "app.bsky.feed.post",
1834+
record: { text: "Hello!", createdAt: new Date().toISOString() },
1835+
}),
1836+
}
1837+
);
1838+
1839+
expect(response.status).toBe(200);
1840+
});
1841+
1842+
it("refreshes session with refresh token", async () => {
1843+
// Login
1844+
const loginRes = await SELF.fetch(
1845+
"https://pds.test/xrpc/com.atproto.server.createSession",
1846+
{
1847+
method: "POST",
1848+
headers: { "Content-Type": "application/json" },
1849+
body: JSON.stringify({
1850+
identifier: "alice.test",
1851+
password: "test-password",
1852+
}),
1853+
}
1854+
);
1855+
const { refreshJwt } = await loginRes.json();
1856+
1857+
// Refresh
1858+
const response = await SELF.fetch(
1859+
"https://pds.test/xrpc/com.atproto.server.refreshSession",
1860+
{
1861+
method: "POST",
1862+
headers: { Authorization: `Bearer ${refreshJwt}` },
1863+
}
1864+
);
1865+
1866+
expect(response.status).toBe(200);
1867+
const body = await response.json();
1868+
expect(body.accessJwt).toBeDefined();
1869+
expect(body.refreshJwt).toBeDefined();
1870+
});
1871+
});
1872+
```
1873+
1874+
---
1875+
15801876
## Testing Configuration
15811877
15821878
### Vitest Setup
@@ -1687,7 +1983,9 @@ describe("Federation Integration", () => {
16871983
| `SIGNING_KEY` | Secret | Private key for signing commits (hex or multibase) |
16881984
| `SIGNING_KEY_PUBLIC` | Secret | Public key for DID document |
16891985
| `HANDLE` | Variable | The account's handle |
1690-
| `AUTH_TOKEN` | Secret | Bearer token for write auth (MVP) |
1986+
| `AUTH_TOKEN` | Secret | Bearer token for write auth (API access) |
1987+
| `JWT_SECRET` | Secret | HS256 secret for session tokens (min 32 chars) |
1988+
| `PASSWORD_HASH` | Secret | Bcrypt hash for app login (optional) |
16911989
| `PDS_HOSTNAME` | Variable | Public hostname of the PDS |
16921990
16931991
Set secrets via:
@@ -1697,6 +1995,8 @@ wrangler secret put DID
16971995
wrangler secret put SIGNING_KEY
16981996
wrangler secret put SIGNING_KEY_PUBLIC
16991997
wrangler secret put AUTH_TOKEN
1998+
wrangler secret put JWT_SECRET # Use a long random string (32+ chars)
1999+
wrangler secret put PASSWORD_HASH # Generate: npx bcryptjs hash "your-password"
17002000
```
17012001
17022002
---

packages/pds/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm",
2020
"test:firehose": "node scripts/load-env.js node scripts/test-firehose.js",
2121
"test:firehose:prod": "PDS_URL=https://pds.mk.gg node scripts/load-env.js node scripts/test-firehose.js",
22-
"create-post": "node scripts/load-env.js node scripts/create-post.js"
22+
"create-post": "node scripts/load-env.js node scripts/create-post.js",
23+
"setup:password": "node scripts/set-password.mjs",
24+
"setup:jwt-secret": "node scripts/set-jwt-secret.mjs"
2325
},
2426
"dependencies": {
2527
"@atproto/common-web": "^0.4.7",
@@ -29,10 +31,13 @@
2931
"@atproto/lexicon": "^0.6.0",
3032
"@atproto/repo": "^0.8.12",
3133
"@atproto/syntax": "^0.4.2",
32-
"hono": "^4.11.3"
34+
"bcryptjs": "^3.0.3",
35+
"hono": "^4.11.3",
36+
"jose": "^6.1.3"
3337
},
3438
"devDependencies": {
3539
"@arethetypeswrong/cli": "^0.18.2",
40+
"@clack/prompts": "^0.11.0",
3641
"@cloudflare/vite-plugin": "^1.17.0",
3742
"@cloudflare/vitest-pool-workers": "https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632",
3843
"@cloudflare/workers-types": "^4.20251225.0",

0 commit comments

Comments
 (0)