chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { beforeEach, describe, expect, it } from 'vitest';
import { SCOPE_ACCESS } from '../packages/core/src/app-password.js';
import { createAccessJwt } from '../packages/core/src/auth.js';
import { renderConsentPage } from '../packages/core/src/oauth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import {
parseScopesForDisplay,
ScopePermissions,
} from '../packages/core/src/scope.js';
const DID = 'did:plc:testemailscope';
const JWT_SECRET = 'test-secret';
const ACCOUNT_PASSWORD = 'account-password';
const EMAIL = 'alice@example.com';
describe('email scope semantics', () => {
describe('ScopePermissions.hasEmailAccess', () => {
it('grants on transition:email', () => {
expect(
new ScopePermissions('atproto transition:email').hasEmailAccess(),
).toBe(true);
});
it('grants on granular account:email, bare or parameterized', () => {
expect(new ScopePermissions('account:email').hasEmailAccess()).toBe(true);
expect(
new ScopePermissions('account:email?action=read').hasEmailAccess(),
).toBe(true);
});
it('is not implied by full access', () => {
// The reference PDS keeps email separate: broad repo access alone does
// not expose the address.
expect(new ScopePermissions('atproto').hasEmailAccess()).toBe(false);
expect(new ScopePermissions('transition:generic').hasEmailAccess()).toBe(
false,
);
expect(new ScopePermissions(SCOPE_ACCESS).hasEmailAccess()).toBe(false);
});
it('is false for an empty scope', () => {
expect(new ScopePermissions('').hasEmailAccess()).toBe(false);
});
});
describe('parseScopesForDisplay', () => {
it('flags hasEmail when the email scope is present', () => {
expect(parseScopesForDisplay('atproto transition:email').hasEmail).toBe(
true,
);
expect(parseScopesForDisplay('account:email').hasEmail).toBe(true);
});
it('leaves hasEmail false otherwise', () => {
expect(parseScopesForDisplay('atproto transition:generic').hasEmail).toBe(
false,
);
});
});
describe('consent page', () => {
const base = {
clientName: 'Test App',
clientId: 'https://app.example.com/client-metadata.json',
requestUri: 'urn:ietf:params:oauth:request_uri:abc',
};
it('tells the user their email will be visible', () => {
const html = renderConsentPage({
...base,
scope: 'atproto transition:email',
});
expect(html).toContain('email address');
});
it('mentions email even alongside full repository access', () => {
const html = renderConsentPage({
...base,
scope: 'atproto transition:generic transition:email',
});
expect(html).toContain('email address');
expect(html).toContain('Full repository access');
});
it('says nothing about email when the scope is not requested', () => {
const html = renderConsentPage({ ...base, scope: 'atproto' });
expect(html).not.toContain('email address');
});
});
});
/**
* @param {import('../packages/core/src/ports.js').EmailState} emailState
*/
function createPds(emailState) {
/** @type {Map<string, {name: string, hash: string, privileged: boolean, createdAt: string}>} */
const byName = new Map();
return new PersonalDataServer({
actorStorage: /** @type {any} */ ({
async getHandle() {
return 'alice.test';
},
async getDid() {
return DID;
},
async getEmail() {
return emailState;
},
async setEmail() {},
}),
sharedStorage: /** @type {any} */ ({
async putAppPassword(
/** @type {string} */ did,
/** @type {string} */ name,
/** @type {string} */ hash,
/** @type {boolean} */ privileged,
/** @type {string} */ createdAt,
) {
if (byName.has(`${did}:${name}`)) return false;
byName.set(`${did}:${name}`, { name, hash, privileged, createdAt });
return true;
},
async getAppPasswordByHash(
/** @type {string} */ did,
/** @type {string} */ hash,
) {
for (const [key, v] of byName) {
if (key.startsWith(`${did}:`) && v.hash === hash) {
return {
name: v.name,
privileged: v.privileged,
createdAt: v.createdAt,
};
}
}
return null;
},
}),
blobs: /** @type {any} */ ({}),
jwtSecret: JWT_SECRET,
hostname: 'pds.example.com',
password: ACCOUNT_PASSWORD,
});
}
/**
* @param {PersonalDataServer} pds
* @param {string} jwt
*/
async function getSession(pds, jwt) {
const response = await pds.fetch(
new Request('https://pds.example.com/xrpc/com.atproto.server.getSession', {
headers: { authorization: `Bearer ${jwt}` },
}),
);
return { status: response.status, body: await response.json() };
}
describe('email in session responses', () => {
/** @type {PersonalDataServer} */
let pds;
beforeEach(() => {
pds = createPds({ address: EMAIL, verified: true, pending: null });
});
it('createSession returns the email to the account-password session', async () => {
const res = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.createSession',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identifier: DID, password: ACCOUNT_PASSWORD }),
},
),
);
const body = await res.json();
expect(body.email).toBe(EMAIL);
expect(body.emailConfirmed).toBe(true);
});
it('getSession exposes email to the account-password session', async () => {
const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS);
const { body } = await getSession(pds, jwt);
expect(body.email).toBe(EMAIL);
expect(body.emailConfirmed).toBe(true);
});
it('reports an unverified address as unconfirmed', async () => {
pds = createPds({ address: EMAIL, verified: false, pending: null });
const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS);
const { body } = await getSession(pds, jwt);
expect(body.email).toBe(EMAIL);
expect(body.emailConfirmed).toBe(false);
});
it('exposes email to an OAuth session holding transition:email', async () => {
const jwt = await createAccessJwt(
DID,
JWT_SECRET,
7200,
'atproto transition:email',
);
const { body } = await getSession(pds, jwt);
expect(body.email).toBe(EMAIL);
});
it('withholds email from an OAuth session without the email scope', async () => {
const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, 'atproto');
const { body } = await getSession(pds, jwt);
expect(body.email).toBeUndefined();
expect(body.emailConfirmed).toBeUndefined();
// Identity is still returned.
expect(body.did).toBe(DID);
});
it('never exposes email to an app-password session', async () => {
// Register an app password, then log in with it for a restricted session.
const accountJwt = await createAccessJwt(
DID,
JWT_SECRET,
7200,
SCOPE_ACCESS,
);
const created = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.createAppPassword',
{
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${accountJwt}`,
},
body: JSON.stringify({ name: 'my-client' }),
},
),
);
const { password } = await created.json();
const session = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/com.atproto.server.createSession',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identifier: DID, password }),
},
),
);
const sessionBody = await session.json();
expect(sessionBody.email).toBeUndefined();
const { body } = await getSession(pds, sessionBody.accessJwt);
expect(body.email).toBeUndefined();
expect(body.emailConfirmed).toBeUndefined();
});
it('omits email fields when the account has no address', async () => {
pds = createPds({ address: null, verified: false, pending: null });
const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS);
const { body } = await getSession(pds, jwt);
expect(body.email).toBeUndefined();
expect(body.emailConfirmed).toBeUndefined();
});
});