diff --git a/.changeset/metal-cameras-give.md b/.changeset/metal-cameras-give.md new file mode 100644 index 00000000000..6103b50aa14 --- /dev/null +++ b/.changeset/metal-cameras-give.md @@ -0,0 +1,5 @@ +--- +"@atproto/api": patch +--- + +Add lxm and exp parameters to com.atproto.server.getServiceAuth diff --git a/.changeset/selfish-emus-wink.md b/.changeset/selfish-emus-wink.md new file mode 100644 index 00000000000..415377b5921 --- /dev/null +++ b/.changeset/selfish-emus-wink.md @@ -0,0 +1,6 @@ +--- +"@atproto/xrpc-server": minor +"@atproto/pds": patch +--- + +Add lxm and nonce to signed service auth tokens. diff --git a/lexicons/com/atproto/server/getServiceAuth.json b/lexicons/com/atproto/server/getServiceAuth.json index 95984c186ad..37c150d79e3 100644 --- a/lexicons/com/atproto/server/getServiceAuth.json +++ b/lexicons/com/atproto/server/getServiceAuth.json @@ -13,6 +13,15 @@ "type": "string", "format": "did", "description": "The DID of the service that the token will be used to authenticate with" + }, + "exp": { + "type": "integer", + "description": "The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope." + }, + "lxm": { + "type": "string", + "format": "nsid", + "description": "Lexicon (XRPC) method to bind the requested token to" } } }, @@ -27,7 +36,13 @@ } } } - } + }, + "errors": [ + { + "name": "BadExpiration", + "description": "Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes." + } + ] } } } diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index e4a3c8b90f8..e305e1c6594 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2607,6 +2607,17 @@ export const schemaDict = { description: 'The DID of the service that the token will be used to authenticate with', }, + exp: { + type: 'integer', + description: + 'The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.', + }, + lxm: { + type: 'string', + format: 'nsid', + description: + 'Lexicon (XRPC) method to bind the requested token to', + }, }, }, output: { @@ -2621,6 +2632,13 @@ export const schemaDict = { }, }, }, + errors: [ + { + name: 'BadExpiration', + description: + 'Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.', + }, + ], }, }, }, diff --git a/packages/api/src/client/types/com/atproto/server/getServiceAuth.ts b/packages/api/src/client/types/com/atproto/server/getServiceAuth.ts index 6056960effc..6ffd9955586 100644 --- a/packages/api/src/client/types/com/atproto/server/getServiceAuth.ts +++ b/packages/api/src/client/types/com/atproto/server/getServiceAuth.ts @@ -10,6 +10,10 @@ import { CID } from 'multiformats/cid' export interface QueryParams { /** The DID of the service that the token will be used to authenticate with */ aud: string + /** The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope. */ + exp?: number + /** Lexicon (XRPC) method to bind the requested token to */ + lxm?: string } export type InputSchema = undefined @@ -29,8 +33,15 @@ export interface Response { data: OutputSchema } +export class BadExpirationError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + export function toKnownErr(e: any) { if (e instanceof XRPCError) { + if (e.error === 'BadExpiration') return new BadExpirationError(e) } return e } diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index b39c58f97cd..a9ac7b408b1 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -248,7 +248,12 @@ export class AuthVerifier { if (!jwtStr) { throw new AuthRequiredError('missing jwt', 'MissingJwt') } - const payload = await verifyServiceJwt(jwtStr, opts.aud, getSigningKey) + const payload = await verifyServiceJwt( + jwtStr, + opts.aud, + null, + getSigningKey, + ) return { iss: payload.iss, aud: payload.aud } } diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index 1479df29f8c..9fb6afa7dd6 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -3,7 +3,6 @@ import * as plc from '@did-plc/lib' import { IdResolver } from '@atproto/identity' import AtpAgent from '@atproto/api' import { Keypair } from '@atproto/crypto' -import { createServiceJwt } from '@atproto/xrpc-server' import { ServerConfig } from './config' import { DataPlaneClient } from './data-plane/client' import { Hydrator } from './hydration/hydrator' @@ -89,15 +88,6 @@ export class AppContext { return this.opts.featureGates } - async serviceAuthJwt(aud: string) { - const iss = this.cfg.serverDid - return createServiceJwt({ - iss, - aud, - keypair: this.signingKey, - }) - } - reqLabelers(req: express.Request): ParsedLabelers { const val = req.header('atproto-accept-labelers') let parsed: ParsedLabelers | null diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index bb109a5847a..e8c739d61cd 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2607,6 +2607,17 @@ export const schemaDict = { description: 'The DID of the service that the token will be used to authenticate with', }, + exp: { + type: 'integer', + description: + 'The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.', + }, + lxm: { + type: 'string', + format: 'nsid', + description: + 'Lexicon (XRPC) method to bind the requested token to', + }, }, }, output: { @@ -2621,6 +2632,13 @@ export const schemaDict = { }, }, }, + errors: [ + { + name: 'BadExpiration', + description: + 'Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.', + }, + ], }, }, }, diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/getServiceAuth.ts b/packages/bsky/src/lexicon/types/com/atproto/server/getServiceAuth.ts index 73efe2313a9..14f249fde44 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/getServiceAuth.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/getServiceAuth.ts @@ -11,6 +11,10 @@ import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' export interface QueryParams { /** The DID of the service that the token will be used to authenticate with */ aud: string + /** The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope. */ + exp?: number + /** Lexicon (XRPC) method to bind the requested token to */ + lxm?: string } export type InputSchema = undefined @@ -31,6 +35,7 @@ export interface HandlerSuccess { export interface HandlerError { status: number message?: string + error?: 'BadExpiration' } export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough diff --git a/packages/bsky/tests/admin/admin-auth.test.ts b/packages/bsky/tests/admin/admin-auth.test.ts index cb13b58897a..867189842d1 100644 --- a/packages/bsky/tests/admin/admin-auth.test.ts +++ b/packages/bsky/tests/admin/admin-auth.test.ts @@ -71,6 +71,7 @@ describe('admin auth', () => { const headers = await createServiceAuthHeaders({ iss: modServiceDid, aud: bskyDid, + lxm: null, keypair: modServiceKey, }) await agent.api.com.atproto.admin.updateSubjectStatus( @@ -96,6 +97,7 @@ describe('admin auth', () => { const headers = await createServiceAuthHeaders({ iss: altModDid, aud: bskyDid, + lxm: null, keypair: modServiceKey, }) const attempt = agent.api.com.atproto.admin.updateSubjectStatus( @@ -116,6 +118,7 @@ describe('admin auth', () => { const headers = await createServiceAuthHeaders({ iss: sc.dids.alice, aud: bskyDid, + lxm: null, keypair: aliceKey, }) const attempt = agent.api.com.atproto.admin.updateSubjectStatus( @@ -136,6 +139,7 @@ describe('admin auth', () => { const headers = await createServiceAuthHeaders({ iss: modServiceDid, aud: bskyDid, + lxm: null, keypair: badKey, }) const attempt = agent.api.com.atproto.admin.updateSubjectStatus( @@ -158,6 +162,7 @@ describe('admin auth', () => { const headers = await createServiceAuthHeaders({ iss: modServiceDid, aud: sc.dids.alice, + lxm: null, keypair: modServiceKey, }) const attempt = agent.api.com.atproto.admin.updateSubjectStatus( diff --git a/packages/bsky/tests/auth.test.ts b/packages/bsky/tests/auth.test.ts index d0903174a2b..9c58b0ccbda 100644 --- a/packages/bsky/tests/auth.test.ts +++ b/packages/bsky/tests/auth.test.ts @@ -29,6 +29,7 @@ describe('auth', () => { const jwt = await createServiceJwt({ iss: issuer, aud: network.bsky.ctx.cfg.serverDid, + lxm: null, keypair, }) return agent.api.app.bsky.actor.getProfile( diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index 82c5e173fa2..4477c5d270b 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -162,6 +162,7 @@ export class TestNetwork extends TestNetworkNoAppView { const jwt = await createServiceJwt({ iss: did, aud: aud ?? this.bsky.ctx.cfg.serverDid, + lxm: null, keypair, }) return { authorization: `Bearer ${jwt}` } diff --git a/packages/dev-env/src/ozone-service-profile.ts b/packages/dev-env/src/ozone-service-profile.ts index 325e95ee1ce..152feed9d00 100644 --- a/packages/dev-env/src/ozone-service-profile.ts +++ b/packages/dev-env/src/ozone-service-profile.ts @@ -47,6 +47,7 @@ export class OzoneServiceProfile { const serviceJwtRes = await this.thirdPartyPdsClient.com.atproto.server.getServiceAuth({ aud: newServerDid, + lxm: 'com.atproto.server.createAccount', }) const serviceJwt = serviceJwtRes.data.token diff --git a/packages/dev-env/src/ozone.ts b/packages/dev-env/src/ozone.ts index d8cd0c4bd59..9fbf52f2a42 100644 --- a/packages/dev-env/src/ozone.ts +++ b/packages/dev-env/src/ozone.ts @@ -151,6 +151,7 @@ export class TestOzone { const jwt = await createServiceJwt({ iss: account.did, aud: this.ctx.cfg.service.did, + lxm: null, keypair: account.key, }) return { authorization: `Bearer ${jwt}` } diff --git a/packages/ozone/src/auth-verifier.ts b/packages/ozone/src/auth-verifier.ts index b09389bd0f3..7cb8f532ec1 100644 --- a/packages/ozone/src/auth-verifier.ts +++ b/packages/ozone/src/auth-verifier.ts @@ -106,7 +106,12 @@ export class AuthVerifier { if (!jwtStr) { throw new AuthRequiredError('missing jwt', 'MissingJwt') } - const payload = await verifyJwt(jwtStr, this.serviceDid, getSigningKey) + const payload = await verifyJwt( + jwtStr, + this.serviceDid, + null, + getSigningKey, + ) const iss = payload.iss const member = await this.teamService.getMember(iss) diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index 9ad50000f24..749e162da3e 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -88,6 +88,7 @@ export class AppContext { createServiceAuthHeaders({ iss: `${cfg.service.did}#atproto_labeler`, aud, + lxm: null, keypair: signingKey, }) @@ -230,6 +231,7 @@ export class AppContext { return createServiceAuthHeaders({ iss, aud, + lxm: null, keypair: this.signingKey, }) } diff --git a/packages/ozone/src/daemon/context.ts b/packages/ozone/src/daemon/context.ts index 3ffa021d37e..70943656a47 100644 --- a/packages/ozone/src/daemon/context.ts +++ b/packages/ozone/src/daemon/context.ts @@ -43,6 +43,7 @@ export class DaemonContext { createServiceAuthHeaders({ iss: `${cfg.service.did}#atproto_labeler`, aud, + lxm: null, keypair: signingKey, }) diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index e4a3c8b90f8..e305e1c6594 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -2607,6 +2607,17 @@ export const schemaDict = { description: 'The DID of the service that the token will be used to authenticate with', }, + exp: { + type: 'integer', + description: + 'The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.', + }, + lxm: { + type: 'string', + format: 'nsid', + description: + 'Lexicon (XRPC) method to bind the requested token to', + }, }, }, output: { @@ -2621,6 +2632,13 @@ export const schemaDict = { }, }, }, + errors: [ + { + name: 'BadExpiration', + description: + 'Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.', + }, + ], }, }, }, diff --git a/packages/ozone/src/lexicon/types/com/atproto/server/getServiceAuth.ts b/packages/ozone/src/lexicon/types/com/atproto/server/getServiceAuth.ts index 73efe2313a9..14f249fde44 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/server/getServiceAuth.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/server/getServiceAuth.ts @@ -11,6 +11,10 @@ import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' export interface QueryParams { /** The DID of the service that the token will be used to authenticate with */ aud: string + /** The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope. */ + exp?: number + /** Lexicon (XRPC) method to bind the requested token to */ + lxm?: string } export type InputSchema = undefined @@ -31,6 +35,7 @@ export interface HandlerSuccess { export interface HandlerError { status: number message?: string + error?: 'BadExpiration' } export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough diff --git a/packages/pds/src/api/app/bsky/feed/getFeed.ts b/packages/pds/src/api/app/bsky/feed/getFeed.ts index 6ae97d5e1bb..f4f2ade83d9 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeed.ts @@ -1,6 +1,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { pipethrough } from '../../../../pipethrough' +import { ids } from '../../../../lexicon/lexicons' export default function (server: Server, ctx: AppContext) { const { appViewAgent } = ctx @@ -14,9 +15,15 @@ export default function (server: Server, ctx: AppContext) { const { data: feed } = await appViewAgent.api.app.bsky.feed.getFeedGenerator( { feed: params.feed }, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders( + requester, + ids.AppBskyFeedGetFeedGenerator, + ), ) - return pipethrough(ctx, req, requester, feed.view.did) + return pipethrough(ctx, req, requester, { + aud: feed.view.did, + lxm: ids.AppBskyFeedGetFeedSkeleton, + }) }, }) } diff --git a/packages/pds/src/api/app/bsky/feed/getPostThread.ts b/packages/pds/src/api/app/bsky/feed/getPostThread.ts index 04fb78c4c07..a497aca0613 100644 --- a/packages/pds/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/pds/src/api/app/bsky/feed/getPostThread.ts @@ -22,6 +22,7 @@ import { formatMungedResponse, } from '../../../../read-after-write' import { pipethrough } from '../../../../pipethrough' +import { ids } from '../../../../lexicon/lexicons' const METHOD_NSID = 'app.bsky.feed.getPostThread' @@ -189,7 +190,7 @@ const readAfterWriteNotFound = async ( assert(ctx.appViewAgent) const parentsRes = await ctx.appViewAgent.api.app.bsky.feed.getPostThread( { uri: highestParent, parentHeight: params.parentHeight, depth: 0 }, - await ctx.appviewAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester, ids.AppBskyFeedGetPostThread), ) thread.parent = parentsRes.data.thread } catch (err) { diff --git a/packages/pds/src/api/app/bsky/notification/registerPush.ts b/packages/pds/src/api/app/bsky/notification/registerPush.ts index f9b7fb3c41c..0e481e639a9 100644 --- a/packages/pds/src/api/app/bsky/notification/registerPush.ts +++ b/packages/pds/src/api/app/bsky/notification/registerPush.ts @@ -5,6 +5,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { AtpAgent } from '@atproto/api' import { getDidDoc } from '../util/resolver' import { AuthScope } from '../../../../auth-verifier' +import { ids } from '../../../../lexicon/lexicons' export default function (server: Server, ctx: AppContext) { const { appViewAgent } = ctx @@ -19,7 +20,11 @@ export default function (server: Server, ctx: AppContext) { credentials: { did }, } = auth - const authHeaders = await ctx.serviceAuthHeaders(did, serviceDid) + const authHeaders = await ctx.serviceAuthHeaders( + did, + serviceDid, + ids.AppBskyNotificationRegisterPush, + ) if (ctx.cfg.bskyAppView?.did === serviceDid) { await appViewAgent.api.app.bsky.notification.registerPush(input.body, { diff --git a/packages/pds/src/api/chat/index.ts b/packages/pds/src/api/chat/index.ts deleted file mode 100644 index e502c727ea2..00000000000 --- a/packages/pds/src/api/chat/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import AppContext from '../../context' -import { Server } from '../../lexicon' -import { pipethrough, pipethroughProcedure } from '../../pipethrough' - -export default function (server: Server, ctx: AppContext) { - server.chat.bsky.actor.deleteAccount({ - auth: ctx.authVerifier.accessPrivileged(), - handler: async ({ req, auth }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did) - }, - }) - server.chat.bsky.actor.exportAccountData({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth }) => { - return pipethrough(ctx, req, auth.credentials.did) - }, - }) - server.chat.bsky.convo.deleteMessageForSelf({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth, input }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did, input.body) - }, - }) - server.chat.bsky.convo.getConvo({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth }) => { - return pipethrough(ctx, req, auth.credentials.did) - }, - }) - server.chat.bsky.convo.getConvoForMembers({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth }) => { - return pipethrough(ctx, req, auth.credentials.did) - }, - }) - server.chat.bsky.convo.getLog({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth }) => { - return pipethrough(ctx, req, auth.credentials.did) - }, - }) - server.chat.bsky.convo.getMessages({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth }) => { - return pipethrough(ctx, req, auth.credentials.did) - }, - }) - server.chat.bsky.convo.leaveConvo({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth, input }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did, input.body) - }, - }) - server.chat.bsky.convo.listConvos({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth }) => { - return pipethrough(ctx, req, auth.credentials.did) - }, - }) - server.chat.bsky.convo.muteConvo({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth, input }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did, input.body) - }, - }) - server.chat.bsky.convo.sendMessage({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth, input }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did, input.body) - }, - }) - server.chat.bsky.convo.sendMessageBatch({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth, input }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did, input.body) - }, - }) - server.chat.bsky.convo.unmuteConvo({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth, input }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did, input.body) - }, - }) - server.chat.bsky.convo.updateRead({ - auth: ctx.authVerifier.accessPrivileged(), - handler: ({ req, auth, input }) => { - return pipethroughProcedure(ctx, req, auth.credentials.did, input.body) - }, - }) -} diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index 67b1755fedc..2cd48307686 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -3,6 +3,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { resultPassthru } from '../../../proxy' +import { ids } from '../../../../lexicon/lexicons' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.sendEmail({ @@ -29,7 +30,8 @@ export default function (server: Server, ctx: AppContext) { encoding: 'application/json', ...(await ctx.serviceAuthHeaders( recipientDid, - ctx.cfg.entryway?.did, + ctx.cfg.entryway.did, + ids.ComAtprotoAdminSendEmail, )), }), ) diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index de7f481c35f..e512d9a6e8a 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -6,7 +6,7 @@ import { BlobMetadata } from '../../../../actor-store/blob/transactor' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.uploadBlob({ - auth: ctx.authVerifier.accessStandard({ + auth: ctx.authVerifier.accessOrUserServiceAuth({ checkTakedown: true, }), rateLimit: { diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index abbad8284be..4d6fd499852 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -20,9 +20,9 @@ export default function (server: Server, ctx: AppContext) { durationMs: 5 * MINUTE, points: 100, }, - auth: ctx.authVerifier.userDidAuthOptional, + auth: ctx.authVerifier.userServiceAuthOptional, handler: async ({ input, auth, req }) => { - const requester = auth.credentials?.iss ?? null + const requester = auth.credentials?.did ?? null const { did, handle, diff --git a/packages/pds/src/api/com/atproto/server/getServiceAuth.ts b/packages/pds/src/api/com/atproto/server/getServiceAuth.ts index bae1d97f8ab..e13a82b57f5 100644 --- a/packages/pds/src/api/com/atproto/server/getServiceAuth.ts +++ b/packages/pds/src/api/com/atproto/server/getServiceAuth.ts @@ -1,4 +1,5 @@ -import { createServiceJwt } from '@atproto/xrpc-server' +import { InvalidRequestError, createServiceJwt } from '@atproto/xrpc-server' +import { MINUTE } from '@atproto/common' import AppContext from '../../../../context' import { Server } from '../../../../lexicon' @@ -8,9 +9,27 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ params, auth }) => { const did = auth.credentials.did const keypair = await ctx.actorStore.keypair(did) + const exp = params.exp ? params.exp * 1000 : undefined + if (exp) { + const diff = exp - Date.now() + if (diff < 0) { + throw new InvalidRequestError( + 'expiration is in past', + 'BadExpiration', + ) + } else if (diff > MINUTE) { + throw new InvalidRequestError( + 'cannot request a token with an expiration more than a minute in the future', + 'BadExpiration', + ) + } + } + const token = await createServiceJwt({ iss: did, aud: params.aud, + lxm: params.lxm ?? null, + exp, keypair, }) return { diff --git a/packages/pds/src/api/index.ts b/packages/pds/src/api/index.ts index d4625ee76b7..f0d08cd559e 100644 --- a/packages/pds/src/api/index.ts +++ b/packages/pds/src/api/index.ts @@ -1,12 +1,10 @@ import { Server } from '../lexicon' import comAtproto from './com/atproto' import appBsky from './app/bsky' -import chat from './chat' import AppContext from '../context' export default function (server: Server, ctx: AppContext) { comAtproto(server, ctx) appBsky(server, ctx) - chat(server, ctx) return server } diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index ae1365c57be..4e4473a3f26 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -71,6 +71,7 @@ type AccessOutput = { did: string scope: AuthScope audience: string | undefined + isPrivileged: boolean } artifacts: string } @@ -86,11 +87,11 @@ type RefreshOutput = { artifacts: string } -type UserDidOutput = { +type UserServiceAuthOutput = { credentials: { - type: 'user_did' + type: 'user_service_auth' aud: string - iss: string + did: string } } @@ -221,30 +222,40 @@ export class AuthVerifier { } } - userDidAuth = async (ctx: ReqCtx): Promise => { + userServiceAuth = async (ctx: ReqCtx): Promise => { const payload = await this.verifyServiceJwt(ctx, { aud: this.dids.entryway ?? this.dids.pds, iss: null, }) return { credentials: { - type: 'user_did', + type: 'user_service_auth', aud: payload.aud, - iss: payload.iss, + did: payload.iss, }, } } - userDidAuthOptional = async ( + userServiceAuthOptional = async ( ctx: ReqCtx, - ): Promise => { + ): Promise => { if (isBearerToken(ctx.req)) { - return await this.userDidAuth(ctx) + return await this.userServiceAuth(ctx) } else { return this.null(ctx) } } + accessOrUserServiceAuth = + (opts: Partial = {}) => + async (ctx: ReqCtx): Promise => { + try { + return await this.accessStandard(opts)(ctx) + } catch { + return await this.userServiceAuth(ctx) + } + } + modService = async (ctx: ReqCtx): Promise => { if (!this.dids.modService) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') @@ -470,6 +481,7 @@ export class AuthVerifier { did: result.claims.sub, scope: AuthScope.Access, audience: this.dids.pds, + isPrivileged: true, }, artifacts: result.token, } @@ -498,12 +510,17 @@ export class AuthVerifier { scopes, { audience: this.dids.pds }, ) + const isPrivileged = [ + AuthScope.Access, + AuthScope.AppPassPrivileged, + ].includes(scope) return { credentials: { type: 'access', did, scope, audience, + isPrivileged, }, artifacts: token, } @@ -544,7 +561,12 @@ export class AuthVerifier { if (!jwtStr) { throw new AuthRequiredError('missing jwt', 'MissingJwt') } - const payload = await verifyServiceJwt(jwtStr, opts.aud, getSigningKey) + const payload = await verifyServiceJwt( + jwtStr, + opts.aud, + null, + getSigningKey, + ) return { iss: payload.iss, aud: payload.aud } } diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index c76c3966c2d..f7b01330e14 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -342,16 +342,17 @@ export class AppContext { }) } - async appviewAuthHeaders(did: string) { + async appviewAuthHeaders(did: string, lxm: string) { assert(this.cfg.bskyAppView) - return this.serviceAuthHeaders(did, this.cfg.bskyAppView.did) + return this.serviceAuthHeaders(did, this.cfg.bskyAppView.did, lxm) } - async serviceAuthHeaders(did: string, aud: string) { + async serviceAuthHeaders(did: string, aud: string, lxm: string) { const keypair = await this.actorStore.keypair(did) return createServiceAuthHeaders({ iss: did, aud, + lxm, keypair, }) } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index e4a3c8b90f8..e305e1c6594 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2607,6 +2607,17 @@ export const schemaDict = { description: 'The DID of the service that the token will be used to authenticate with', }, + exp: { + type: 'integer', + description: + 'The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.', + }, + lxm: { + type: 'string', + format: 'nsid', + description: + 'Lexicon (XRPC) method to bind the requested token to', + }, }, }, output: { @@ -2621,6 +2632,13 @@ export const schemaDict = { }, }, }, + errors: [ + { + name: 'BadExpiration', + description: + 'Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.', + }, + ], }, }, }, diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getServiceAuth.ts b/packages/pds/src/lexicon/types/com/atproto/server/getServiceAuth.ts index 73efe2313a9..14f249fde44 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getServiceAuth.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getServiceAuth.ts @@ -11,6 +11,10 @@ import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' export interface QueryParams { /** The DID of the service that the token will be used to authenticate with */ aud: string + /** The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope. */ + exp?: number + /** Lexicon (XRPC) method to bind the requested token to */ + lxm?: string } export type InputSchema = undefined @@ -31,6 +35,7 @@ export interface HandlerSuccess { export interface HandlerError { status: number message?: string + error?: 'BadExpiration' } export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough diff --git a/packages/pds/src/pipethrough.ts b/packages/pds/src/pipethrough.ts index af8284af2a0..855fb438202 100644 --- a/packages/pds/src/pipethrough.ts +++ b/packages/pds/src/pipethrough.ts @@ -14,14 +14,22 @@ import { ids, lexicons } from './lexicon/lexicons' import { httpLogger } from './logger' import { getServiceEndpoint, noUndefinedVals } from '@atproto/common' import AppContext from './context' +import { parseReqNsid } from '@atproto/xrpc-server' export const proxyHandler = (ctx: AppContext): CatchallHandler => { const accessStandard = ctx.authVerifier.accessStandard() return async (req, res, next) => { try { - const { url, aud } = await formatUrlAndAud(ctx, req) + const { url, aud, nsid } = await formatUrlAndAud(ctx, req) const auth = await accessStandard({ req }) - const headers = await formatHeaders(ctx, req, aud, auth.credentials.did) + if (!auth.credentials.isPrivileged && PRIVILEGED_METHODS.has(nsid)) { + throw new InvalidRequestError('Bad token method', 'InvalidToken') + } + const headers = await formatHeaders(ctx, req, { + aud, + lxm: nsid, + requester: auth.credentials.did, + }) const body: webStream.ReadableStream = stream.Readable.toWeb(req) const reqInit = formatReqInit(req, headers, body) @@ -38,10 +46,14 @@ export const pipethrough = async ( ctx: AppContext, req: express.Request, requester: string | null, - audOverride?: string, + override: { + aud?: string + lxm?: string + } = {}, ): Promise => { - const { url, aud } = await formatUrlAndAud(ctx, req, audOverride) - const headers = await formatHeaders(ctx, req, aud, requester) + const { url, aud, nsid } = await formatUrlAndAud(ctx, req, override.aud) + const lxm = override.lxm ?? nsid + const headers = await formatHeaders(ctx, req, { aud, lxm, requester }) const reqInit = formatReqInit(req, headers) const res = await makeRequest(url, reqInit) return parseProxyRes(res) @@ -53,8 +65,8 @@ export const pipethroughProcedure = async ( requester: string | null, body?: LexValue, ): Promise => { - const { url, aud } = await formatUrlAndAud(ctx, req) - const headers = await formatHeaders(ctx, req, aud, requester) + const { url, aud, nsid: lxm } = await formatUrlAndAud(ctx, req) + const headers = await formatHeaders(ctx, req, { aud, lxm, requester }) const encodedBody = body ? new TextEncoder().encode(stringifyLex(body)) : undefined @@ -77,9 +89,10 @@ export const formatUrlAndAud = async ( ctx: AppContext, req: express.Request, audOverride?: string, -): Promise<{ url: URL; aud: string }> => { +): Promise<{ url: URL; aud: string; nsid: string }> => { const proxyTo = await parseProxyHeader(ctx, req) - const defaultProxy = defaultService(ctx, req) + const nsid = parseReqNsid(req) + const defaultProxy = defaultService(ctx, nsid) const serviceUrl = proxyTo?.serviceUrl ?? defaultProxy?.url const aud = audOverride ?? proxyTo?.did ?? defaultProxy?.did if (!serviceUrl || !aud) { @@ -89,17 +102,21 @@ export const formatUrlAndAud = async ( if (!ctx.cfg.service.devMode && !isSafeUrl(url)) { throw new InvalidRequestError(`Invalid service url: ${url.toString()}`) } - return { url, aud } + return { url, aud, nsid } } export const formatHeaders = async ( ctx: AppContext, req: express.Request, - aud: string, - requester: string | null, + opts: { + aud: string + lxm: string + requester: string | null + }, ): Promise<{ authorization?: string }> => { + const { aud, lxm, requester } = opts const headers = requester - ? (await ctx.serviceAuthHeaders(requester, aud)).headers + ? (await ctx.serviceAuthHeaders(requester, aud, lxm)).headers : {} // forward select headers to upstream services for (const header of REQ_HEADERS_TO_FORWARD) { @@ -241,11 +258,28 @@ export const parseProxyRes = async (res: Response) => { // Utils // ------------------- +export const PRIVILEGED_METHODS = new Set([ + ids.ChatBskyActorDeleteAccount, + ids.ChatBskyActorExportAccountData, + ids.ChatBskyConvoDeleteMessageForSelf, + ids.ChatBskyConvoGetConvo, + ids.ChatBskyConvoGetConvoForMembers, + ids.ChatBskyConvoGetLog, + ids.ChatBskyConvoGetMessages, + ids.ChatBskyConvoLeaveConvo, + ids.ChatBskyConvoListConvos, + ids.ChatBskyConvoMuteConvo, + ids.ChatBskyConvoSendMessage, + ids.ChatBskyConvoSendMessageBatch, + ids.ChatBskyConvoUnmuteConvo, + ids.ChatBskyConvoUpdateRead, + ids.ComAtprotoServerCreateAccount, +]) + const defaultService = ( ctx: AppContext, - req: express.Request, + nsid: string, ): { url: string; did: string } | null => { - const nsid = req.originalUrl.split('?')[0].replace('/xrpc/', '') switch (nsid) { case ids.ToolsOzoneTeamAddMember: case ids.ToolsOzoneTeamDeleteMember: diff --git a/packages/pds/src/read-after-write/viewer.ts b/packages/pds/src/read-after-write/viewer.ts index 1373f9ec622..165adf81404 100644 --- a/packages/pds/src/read-after-write/viewer.ts +++ b/packages/pds/src/read-after-write/viewer.ts @@ -89,7 +89,7 @@ export class LocalViewer { return util.format(this.appviewCdnUrlPattern, pattern, this.did, cid) } - async serviceAuthHeaders(did: string) { + async serviceAuthHeaders(did: string, lxm: string) { if (!this.appviewDid) { throw new Error('Could not find bsky appview did') } @@ -98,6 +98,7 @@ export class LocalViewer { return createServiceAuthHeaders({ iss: did, aud: this.appviewDid, + lxm, keypair, }) } @@ -244,7 +245,7 @@ export class LocalViewer { if (collection === ids.AppBskyFeedPost) { const res = await this.appViewAgent.api.app.bsky.feed.getPosts( { uris: [embed.record.uri] }, - await this.serviceAuthHeaders(this.did), + await this.serviceAuthHeaders(this.did, ids.AppBskyFeedGetPosts), ) const post = res.data.posts[0] if (!post) return null @@ -261,7 +262,10 @@ export class LocalViewer { } else if (collection === ids.AppBskyFeedGenerator) { const res = await this.appViewAgent.api.app.bsky.feed.getFeedGenerator( { feed: embed.record.uri }, - await this.serviceAuthHeaders(this.did), + await this.serviceAuthHeaders( + this.did, + ids.AppBskyFeedGetFeedGenerator, + ), ) return { $type: 'app.bsky.feed.defs#generatorView', @@ -270,7 +274,7 @@ export class LocalViewer { } else if (collection === ids.AppBskyGraphList) { const res = await this.appViewAgent.api.app.bsky.graph.getList( { list: embed.record.uri }, - await this.serviceAuthHeaders(this.did), + await this.serviceAuthHeaders(this.did, ids.AppBskyGraphGetList), ) return { $type: 'app.bsky.graph.defs#listView', diff --git a/packages/pds/tests/moderator-auth.test.ts b/packages/pds/tests/moderator-auth.test.ts index 0b89b4f0fd7..fca2ce02e3c 100644 --- a/packages/pds/tests/moderator-auth.test.ts +++ b/packages/pds/tests/moderator-auth.test.ts @@ -76,6 +76,7 @@ describe('moderator auth', () => { const headers = await createServiceAuthHeaders({ iss: modServiceDid, aud: pdsDid, + lxm: null, keypair: modServiceKey, }) await agent.api.com.atproto.admin.updateSubjectStatus( @@ -103,6 +104,7 @@ describe('moderator auth', () => { const headers = await createServiceAuthHeaders({ iss: altModDid, aud: pdsDid, + lxm: null, keypair: modServiceKey, }) const attempt = agent.api.com.atproto.admin.updateSubjectStatus( @@ -123,6 +125,7 @@ describe('moderator auth', () => { const headers = await createServiceAuthHeaders({ iss: modServiceDid, aud: pdsDid, + lxm: null, keypair: badKey, }) const attempt = agent.api.com.atproto.admin.updateSubjectStatus( @@ -145,6 +148,7 @@ describe('moderator auth', () => { const headers = await createServiceAuthHeaders({ iss: modServiceDid, aud: sc.dids.alice, + lxm: null, keypair: modServiceKey, }) const attempt = agent.api.com.atproto.admin.updateSubjectStatus( diff --git a/packages/pds/tests/proxied/notif.test.ts b/packages/pds/tests/proxied/notif.test.ts index a02b291fa75..57ced2fb84c 100644 --- a/packages/pds/tests/proxied/notif.test.ts +++ b/packages/pds/tests/proxied/notif.test.ts @@ -69,6 +69,7 @@ describe('notif service proxy', () => { const auth = await verifyJwt( spy.current?.['jwt'] as string, notifDid, + null, async (did) => { const keypair = await network.pds.ctx.actorStore.keypair(did) return keypair.did() diff --git a/packages/pds/tests/proxied/proxy-header.test.ts b/packages/pds/tests/proxied/proxy-header.test.ts index d00dc3bb342..9eb9842c1ee 100644 --- a/packages/pds/tests/proxied/proxy-header.test.ts +++ b/packages/pds/tests/proxied/proxy-header.test.ts @@ -66,6 +66,7 @@ describe('proxy header', () => { const verified = await verifyJwt( req.auth.replace('Bearer ', ''), proxyServer.did, + null, (iss) => network.pds.ctx.idResolver.did.resolveAtprotoKey(iss, true), ) expect(verified.aud).toBe(proxyServer.did) diff --git a/packages/xrpc-server/src/auth.ts b/packages/xrpc-server/src/auth.ts index 32373248f51..548579d1eb3 100644 --- a/packages/xrpc-server/src/auth.ts +++ b/packages/xrpc-server/src/auth.ts @@ -4,14 +4,20 @@ import * as crypto from '@atproto/crypto' import * as ui8 from 'uint8arrays' import { AuthRequiredError } from './types' -type ServiceJwtPayload = { +type ServiceJwtParams = { iss: string aud: string exp?: number + lxm: string | null + keypair: crypto.Keypair } -type ServiceJwtParams = ServiceJwtPayload & { - keypair: crypto.Keypair +type ServiceJwtPayload = { + iss: string + aud: string + exp: number + lxm?: string + jti?: string } export const createServiceJwt = async ( @@ -19,15 +25,19 @@ export const createServiceJwt = async ( ): Promise => { const { iss, aud, keypair } = params const exp = params.exp ?? Math.floor((Date.now() + MINUTE) / 1000) + const lxm = params.lxm ?? undefined + const jti = await crypto.randomStr(16, 'hex') const header = { typ: 'JWT', alg: keypair.jwtAlg, } - const payload = { + const payload = common.noUndefinedVals({ iss, aud, exp, - } + lxm, + jti, + }) const toSignStr = `${jsonToB64Url(header)}.${jsonToB64Url(payload)}` const toSign = ui8.fromString(toSignStr, 'utf8') const sig = await keypair.sign(toSign) @@ -48,6 +58,7 @@ const jsonToB64Url = (json: Record): string => { export const verifyJwt = async ( jwtStr: string, ownDid: string | null, // null indicates to skip the audience check + lxm: string | null, // null indicates to skip the lxm check getSigningKey: (iss: string, forceRefresh: boolean) => Promise, ): Promise => { const parts = jwtStr.split('.') @@ -66,6 +77,12 @@ export const verifyJwt = async ( 'BadJwtAudience', ) } + if (lxm !== null && lxm !== payload.lxm) { + throw new AuthRequiredError( + `missing jwt lexicon method ("lxm"): ${lxm}`, + 'MissingJwtMethod', + ) + } const msgBytes = ui8.fromString(parts.slice(0, 2).join('.'), 'utf8') const sigBytes = ui8.fromString(sig, 'base64url') @@ -117,20 +134,18 @@ const parseB64UrlToJson = (b64: string) => { return JSON.parse(common.b64UrlToUtf8(b64)) } -const parsePayload = (b64: string): JwtPayload => { +const parsePayload = (b64: string): ServiceJwtPayload => { const payload = parseB64UrlToJson(b64) - if (!payload || typeof payload !== 'object') { - throw new AuthRequiredError('poorly formatted jwt', 'BadJwt') - } else if (typeof payload.exp !== 'number') { - throw new AuthRequiredError('poorly formatted jwt', 'BadJwt') - } else if (typeof payload.iss !== 'string') { + if ( + !payload || + typeof payload !== 'object' || + typeof payload.iss !== 'string' || + typeof payload.aud !== 'string' || + typeof payload.exp !== 'number' || + (payload.lxm && typeof payload.lxm !== 'string') || + (payload.nonce && typeof payload.nonce !== 'string') + ) { throw new AuthRequiredError('poorly formatted jwt', 'BadJwt') } return payload } - -type JwtPayload = { - iss: string - aud: string - exp: number -} diff --git a/packages/xrpc-server/src/index.ts b/packages/xrpc-server/src/index.ts index 1458d2ba070..5efd15d0e62 100644 --- a/packages/xrpc-server/src/index.ts +++ b/packages/xrpc-server/src/index.ts @@ -5,4 +5,4 @@ export * from './stream' export * from './rate-limiter' export type { ServerTiming } from './util' -export { serverTimingHeader, ServerTimer } from './util' +export { parseReqNsid, serverTimingHeader, ServerTimer } from './util' diff --git a/packages/xrpc-server/src/util.ts b/packages/xrpc-server/src/util.ts index ed78dbc070c..47f9a5e764f 100644 --- a/packages/xrpc-server/src/util.ts +++ b/packages/xrpc-server/src/util.ts @@ -306,3 +306,8 @@ export interface ServerTiming { duration?: number description?: string } + +export const parseReqNsid = (req: express.Request): string => { + const nsid = req.originalUrl.split('?')[0].replace('/xrpc/', '') + return nsid.endsWith('/') ? nsid.slice(0, -1) : nsid // trim trailing slash +} diff --git a/packages/xrpc-server/tests/auth.test.ts b/packages/xrpc-server/tests/auth.test.ts index bbd202d1024..a342ed21684 100644 --- a/packages/xrpc-server/tests/auth.test.ts +++ b/packages/xrpc-server/tests/auth.test.ts @@ -70,10 +70,52 @@ describe('Auth', () => { s = await createServer(port, server) client = xrpc.service(`http://localhost:${port}`) }) + afterAll(async () => { await closeServer(s) }) + it('creates and validates service auth headers', async () => { + const keypair = await Secp256k1Keypair.create() + const iss = 'did:example:alice' + const aud = 'did:example:bob' + const token = await xrpcServer.createServiceJwt({ + iss, + aud, + keypair, + lxm: null, + }) + const validated = await xrpcServer.verifyJwt(token, null, null, async () => + keypair.did(), + ) + expect(validated.iss).toEqual(iss) + expect(validated.aud).toEqual(aud) + // should expire within the minute when no exp is provided + expect(validated.exp).toBeGreaterThan(Date.now() / 1000) + expect(validated.exp).toBeLessThan(Date.now() / 1000 + 60) + expect(typeof validated.jti).toBe('string') + expect(validated.lxm).toBeUndefined() + }) + + it('creates and validates service auth headers bound to a particular method', async () => { + const keypair = await Secp256k1Keypair.create() + const iss = 'did:example:alice' + const aud = 'did:example:bob' + const lxm = 'com.atproto.repo.createRecord' + const token = await xrpcServer.createServiceJwt({ + iss, + aud, + keypair, + lxm, + }) + const validated = await xrpcServer.verifyJwt(token, null, lxm, async () => + keypair.did(), + ) + expect(validated.iss).toEqual(iss) + expect(validated.aud).toEqual(aud) + expect(validated.lxm).toEqual(lxm) + }) + it('fails on bad auth before invalid request payload.', async () => { try { await client.call( @@ -147,10 +189,12 @@ describe('Auth', () => { iss: 'did:example:iss', keypair, exp: Math.floor((Date.now() - MINUTE) / 1000), + lxm: null, }) const tryVerify = xrpcServer.verifyJwt( jwt, 'did:example:aud', + null, async () => { return keypair.did() }, @@ -164,10 +208,12 @@ describe('Auth', () => { aud: 'did:example:aud1', iss: 'did:example:iss', keypair, + lxm: null, }) const tryVerify = xrpcServer.verifyJwt( jwt, 'did:example:aud2', + null, async () => { return keypair.did() }, @@ -177,6 +223,44 @@ describe('Auth', () => { ) }) + it('fails on bad lxm', async () => { + const keypair = await Secp256k1Keypair.create() + const jwt = await xrpcServer.createServiceJwt({ + aud: 'did:example:aud1', + iss: 'did:example:iss', + keypair, + lxm: 'com.atproto.repo.createRecord', + }) + const tryVerify = xrpcServer.verifyJwt( + jwt, + 'did:example:aud1', + 'com.atproto.repo.putRecord', + async () => { + return keypair.did() + }, + ) + await expect(tryVerify).rejects.toThrow(/missing jwt lexicon method/) + }) + + it('fails on null lxm when lxm is required', async () => { + const keypair = await Secp256k1Keypair.create() + const jwt = await xrpcServer.createServiceJwt({ + aud: 'did:example:aud1', + iss: 'did:example:iss', + keypair, + lxm: null, + }) + const tryVerify = xrpcServer.verifyJwt( + jwt, + 'did:example:aud1', + 'com.atproto.repo.putRecord', + async () => { + return keypair.did() + }, + ) + await expect(tryVerify).rejects.toThrow(/missing jwt lexicon method/) + }) + it('refreshes key on verification failure.', async () => { const keypair1 = await Secp256k1Keypair.create() const keypair2 = await Secp256k1Keypair.create() @@ -184,12 +268,14 @@ describe('Auth', () => { aud: 'did:example:aud', iss: 'did:example:iss', keypair: keypair2, + lxm: null, }) let usedKeypair1 = false let usedKeypair2 = false const tryVerify = xrpcServer.verifyJwt( jwt, 'did:example:aud', + null, async (_did, forceRefresh) => { if (forceRefresh) { usedKeypair2 = true @@ -222,6 +308,7 @@ describe('Auth', () => { const tryVerify = xrpcServer.verifyJwt( jwt, 'did:example:aud', + null, async () => { return keypair.did() },