Service auth method binding - PDS (#2668)
* pds changes only * use scope for ozone service profile * dont verify scopes on pds yet * tidy * tidy imports * changeset * add tests * another changeset * scope -> lxm * tidy * update nonce size * pr feedback * trim trailing slash * nonce -> jti * fix xrpc-server test * allow service auth on uploadBlob
This commit is contained in:
parent
a95a902bba
commit
dc471da267
.changeset
lexicons/com/atproto/server
packages
api/src/client
bsky
src
tests
dev-env/src
ozone/src
pds
src
tests
xrpc-server
5
.changeset/metal-cameras-give.md
Normal file
5
.changeset/metal-cameras-give.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/api": patch
|
||||
---
|
||||
|
||||
Add lxm and exp parameters to com.atproto.server.getServiceAuth
|
6
.changeset/selfish-emus-wink.md
Normal file
6
.changeset/selfish-emus-wink.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
"@atproto/xrpc-server": minor
|
||||
"@atproto/pds": patch
|
||||
---
|
||||
|
||||
Add lxm and nonce to signed service auth tokens.
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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}` }
|
||||
|
@ -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
|
||||
|
||||
|
@ -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}` }
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -43,6 +43,7 @@ export class DaemonContext {
|
||||
createServiceAuthHeaders({
|
||||
iss: `${cfg.service.did}#atproto_labeler`,
|
||||
aud,
|
||||
lxm: null,
|
||||
keypair: signingKey,
|
||||
})
|
||||
|
||||
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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, {
|
||||
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
@ -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,
|
||||
)),
|
||||
}),
|
||||
)
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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<UserDidOutput> => {
|
||||
userServiceAuth = async (ctx: ReqCtx): Promise<UserServiceAuthOutput> => {
|
||||
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<UserDidOutput | NullOutput> => {
|
||||
): Promise<UserServiceAuthOutput | NullOutput> => {
|
||||
if (isBearerToken(ctx.req)) {
|
||||
return await this.userDidAuth(ctx)
|
||||
return await this.userServiceAuth(ctx)
|
||||
} else {
|
||||
return this.null(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
accessOrUserServiceAuth =
|
||||
(opts: Partial<AccessOpts> = {}) =>
|
||||
async (ctx: ReqCtx): Promise<UserServiceAuthOutput | AccessOutput> => {
|
||||
try {
|
||||
return await this.accessStandard(opts)(ctx)
|
||||
} catch {
|
||||
return await this.userServiceAuth(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
modService = async (ctx: ReqCtx): Promise<ModServiceOutput> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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<Uint8Array> =
|
||||
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<HandlerPipeThrough> => {
|
||||
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<HandlerPipeThrough> => {
|
||||
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:
|
||||
|
@ -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',
|
||||
|
@ -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(
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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<string> => {
|
||||
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, unknown>): 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<string>,
|
||||
): Promise<ServiceJwtPayload> => {
|
||||
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
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user