diff --git a/src/pds.js b/src/pds.js index c20c9fe..01bdd5c 100644 --- a/src/pds.js +++ b/src/pds.js @@ -599,10 +599,10 @@ export async function createAccessJwt(did, secret, expiresIn = 7200) { * Create a refresh JWT for ATProto * @param {string} did - User's DID (subject and audience) * @param {string} secret - JWT signing secret - * @param {number} [expiresIn=7776000] - Expiration in seconds (default 90 days) + * @param {number} [expiresIn=86400] - Expiration in seconds (default 24 hours) * @returns {Promise} Signed JWT */ -export async function createRefreshJwt(did, secret, expiresIn = 7776000) { +export async function createRefreshJwt(did, secret, expiresIn = 86400) { const header = { typ: 'refresh+jwt', alg: 'HS256' }; const now = Math.floor(Date.now() / 1000); // Generate random jti (token ID) @@ -631,13 +631,14 @@ export async function createRefreshJwt(did, secret, expiresIn = 7776000) { } /** - * Verify and decode an access JWT + * Verify and decode a JWT (shared logic) * @param {string} jwt - JWT string to verify * @param {string} secret - JWT signing secret - * @returns {Promise} Decoded payload + * @param {string} expectedType - Expected token type (e.g., 'at+jwt', 'refresh+jwt') + * @returns {Promise<{header: Object, payload: Object}>} Decoded header and payload * @throws {Error} If token is invalid, expired, or wrong type */ -export async function verifyAccessJwt(jwt, secret) { +async function verifyJwt(jwt, secret, expectedType) { const parts = jwt.split('.'); if (parts.length !== 3) { throw new Error('Invalid JWT format'); @@ -660,8 +661,8 @@ export async function verifyAccessJwt(jwt, secret) { ); // Check token type - if (header.typ !== 'at+jwt') { - throw new Error('Invalid token type: expected access token'); + if (header.typ !== expectedType) { + throw new Error(`Invalid token type: expected ${expectedType}`); } // Check expiration @@ -670,6 +671,36 @@ export async function verifyAccessJwt(jwt, secret) { throw new Error('Token expired'); } + return { header, payload }; +} + +/** + * Verify and decode an access JWT + * @param {string} jwt - JWT string to verify + * @param {string} secret - JWT signing secret + * @returns {Promise} Decoded payload + * @throws {Error} If token is invalid, expired, or wrong type + */ +export async function verifyAccessJwt(jwt, secret) { + const { payload } = await verifyJwt(jwt, secret, 'at+jwt'); + return payload; +} + +/** + * Verify and decode a refresh JWT + * @param {string} jwt - JWT string to verify + * @param {string} secret - JWT signing secret + * @returns {Promise} Decoded payload + * @throws {Error} If token is invalid, expired, or wrong type + */ +export async function verifyRefreshJwt(jwt, secret) { + const { payload } = await verifyJwt(jwt, secret, 'refresh+jwt'); + + // Validate audience matches subject (token intended for this user) + if (payload.aud && payload.aud !== payload.sub) { + throw new Error('Invalid audience'); + } + return payload; } @@ -1064,6 +1095,10 @@ const pdsRoutes = { '/xrpc/com.atproto.server.getSession': { handler: (pds, req, _url) => pds.handleGetSession(req), }, + '/xrpc/com.atproto.server.refreshSession': { + method: 'POST', + handler: (pds, req, _url) => pds.handleRefreshSession(req), + }, '/xrpc/app.bsky.actor.getPreferences': { handler: (pds, req, _url) => pds.handleGetPreferences(req), }, @@ -1655,7 +1690,7 @@ export class PersonalDataServer { const expectedPassword = this.env?.PDS_PASSWORD; if (!expectedPassword || password !== expectedPassword) { return errorResponse( - 'AuthenticationRequired', + 'AuthRequired', 'Invalid identifier or password', 401, ); @@ -1701,7 +1736,7 @@ export class PersonalDataServer { const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return errorResponse( - 'AuthenticationRequired', + 'AuthRequired', 'Missing or invalid authorization header', 401, ); @@ -1732,6 +1767,50 @@ export class PersonalDataServer { } } + async handleRefreshSession(request) { + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return errorResponse( + 'AuthRequired', + 'Missing or invalid authorization header', + 401, + ); + } + + const token = authHeader.slice(7); // Remove 'Bearer ' + const jwtSecret = this.env?.JWT_SECRET; + if (!jwtSecret) { + return errorResponse( + 'InternalServerError', + 'Server not configured for authentication', + 500, + ); + } + + try { + const payload = await verifyRefreshJwt(token, jwtSecret); + const did = payload.sub; + const handle = await this.getHandleForDid(did); + + // Issue fresh tokens + const accessJwt = await createAccessJwt(did, jwtSecret); + const refreshJwt = await createRefreshJwt(did, jwtSecret); + + return Response.json({ + accessJwt, + refreshJwt, + handle: handle || did, + did, + active: true, + }); + } catch (err) { + if (err.message === 'Token expired') { + return errorResponse('ExpiredToken', 'Refresh token has expired', 400); + } + return errorResponse('InvalidToken', err.message, 400); + } + } + async handleGetPreferences(_request) { // Preferences are stored per-user in their DO const preferences = (await this.state.storage.get('preferences')) || []; @@ -2315,7 +2394,7 @@ async function requireAuth(request, env) { return { error: Response.json( { - error: 'AuthenticationRequired', + error: 'AuthRequired', message: 'Authentication required', }, { status: 401 }, @@ -2437,6 +2516,13 @@ async function handleRequest(request, env) { return defaultPds.fetch(request); } + // refreshSession - route to default DO + if (url.pathname === '/xrpc/com.atproto.server.refreshSession') { + const defaultId = env.PDS.idFromName('default'); + const defaultPds = env.PDS.get(defaultId); + return defaultPds.fetch(request); + } + // Proxy app.bsky.* endpoints to Bluesky AppView if (url.pathname.startsWith('/xrpc/app.bsky.')) { // Authenticate the user first diff --git a/test/e2e.sh b/test/e2e.sh index 568146e..881ec25 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -51,48 +51,76 @@ echo echo "Running tests..." echo -# 1. Root returns ASCII art +# Root returns ASCII art curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" -# 2. describeServer works +# describeServer works curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' >/dev/null && pass "describeServer" || fail "describeServer" -# 3. resolveHandle works +# resolveHandle works curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" | jq -e '.did' >/dev/null && pass "resolveHandle" || fail "resolveHandle" -# 4. createSession returns tokens +# createSession returns tokens SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ -H "Content-Type: application/json" \ -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" -# 5. getSession works with token +# getSession works with token curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ -H "Authorization: Bearer $TOKEN" | jq -e '.did' >/dev/null && pass "getSession with valid token" || fail "getSession" -# 6. Protected endpoint rejects without auth +# refreshSession returns new tokens +REFRESH_TOKEN=$(echo "$SESSION" | jq -r '.refreshJwt') +REFRESH_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ + -H "Authorization: Bearer $REFRESH_TOKEN") +NEW_ACCESS=$(echo "$REFRESH_RESULT" | jq -r '.accessJwt') +NEW_REFRESH=$(echo "$REFRESH_RESULT" | jq -r '.refreshJwt') +[ "$NEW_ACCESS" != "null" ] && [ -n "$NEW_ACCESS" ] && [ "$NEW_REFRESH" != "null" ] && [ -n "$NEW_REFRESH" ] && + pass "refreshSession returns new tokens" || fail "refreshSession" + +# New access token from refresh works +curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ + -H "Authorization: Bearer $NEW_ACCESS" | jq -e '.did' >/dev/null && + pass "refreshed access token works" || fail "refreshed token" + +# refreshSession rejects access token (wrong type) +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ + -H "Authorization: Bearer $TOKEN") +[ "$STATUS" = "400" ] && pass "refreshSession rejects access token" || fail "refreshSession should reject access token" + +# refreshSession rejects missing auth +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession") +[ "$STATUS" = "401" ] && pass "refreshSession rejects missing auth" || fail "refreshSession should require auth" + +# refreshSession rejects malformed token +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.refreshSession" \ + -H "Authorization: Bearer not-a-valid-jwt") +[ "$STATUS" = "400" ] && pass "refreshSession rejects malformed token" || fail "refreshSession should reject malformed token" + +# Protected endpoint rejects without auth STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ -H "Content-Type: application/json" \ -d '{"repo":"x","collection":"x","record":{}}') [ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" -# 7. getPreferences works (returns empty array initially) +# getPreferences works (returns empty array initially) curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' >/dev/null && pass "getPreferences" || fail "getPreferences" -# 8. putPreferences works +# putPreferences works curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' >/dev/null && pass "putPreferences" || fail "putPreferences" -# 9. createRecord works with auth +# createRecord works with auth RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ @@ -100,57 +128,57 @@ RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ URI=$(echo "$RECORD" | jq -r '.uri') [ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" -# 10. getRecord retrieves it +# getRecord retrieves it RKEY=$(echo "$URI" | sed 's|.*/||') curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | jq -e '.value.text' >/dev/null && pass "getRecord" || fail "getRecord" -# 11. putRecord updates the record +# putRecord updates the record curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" | jq -e '.uri' >/dev/null && pass "putRecord" || fail "putRecord" -# 12. listRecords shows the record +# listRecords shows the record curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" | jq -e '.records | length > 0' >/dev/null && pass "listRecords" || fail "listRecords" -# 13. describeRepo returns repo info +# describeRepo returns repo info curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" | jq -e '.did' >/dev/null && pass "describeRepo" || fail "describeRepo" -# 14. applyWrites batch operation (create then delete a record) +# applyWrites batch operation (create then delete a record) APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -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)\"}}]}") echo "$APPLY_RESULT" | jq -e '.results' >/dev/null && pass "applyWrites create" || fail "applyWrites create" -# 15. applyWrites delete +# applyWrites delete curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" | jq -e '.results' >/dev/null && pass "applyWrites delete" || fail "applyWrites delete" -# 16. sync.getLatestCommit returns head +# sync.getLatestCommit returns head curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" | jq -e '.cid' >/dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" -# 17. sync.getRepoStatus returns status +# sync.getRepoStatus returns status curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" | jq -e '.did' >/dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" -# 18. sync.getRepo returns CAR file +# sync.getRepo returns CAR file REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) [ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" -# 19. sync.getRecord returns record with proof (binary CAR data) +# sync.getRecord returns record with proof (binary CAR data) RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) [ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" -# 20. sync.listRepos lists repos +# sync.listRepos lists repos curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | jq -e '.repos | length > 0' >/dev/null && pass "sync.listRepos" || fail "sync.listRepos" @@ -158,20 +186,20 @@ curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" | echo echo "Testing error handling..." -# 21. Invalid password rejected +# Invalid password rejected STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ -H "Content-Type: application/json" \ -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") [ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" -# 22. Wrong repo rejected (can't modify another user's repo) +# Wrong repo rejected (can't modify another user's repo) STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') [ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" -# 23. Non-existent record returns 404 +# Non-existent record returns 404 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=nonexistent") [ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" diff --git a/test/pds.test.js b/test/pds.test.js index fcaccda..86ea8fe 100644 --- a/test/pds.test.js +++ b/test/pds.test.js @@ -21,6 +21,7 @@ import { sign, varint, verifyAccessJwt, + verifyRefreshJwt, } from '../src/pds.js'; describe('CBOR Encoding', () => { @@ -493,4 +494,81 @@ describe('JWT Verification', () => { /invalid token type/i, ); }); + + test('verifyRefreshJwt returns payload for valid token', async () => { + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; + const jwt = await createRefreshJwt(did, secret); + + const payload = await verifyRefreshJwt(jwt, secret); + assert.strictEqual(payload.sub, did); + assert.strictEqual(payload.scope, 'com.atproto.refresh'); + assert.ok(payload.jti); // has token ID + }); + + test('verifyRefreshJwt throws for wrong secret', async () => { + const did = 'did:web:test.example'; + const jwt = await createRefreshJwt(did, 'correct-secret'); + + await assert.rejects( + () => verifyRefreshJwt(jwt, 'wrong-secret'), + /invalid signature/i, + ); + }); + + test('verifyRefreshJwt throws for expired token', async () => { + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; + // Create token that expired 1 second ago + const jwt = await createRefreshJwt(did, secret, -1); + + await assert.rejects(() => verifyRefreshJwt(jwt, secret), /expired/i); + }); + + test('verifyRefreshJwt throws for access token', async () => { + const did = 'did:web:test.example'; + const secret = 'test-secret-key'; + const jwt = await createAccessJwt(did, secret); + + await assert.rejects( + () => verifyRefreshJwt(jwt, secret), + /invalid token type/i, + ); + }); + + test('verifyAccessJwt throws for malformed JWT', async () => { + const secret = 'test-secret-key'; + + // Not a JWT at all + await assert.rejects( + () => verifyAccessJwt('not-a-jwt', secret), + /Invalid JWT format/i, + ); + + // Only two parts + await assert.rejects( + () => verifyAccessJwt('two.parts', secret), + /Invalid JWT format/i, + ); + + // Four parts + await assert.rejects( + () => verifyAccessJwt('one.two.three.four', secret), + /Invalid JWT format/i, + ); + }); + + test('verifyRefreshJwt throws for malformed JWT', async () => { + const secret = 'test-secret-key'; + + await assert.rejects( + () => verifyRefreshJwt('not-a-jwt', secret), + /Invalid JWT format/i, + ); + + await assert.rejects( + () => verifyRefreshJwt('two.parts', secret), + /Invalid JWT format/i, + ); + }); });