A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

feat: add refreshSession endpoint

Implement com.atproto.server.refreshSession for token refresh:
- Add verifyRefreshJwt with audience validation
- Extract shared verifyJwt helper to reduce duplication
- Change refresh token expiration from 90 days to 24 hours
- Fix error name AuthenticationRequired → AuthRequired

Tests:
- Unit tests for verifyRefreshJwt (valid, expired, wrong type, malformed)
- E2E tests for happy path and error cases
- Remove test numbers from e2e.sh for easier maintenance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+225 -33
src
test
+96 -10
src/pds.js
··· 599 599 * Create a refresh JWT for ATProto 600 600 * @param {string} did - User's DID (subject and audience) 601 601 * @param {string} secret - JWT signing secret 602 - * @param {number} [expiresIn=7776000] - Expiration in seconds (default 90 days) 602 + * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours) 603 603 * @returns {Promise<string>} Signed JWT 604 604 */ 605 - export async function createRefreshJwt(did, secret, expiresIn = 7776000) { 605 + export async function createRefreshJwt(did, secret, expiresIn = 86400) { 606 606 const header = { typ: 'refresh+jwt', alg: 'HS256' }; 607 607 const now = Math.floor(Date.now() / 1000); 608 608 // Generate random jti (token ID) ··· 631 631 } 632 632 633 633 /** 634 - * Verify and decode an access JWT 634 + * Verify and decode a JWT (shared logic) 635 635 * @param {string} jwt - JWT string to verify 636 636 * @param {string} secret - JWT signing secret 637 - * @returns {Promise<Object>} Decoded payload 637 + * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt') 638 + * @returns {Promise<{header: Object, payload: Object}>} Decoded header and payload 638 639 * @throws {Error} If token is invalid, expired, or wrong type 639 640 */ 640 - export async function verifyAccessJwt(jwt, secret) { 641 + async function verifyJwt(jwt, secret, expectedType) { 641 642 const parts = jwt.split('.'); 642 643 if (parts.length !== 3) { 643 644 throw new Error('Invalid JWT format'); ··· 660 661 ); 661 662 662 663 // Check token type 663 - if (header.typ !== 'at+jwt') { 664 - throw new Error('Invalid token type: expected access token'); 664 + if (header.typ !== expectedType) { 665 + throw new Error(`Invalid token type: expected ${expectedType}`); 665 666 } 666 667 667 668 // Check expiration 668 669 const now = Math.floor(Date.now() / 1000); 669 670 if (payload.exp && payload.exp < now) { 670 671 throw new Error('Token expired'); 672 + } 673 + 674 + return { header, payload }; 675 + } 676 + 677 + /** 678 + * Verify and decode an access JWT 679 + * @param {string} jwt - JWT string to verify 680 + * @param {string} secret - JWT signing secret 681 + * @returns {Promise<Object>} Decoded payload 682 + * @throws {Error} If token is invalid, expired, or wrong type 683 + */ 684 + export async function verifyAccessJwt(jwt, secret) { 685 + const { payload } = await verifyJwt(jwt, secret, 'at+jwt'); 686 + return payload; 687 + } 688 + 689 + /** 690 + * Verify and decode a refresh JWT 691 + * @param {string} jwt - JWT string to verify 692 + * @param {string} secret - JWT signing secret 693 + * @returns {Promise<Object>} Decoded payload 694 + * @throws {Error} If token is invalid, expired, or wrong type 695 + */ 696 + export async function verifyRefreshJwt(jwt, secret) { 697 + const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt'); 698 + 699 + // Validate audience matches subject (token intended for this user) 700 + if (payload.aud && payload.aud !== payload.sub) { 701 + throw new Error('Invalid audience'); 671 702 } 672 703 673 704 return payload; ··· 1063 1094 }, 1064 1095 '/xrpc/com.atproto.server.getSession': { 1065 1096 handler: (pds, req, _url) => pds.handleGetSession(req), 1097 + }, 1098 + '/xrpc/com.atproto.server.refreshSession': { 1099 + method: 'POST', 1100 + handler: (pds, req, _url) => pds.handleRefreshSession(req), 1066 1101 }, 1067 1102 '/xrpc/app.bsky.actor.getPreferences': { 1068 1103 handler: (pds, req, _url) => pds.handleGetPreferences(req), ··· 1655 1690 const expectedPassword = this.env?.PDS_PASSWORD; 1656 1691 if (!expectedPassword || password !== expectedPassword) { 1657 1692 return errorResponse( 1658 - 'AuthenticationRequired', 1693 + 'AuthRequired', 1659 1694 'Invalid identifier or password', 1660 1695 401, 1661 1696 ); ··· 1701 1736 const authHeader = request.headers.get('Authorization'); 1702 1737 if (!authHeader || !authHeader.startsWith('Bearer ')) { 1703 1738 return errorResponse( 1704 - 'AuthenticationRequired', 1739 + 'AuthRequired', 1705 1740 'Missing or invalid authorization header', 1706 1741 401, 1707 1742 ); ··· 1732 1767 } 1733 1768 } 1734 1769 1770 + async handleRefreshSession(request) { 1771 + const authHeader = request.headers.get('Authorization'); 1772 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 1773 + return errorResponse( 1774 + 'AuthRequired', 1775 + 'Missing or invalid authorization header', 1776 + 401, 1777 + ); 1778 + } 1779 + 1780 + const token = authHeader.slice(7); // Remove 'Bearer ' 1781 + const jwtSecret = this.env?.JWT_SECRET; 1782 + if (!jwtSecret) { 1783 + return errorResponse( 1784 + 'InternalServerError', 1785 + 'Server not configured for authentication', 1786 + 500, 1787 + ); 1788 + } 1789 + 1790 + try { 1791 + const payload = await verifyRefreshJwt(token, jwtSecret); 1792 + const did = payload.sub; 1793 + const handle = await this.getHandleForDid(did); 1794 + 1795 + // Issue fresh tokens 1796 + const accessJwt = await createAccessJwt(did, jwtSecret); 1797 + const refreshJwt = await createRefreshJwt(did, jwtSecret); 1798 + 1799 + return Response.json({ 1800 + accessJwt, 1801 + refreshJwt, 1802 + handle: handle || did, 1803 + did, 1804 + active: true, 1805 + }); 1806 + } catch (err) { 1807 + if (err.message === 'Token expired') { 1808 + return errorResponse('ExpiredToken', 'Refresh token has expired', 400); 1809 + } 1810 + return errorResponse('InvalidToken', err.message, 400); 1811 + } 1812 + } 1813 + 1735 1814 async handleGetPreferences(_request) { 1736 1815 // Preferences are stored per-user in their DO 1737 1816 const preferences = (await this.state.storage.get('preferences')) || []; ··· 2315 2394 return { 2316 2395 error: Response.json( 2317 2396 { 2318 - error: 'AuthenticationRequired', 2397 + error: 'AuthRequired', 2319 2398 message: 'Authentication required', 2320 2399 }, 2321 2400 { status: 401 }, ··· 2432 2511 2433 2512 // getSession - route to default DO 2434 2513 if (url.pathname === '/xrpc/com.atproto.server.getSession') { 2514 + const defaultId = env.PDS.idFromName('default'); 2515 + const defaultPds = env.PDS.get(defaultId); 2516 + return defaultPds.fetch(request); 2517 + } 2518 + 2519 + // refreshSession - route to default DO 2520 + if (url.pathname === '/xrpc/com.atproto.server.refreshSession') { 2435 2521 const defaultId = env.PDS.idFromName('default'); 2436 2522 const defaultPds = env.PDS.get(defaultId); 2437 2523 return defaultPds.fetch(request);
+51 -23
test/e2e.sh
··· 51 51 echo "Running tests..." 52 52 echo 53 53 54 - # 1. Root returns ASCII art 54 + # Root returns ASCII art 55 55 curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" 56 56 57 - # 2. describeServer works 57 + # describeServer works 58 58 curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && 59 59 pass "describeServer" || fail "describeServer" 60 60 61 - # 3. resolveHandle works 61 + # resolveHandle works 62 62 curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | 63 63 jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" 64 64 65 - # 4. createSession returns tokens 65 + # createSession returns tokens 66 66 SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 67 67 -H "Content-Type: application/json" \ 68 68 -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") 69 69 TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') 70 70 [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" 71 71 72 - # 5. getSession works with token 72 + # getSession works with token 73 73 curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 74 74 -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && 75 75 pass "getSession with valid token" || fail "getSession" 76 76 77 - # 6. Protected endpoint rejects without auth 77 + # refreshSession returns new tokens 78 + REFRESH_TOKEN=$(echo "$SESSION" | jq -r '.refreshJwt') 79 + REFRESH_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 80 + -H "Authorization: Bearer $REFRESH_TOKEN") 81 + NEW_ACCESS=$(echo "$REFRESH_RESULT" | jq -r '.accessJwt') 82 + NEW_REFRESH=$(echo "$REFRESH_RESULT" | jq -r '.refreshJwt') 83 + [ "$NEW_ACCESS" != "null" ] && [ -n "$NEW_ACCESS" ] && [ "$NEW_REFRESH" != "null" ] && [ -n "$NEW_REFRESH" ] && 84 + pass "refreshSession returns new tokens" || fail "refreshSession" 85 + 86 + # New access token from refresh works 87 + curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 88 + -H "Authorization: Bearer $NEW_ACCESS" | jq -e '.did' >/dev/null && 89 + pass "refreshed access token works" || fail "refreshed token" 90 + 91 + # refreshSession rejects access token (wrong type) 92 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 93 + -H "Authorization: Bearer $TOKEN") 94 + [ "$STATUS" = "400" ] && pass "refreshSession rejects access token" || fail "refreshSession should reject access token" 95 + 96 + # refreshSession rejects missing auth 97 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession") 98 + [ "$STATUS" = "401" ] && pass "refreshSession rejects missing auth" || fail "refreshSession should require auth" 99 + 100 + # refreshSession rejects malformed token 101 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ 102 + -H "Authorization: Bearer not-a-valid-jwt") 103 + [ "$STATUS" = "400" ] && pass "refreshSession rejects malformed token" || fail "refreshSession should reject malformed token" 104 + 105 + # Protected endpoint rejects without auth 78 106 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 79 107 -H "Content-Type: application/json" \ 80 108 -d '{"repo":"x","collection":"x","record":{}}') 81 109 [ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" 82 110 83 - # 7. getPreferences works (returns empty array initially) 111 + # getPreferences works (returns empty array initially) 84 112 curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ 85 113 -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && 86 114 pass "getPreferences" || fail "getPreferences" 87 115 88 - # 8. putPreferences works 116 + # putPreferences works 89 117 curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ 90 118 -H "Authorization: Bearer $TOKEN" \ 91 119 -H "Content-Type: application/json" \ 92 120 -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && 93 121 pass "putPreferences" || fail "putPreferences" 94 122 95 - # 9. createRecord works with auth 123 + # createRecord works with auth 96 124 RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 97 125 -H "Authorization: Bearer $TOKEN" \ 98 126 -H "Content-Type: application/json" \ ··· 100 128 URI=$(echo "$RECORD" | jq -r '.uri') 101 129 [ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" 102 130 103 - # 10. getRecord retrieves it 131 + # getRecord retrieves it 104 132 RKEY=$(echo "$URI" | sed 's|.*/||') 105 133 curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | 106 134 jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" 107 135 108 - # 11. putRecord updates the record 136 + # putRecord updates the record 109 137 curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ 110 138 -H "Authorization: Bearer $TOKEN" \ 111 139 -H "Content-Type: application/json" \ 112 140 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | 113 141 jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" 114 142 115 - # 12. listRecords shows the record 143 + # listRecords shows the record 116 144 curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | 117 145 jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" 118 146 119 - # 13. describeRepo returns repo info 147 + # describeRepo returns repo info 120 148 curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | 121 149 jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" 122 150 123 - # 14. applyWrites batch operation (create then delete a record) 151 + # applyWrites batch operation (create then delete a record) 124 152 APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 125 153 -H "Authorization: Bearer $TOKEN" \ 126 154 -H "Content-Type: application/json" \ 127 155 -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#create\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\",\"value\":{\"text\":\"batch\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}]}") 128 156 echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" 129 157 130 - # 15. applyWrites delete 158 + # applyWrites delete 131 159 curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 132 160 -H "Authorization: Bearer $TOKEN" \ 133 161 -H "Content-Type: application/json" \ 134 162 -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | 135 163 jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" 136 164 137 - # 16. sync.getLatestCommit returns head 165 + # sync.getLatestCommit returns head 138 166 curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | 139 167 jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" 140 168 141 - # 17. sync.getRepoStatus returns status 169 + # sync.getRepoStatus returns status 142 170 curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | 143 171 jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" 144 172 145 - # 18. sync.getRepo returns CAR file 173 + # sync.getRepo returns CAR file 146 174 REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) 147 175 [ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" 148 176 149 - # 19. sync.getRecord returns record with proof (binary CAR data) 177 + # sync.getRecord returns record with proof (binary CAR data) 150 178 RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) 151 179 [ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" 152 180 153 - # 20. sync.listRepos lists repos 181 + # sync.listRepos lists repos 154 182 curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | 155 183 jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" 156 184 ··· 158 186 echo 159 187 echo "Testing error handling..." 160 188 161 - # 21. Invalid password rejected 189 + # Invalid password rejected 162 190 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 163 191 -H "Content-Type: application/json" \ 164 192 -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") 165 193 [ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" 166 194 167 - # 22. Wrong repo rejected (can't modify another user's repo) 195 + # Wrong repo rejected (can't modify another user's repo) 168 196 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 169 197 -H "Authorization: Bearer $TOKEN" \ 170 198 -H "Content-Type: application/json" \ 171 199 -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') 172 200 [ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" 173 201 174 - # 23. Non-existent record returns 404 202 + # Non-existent record returns 404 175 203 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=nonexistent") 176 204 [ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" 177 205
+78
test/pds.test.js
··· 21 21 sign, 22 22 varint, 23 23 verifyAccessJwt, 24 + verifyRefreshJwt, 24 25 } from '../src/pds.js'; 25 26 26 27 describe('CBOR Encoding', () => { ··· 491 492 await assert.rejects( 492 493 () => verifyAccessJwt(jwt, secret), 493 494 /invalid token type/i, 495 + ); 496 + }); 497 + 498 + test('verifyRefreshJwt returns payload for valid token', async () => { 499 + const did = 'did:web:test.example'; 500 + const secret = 'test-secret-key'; 501 + const jwt = await createRefreshJwt(did, secret); 502 + 503 + const payload = await verifyRefreshJwt(jwt, secret); 504 + assert.strictEqual(payload.sub, did); 505 + assert.strictEqual(payload.scope, 'com.atproto.refresh'); 506 + assert.ok(payload.jti); // has token ID 507 + }); 508 + 509 + test('verifyRefreshJwt throws for wrong secret', async () => { 510 + const did = 'did:web:test.example'; 511 + const jwt = await createRefreshJwt(did, 'correct-secret'); 512 + 513 + await assert.rejects( 514 + () => verifyRefreshJwt(jwt, 'wrong-secret'), 515 + /invalid signature/i, 516 + ); 517 + }); 518 + 519 + test('verifyRefreshJwt throws for expired token', async () => { 520 + const did = 'did:web:test.example'; 521 + const secret = 'test-secret-key'; 522 + // Create token that expired 1 second ago 523 + const jwt = await createRefreshJwt(did, secret, -1); 524 + 525 + await assert.rejects(() => verifyRefreshJwt(jwt, secret), /expired/i); 526 + }); 527 + 528 + test('verifyRefreshJwt throws for access token', async () => { 529 + const did = 'did:web:test.example'; 530 + const secret = 'test-secret-key'; 531 + const jwt = await createAccessJwt(did, secret); 532 + 533 + await assert.rejects( 534 + () => verifyRefreshJwt(jwt, secret), 535 + /invalid token type/i, 536 + ); 537 + }); 538 + 539 + test('verifyAccessJwt throws for malformed JWT', async () => { 540 + const secret = 'test-secret-key'; 541 + 542 + // Not a JWT at all 543 + await assert.rejects( 544 + () => verifyAccessJwt('not-a-jwt', secret), 545 + /Invalid JWT format/i, 546 + ); 547 + 548 + // Only two parts 549 + await assert.rejects( 550 + () => verifyAccessJwt('two.parts', secret), 551 + /Invalid JWT format/i, 552 + ); 553 + 554 + // Four parts 555 + await assert.rejects( 556 + () => verifyAccessJwt('one.two.three.four', secret), 557 + /Invalid JWT format/i, 558 + ); 559 + }); 560 + 561 + test('verifyRefreshJwt throws for malformed JWT', async () => { 562 + const secret = 'test-secret-key'; 563 + 564 + await assert.rejects( 565 + () => verifyRefreshJwt('not-a-jwt', secret), 566 + /Invalid JWT format/i, 567 + ); 568 + 569 + await assert.rejects( 570 + () => verifyRefreshJwt('two.parts', secret), 571 + /Invalid JWT format/i, 494 572 ); 495 573 }); 496 574 });