diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index d0073265551..56d83e6958f 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -5,9 +5,15 @@ import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.sendEmail({ - auth: ctx.authVerifier.role, + auth: ctx.authVerifier.roleOrAccountService, handler: async ({ req, input, auth }) => { - if (!auth.credentials.admin && !auth.credentials.moderator) { + if (auth.credentials.type === 'role' && !auth.credentials.moderator) { + throw new AuthRequiredError('Insufficient privileges') + } + if ( + auth.credentials.type === 'account_service' && + auth.credentials.did !== input.body.recipientDid + ) { throw new AuthRequiredError('Insufficient privileges') } @@ -32,21 +38,26 @@ export default function (server: Server, ctx: AppContext) { { content }, { subject, to: userInfo.email }, ) - await ctx.moderationAgent.api.com.atproto.admin.emitModerationEvent( - { - event: { - $type: 'com.atproto.admin.defs#modEventEmail', - subjectLine: subject, - comment, - }, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: recipientDid, + + if (auth.credentials.type === 'role') { + // @TODO this behavior is deprecated, remove after service auth takes effect + await ctx.moderationAgent.api.com.atproto.admin.emitModerationEvent( + { + event: { + $type: 'com.atproto.admin.defs#modEventEmail', + subjectLine: subject, + comment, + }, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: recipientDid, + }, + createdBy: senderDid, }, - createdBy: senderDid, - }, - { ...authPassthru(req), encoding: 'application/json' }, - ) + { ...authPassthru(req), encoding: 'application/json' }, + ) + } + return { encoding: 'application/json', body: { sent: true }, diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 3d43e66cbd8..3a001733025 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -71,6 +71,14 @@ type AccessOutput = { artifacts: string } +type AccountServiceOutput = { + credentials: { + type: 'account_service' + did: string + } + artifacts: string +} + type AccessCheckedOutput = AccessOutput & { credentials: { pdsDid: string | null @@ -271,6 +279,34 @@ export class AuthVerifier { } } + accountService = async (reqCtx: ReqCtx): Promise => { + const token = bearerTokenFromReq(reqCtx.req) + if (!token) { + throw new AuthRequiredError('missing jwt', 'MissingJwt') + } + const payload = await verifyServiceJwt( + token, + this._pdsServiceDid, + (did, forceRefresh) => + this.idResolver.did.resolveAtprotoKey(did, forceRefresh), + ) + const found = await this.db.db + .selectFrom('user_account') + .where('did', '=', payload.iss) + .select('did') + .executeTakeFirst() + if (!found) { + throw new AuthRequiredError('Unknown account', 'UntrustedIss') + } + return { + credentials: { + type: 'account_service', + did: payload.iss, + }, + artifacts: token, + } + } + roleOrAdminService = async ( reqCtx: ReqCtx, ): Promise => { @@ -281,6 +317,16 @@ export class AuthVerifier { } } + roleOrAccountService = async ( + reqCtx: ReqCtx, + ): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.accountService(reqCtx) + } else { + return this.role(reqCtx) + } + } + async validateBearerToken( req: express.Request, scopes: AuthScope[], diff --git a/packages/pds/tests/admin-auth.test.ts b/packages/pds/tests/admin-auth.test.ts index 4cf7d5c26a5..548c9af3c94 100644 --- a/packages/pds/tests/admin-auth.test.ts +++ b/packages/pds/tests/admin-auth.test.ts @@ -29,14 +29,14 @@ describe('admin auth', () => { modServiceKey = await Secp256k1Keypair.create() const origResolve = network.pds.ctx.idResolver.did.resolveAtprotoKey - network.pds.ctx.idResolver.did.resolveAtprotoKey = async ( + network.pds.ctx.idResolver.did.resolveAtprotoKey = async function ( did: string, forceRefresh?: boolean, - ) => { + ) { if (did === modServiceDid || did === altModDid) { return modServiceKey.did() } - return origResolve(did, forceRefresh) + return origResolve.call(this, did, forceRefresh) } agent = network.pds.getClient() @@ -142,4 +142,76 @@ describe('admin auth', () => { 'jwt audience does not match service did', ) }) + + describe('account service auth', () => { + it('succeeds when account exists', async () => { + const headers = await network.pds.ctx.serviceAuthHeaders( + sc.dids.alice, + network.pds.ctx.cfg.service.did, + ) + const attempt = agent.api.com.atproto.admin.sendEmail( + { + subject: 'Subject', + content: 'Content', + recipientDid: sc.dids.alice, + senderDid: sc.dids.alice, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).resolves.toBeDefined() + }) + + it("fails on account that doesn't exist", async () => { + await agent.api.com.atproto.admin.deleteAccount( + { did: sc.dids.dan }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) + await network.pds.processAll() + const headers = await network.pds.ctx.serviceAuthHeaders( + sc.dids.dan, + network.pds.ctx.cfg.service.did, + ) + const attempt = agent.api.com.atproto.admin.sendEmail( + { + subject: 'Subject', + content: 'Content', + recipientDid: sc.dids.dan, + senderDid: sc.dids.dan, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow('Unknown account') + }) + + it('fails on bad audience', async () => { + const headers = await network.pds.ctx.serviceAuthHeaders( + sc.dids.alice, + sc.dids.bob, + ) + const attempt = agent.api.com.atproto.admin.sendEmail( + { + subject: 'Subject', + content: 'Content', + recipientDid: sc.dids.alice, + senderDid: sc.dids.alice, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow( + 'jwt audience does not match service did', + ) + }) + }) })