@@ -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
16931991Set secrets via:
@@ -1697,6 +1995,8 @@ wrangler secret put DID
16971995wrangler secret put SIGNING_KEY
16981996wrangler secret put SIGNING_KEY_PUBLIC
16991997wrangler 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---
0 commit comments