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 * Create a refresh JWT for ATProto 600 * @param {string} did - User's DID (subject and audience) 601 * @param {string} secret - JWT signing secret 602 - * @param {number} [expiresIn=7776000] - Expiration in seconds (default 90 days) 603 * @returns {Promise<string>} Signed JWT 604 */ 605 - export async function createRefreshJwt(did, secret, expiresIn = 7776000) { 606 const header = { typ: 'refresh+jwt', alg: 'HS256' }; 607 const now = Math.floor(Date.now() / 1000); 608 // Generate random jti (token ID) ··· 631 } 632 633 /** 634 - * Verify and decode an access JWT 635 * @param {string} jwt - JWT string to verify 636 * @param {string} secret - JWT signing secret 637 - * @returns {Promise<Object>} Decoded payload 638 * @throws {Error} If token is invalid, expired, or wrong type 639 */ 640 - export async function verifyAccessJwt(jwt, secret) { 641 const parts = jwt.split('.'); 642 if (parts.length !== 3) { 643 throw new Error('Invalid JWT format'); ··· 660 ); 661 662 // Check token type 663 - if (header.typ !== 'at+jwt') { 664 - throw new Error('Invalid token type: expected access token'); 665 } 666 667 // Check expiration 668 const now = Math.floor(Date.now() / 1000); 669 if (payload.exp && payload.exp < now) { 670 throw new Error('Token expired'); 671 } 672 673 return payload; ··· 1063 }, 1064 '/xrpc/com.atproto.server.getSession': { 1065 handler: (pds, req, _url) => pds.handleGetSession(req), 1066 }, 1067 '/xrpc/app.bsky.actor.getPreferences': { 1068 handler: (pds, req, _url) => pds.handleGetPreferences(req), ··· 1655 const expectedPassword = this.env?.PDS_PASSWORD; 1656 if (!expectedPassword || password !== expectedPassword) { 1657 return errorResponse( 1658 - 'AuthenticationRequired', 1659 'Invalid identifier or password', 1660 401, 1661 ); ··· 1701 const authHeader = request.headers.get('Authorization'); 1702 if (!authHeader || !authHeader.startsWith('Bearer ')) { 1703 return errorResponse( 1704 - 'AuthenticationRequired', 1705 'Missing or invalid authorization header', 1706 401, 1707 ); ··· 1732 } 1733 } 1734 1735 async handleGetPreferences(_request) { 1736 // Preferences are stored per-user in their DO 1737 const preferences = (await this.state.storage.get('preferences')) || []; ··· 2315 return { 2316 error: Response.json( 2317 { 2318 - error: 'AuthenticationRequired', 2319 message: 'Authentication required', 2320 }, 2321 { status: 401 }, ··· 2432 2433 // getSession - route to default DO 2434 if (url.pathname === '/xrpc/com.atproto.server.getSession') { 2435 const defaultId = env.PDS.idFromName('default'); 2436 const defaultPds = env.PDS.get(defaultId); 2437 return defaultPds.fetch(request);
··· 599 * Create a refresh JWT for ATProto 600 * @param {string} did - User's DID (subject and audience) 601 * @param {string} secret - JWT signing secret 602 + * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours) 603 * @returns {Promise<string>} Signed JWT 604 */ 605 + export async function createRefreshJwt(did, secret, expiresIn = 86400) { 606 const header = { typ: 'refresh+jwt', alg: 'HS256' }; 607 const now = Math.floor(Date.now() / 1000); 608 // Generate random jti (token ID) ··· 631 } 632 633 /** 634 + * Verify and decode a JWT (shared logic) 635 * @param {string} jwt - JWT string to verify 636 * @param {string} secret - JWT signing secret 637 + * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt') 638 + * @returns {Promise<{header: Object, payload: Object}>} Decoded header and payload 639 * @throws {Error} If token is invalid, expired, or wrong type 640 */ 641 + async function verifyJwt(jwt, secret, expectedType) { 642 const parts = jwt.split('.'); 643 if (parts.length !== 3) { 644 throw new Error('Invalid JWT format'); ··· 661 ); 662 663 // Check token type 664 + if (header.typ !== expectedType) { 665 + throw new Error(`Invalid token type: expected ${expectedType}`); 666 } 667 668 // Check expiration 669 const now = Math.floor(Date.now() / 1000); 670 if (payload.exp && payload.exp < now) { 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'); 702 } 703 704 return payload; ··· 1094 }, 1095 '/xrpc/com.atproto.server.getSession': { 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), 1101 }, 1102 '/xrpc/app.bsky.actor.getPreferences': { 1103 handler: (pds, req, _url) => pds.handleGetPreferences(req), ··· 1690 const expectedPassword = this.env?.PDS_PASSWORD; 1691 if (!expectedPassword || password !== expectedPassword) { 1692 return errorResponse( 1693 + 'AuthRequired', 1694 'Invalid identifier or password', 1695 401, 1696 ); ··· 1736 const authHeader = request.headers.get('Authorization'); 1737 if (!authHeader || !authHeader.startsWith('Bearer ')) { 1738 return errorResponse( 1739 + 'AuthRequired', 1740 'Missing or invalid authorization header', 1741 401, 1742 ); ··· 1767 } 1768 } 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 + 1814 async handleGetPreferences(_request) { 1815 // Preferences are stored per-user in their DO 1816 const preferences = (await this.state.storage.get('preferences')) || []; ··· 2394 return { 2395 error: Response.json( 2396 { 2397 + error: 'AuthRequired', 2398 message: 'Authentication required', 2399 }, 2400 { status: 401 }, ··· 2511 2512 // getSession - route to default DO 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') { 2521 const defaultId = env.PDS.idFromName('default'); 2522 const defaultPds = env.PDS.get(defaultId); 2523 return defaultPds.fetch(request);
+51 -23
test/e2e.sh
··· 51 echo "Running tests..." 52 echo 53 54 - # 1. Root returns ASCII art 55 curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" 56 57 - # 2. describeServer works 58 curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && 59 pass "describeServer" || fail "describeServer" 60 61 - # 3. resolveHandle works 62 curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | 63 jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" 64 65 - # 4. createSession returns tokens 66 SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 67 -H "Content-Type: application/json" \ 68 -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") 69 TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') 70 [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" 71 72 - # 5. getSession works with token 73 curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 74 -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && 75 pass "getSession with valid token" || fail "getSession" 76 77 - # 6. Protected endpoint rejects without auth 78 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 79 -H "Content-Type: application/json" \ 80 -d '{"repo":"x","collection":"x","record":{}}') 81 [ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" 82 83 - # 7. getPreferences works (returns empty array initially) 84 curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ 85 -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && 86 pass "getPreferences" || fail "getPreferences" 87 88 - # 8. putPreferences works 89 curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ 90 -H "Authorization: Bearer $TOKEN" \ 91 -H "Content-Type: application/json" \ 92 -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && 93 pass "putPreferences" || fail "putPreferences" 94 95 - # 9. createRecord works with auth 96 RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 97 -H "Authorization: Bearer $TOKEN" \ 98 -H "Content-Type: application/json" \ ··· 100 URI=$(echo "$RECORD" | jq -r '.uri') 101 [ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" 102 103 - # 10. getRecord retrieves it 104 RKEY=$(echo "$URI" | sed 's|.*/||') 105 curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | 106 jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" 107 108 - # 11. putRecord updates the record 109 curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ 110 -H "Authorization: Bearer $TOKEN" \ 111 -H "Content-Type: application/json" \ 112 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | 113 jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" 114 115 - # 12. listRecords shows the record 116 curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | 117 jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" 118 119 - # 13. describeRepo returns repo info 120 curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | 121 jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" 122 123 - # 14. applyWrites batch operation (create then delete a record) 124 APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 125 -H "Authorization: Bearer $TOKEN" \ 126 -H "Content-Type: application/json" \ 127 -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 echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" 129 130 - # 15. applyWrites delete 131 curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 132 -H "Authorization: Bearer $TOKEN" \ 133 -H "Content-Type: application/json" \ 134 -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | 135 jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" 136 137 - # 16. sync.getLatestCommit returns head 138 curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | 139 jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" 140 141 - # 17. sync.getRepoStatus returns status 142 curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | 143 jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" 144 145 - # 18. sync.getRepo returns CAR file 146 REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) 147 [ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" 148 149 - # 19. sync.getRecord returns record with proof (binary CAR data) 150 RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) 151 [ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" 152 153 - # 20. sync.listRepos lists repos 154 curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | 155 jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" 156 ··· 158 echo 159 echo "Testing error handling..." 160 161 - # 21. Invalid password rejected 162 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 163 -H "Content-Type: application/json" \ 164 -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") 165 [ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" 166 167 - # 22. Wrong repo rejected (can't modify another user's repo) 168 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 169 -H "Authorization: Bearer $TOKEN" \ 170 -H "Content-Type: application/json" \ 171 -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') 172 [ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" 173 174 - # 23. Non-existent record returns 404 175 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 [ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" 177
··· 51 echo "Running tests..." 52 echo 53 54 + # Root returns ASCII art 55 curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" 56 57 + # describeServer works 58 curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && 59 pass "describeServer" || fail "describeServer" 60 61 + # resolveHandle works 62 curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | 63 jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" 64 65 + # createSession returns tokens 66 SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 67 -H "Content-Type: application/json" \ 68 -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") 69 TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') 70 [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" 71 72 + # getSession works with token 73 curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 74 -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && 75 pass "getSession with valid token" || fail "getSession" 76 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 106 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 107 -H "Content-Type: application/json" \ 108 -d '{"repo":"x","collection":"x","record":{}}') 109 [ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" 110 111 + # getPreferences works (returns empty array initially) 112 curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ 113 -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && 114 pass "getPreferences" || fail "getPreferences" 115 116 + # putPreferences works 117 curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ 118 -H "Authorization: Bearer $TOKEN" \ 119 -H "Content-Type: application/json" \ 120 -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && 121 pass "putPreferences" || fail "putPreferences" 122 123 + # createRecord works with auth 124 RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 125 -H "Authorization: Bearer $TOKEN" \ 126 -H "Content-Type: application/json" \ ··· 128 URI=$(echo "$RECORD" | jq -r '.uri') 129 [ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" 130 131 + # getRecord retrieves it 132 RKEY=$(echo "$URI" | sed 's|.*/||') 133 curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | 134 jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" 135 136 + # putRecord updates the record 137 curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ 138 -H "Authorization: Bearer $TOKEN" \ 139 -H "Content-Type: application/json" \ 140 -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | 141 jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" 142 143 + # listRecords shows the record 144 curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | 145 jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" 146 147 + # describeRepo returns repo info 148 curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | 149 jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" 150 151 + # applyWrites batch operation (create then delete a record) 152 APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 153 -H "Authorization: Bearer $TOKEN" \ 154 -H "Content-Type: application/json" \ 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)\"}}]}") 156 echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" 157 158 + # applyWrites delete 159 curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 160 -H "Authorization: Bearer $TOKEN" \ 161 -H "Content-Type: application/json" \ 162 -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | 163 jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" 164 165 + # sync.getLatestCommit returns head 166 curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | 167 jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" 168 169 + # sync.getRepoStatus returns status 170 curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | 171 jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" 172 173 + # sync.getRepo returns CAR file 174 REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) 175 [ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" 176 177 + # sync.getRecord returns record with proof (binary CAR data) 178 RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) 179 [ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" 180 181 + # sync.listRepos lists repos 182 curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | 183 jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" 184 ··· 186 echo 187 echo "Testing error handling..." 188 189 + # Invalid password rejected 190 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 191 -H "Content-Type: application/json" \ 192 -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") 193 [ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" 194 195 + # Wrong repo rejected (can't modify another user's repo) 196 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 197 -H "Authorization: Bearer $TOKEN" \ 198 -H "Content-Type: application/json" \ 199 -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') 200 [ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" 201 202 + # Non-existent record returns 404 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") 204 [ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" 205
+78
test/pds.test.js
··· 21 sign, 22 varint, 23 verifyAccessJwt, 24 } from '../src/pds.js'; 25 26 describe('CBOR Encoding', () => { ··· 491 await assert.rejects( 492 () => verifyAccessJwt(jwt, secret), 493 /invalid token type/i, 494 ); 495 }); 496 });
··· 21 sign, 22 varint, 23 verifyAccessJwt, 24 + verifyRefreshJwt, 25 } from '../src/pds.js'; 26 27 describe('CBOR Encoding', () => { ··· 492 await assert.rejects( 493 () => verifyAccessJwt(jwt, secret), 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, 572 ); 573 }); 574 });