From c78eaf57fbe569565805d7e5e16425fba1323282 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 9 Oct 2023 17:27:05 -0400 Subject: [PATCH 01/10] lexicon for did doc w/ auth credentials --- lexicons/com/atproto/server/createAccount.json | 3 ++- lexicons/com/atproto/server/createSession.json | 1 + lexicons/com/atproto/server/refreshSession.json | 3 ++- packages/api/src/client/lexicons.ts | 9 +++++++++ .../src/client/types/com/atproto/server/createAccount.ts | 1 + .../src/client/types/com/atproto/server/createSession.ts | 1 + .../client/types/com/atproto/server/refreshSession.ts | 1 + packages/bsky/src/lexicon/lexicons.ts | 9 +++++++++ .../lexicon/types/com/atproto/server/createAccount.ts | 1 + .../lexicon/types/com/atproto/server/createSession.ts | 1 + .../lexicon/types/com/atproto/server/refreshSession.ts | 1 + packages/pds/src/lexicon/lexicons.ts | 9 +++++++++ .../lexicon/types/com/atproto/server/createAccount.ts | 1 + .../lexicon/types/com/atproto/server/createSession.ts | 1 + .../lexicon/types/com/atproto/server/refreshSession.ts | 1 + 15 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lexicons/com/atproto/server/createAccount.json b/lexicons/com/atproto/server/createAccount.json index 9fd09740fda..3b69c4daada 100644 --- a/lexicons/com/atproto/server/createAccount.json +++ b/lexicons/com/atproto/server/createAccount.json @@ -29,7 +29,8 @@ "accessJwt": { "type": "string" }, "refreshJwt": { "type": "string" }, "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" } + "did": { "type": "string", "format": "did" }, + "didDoc": { "type": "unknown" } } } }, diff --git a/lexicons/com/atproto/server/createSession.json b/lexicons/com/atproto/server/createSession.json index 7d877cec91c..cef01b45b35 100644 --- a/lexicons/com/atproto/server/createSession.json +++ b/lexicons/com/atproto/server/createSession.json @@ -29,6 +29,7 @@ "refreshJwt": { "type": "string" }, "handle": { "type": "string", "format": "handle" }, "did": { "type": "string", "format": "did" }, + "didDoc": { "type": "unknown" }, "email": { "type": "string" }, "emailConfirmed": { "type": "boolean" } } diff --git a/lexicons/com/atproto/server/refreshSession.json b/lexicons/com/atproto/server/refreshSession.json index ab895a34c94..3f4d7fdf272 100644 --- a/lexicons/com/atproto/server/refreshSession.json +++ b/lexicons/com/atproto/server/refreshSession.json @@ -14,7 +14,8 @@ "accessJwt": { "type": "string" }, "refreshJwt": { "type": "string" }, "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" } + "did": { "type": "string", "format": "did" }, + "didDoc": { "type": "unknown" } } } }, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index ded6b1f86f6..13a0fef4c8c 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2338,6 +2338,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, @@ -2563,6 +2566,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, email: { type: 'string', }, @@ -2879,6 +2885,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, diff --git a/packages/api/src/client/types/com/atproto/server/createAccount.ts b/packages/api/src/client/types/com/atproto/server/createAccount.ts index 3eeaab250b4..2d54f700492 100644 --- a/packages/api/src/client/types/com/atproto/server/createAccount.ts +++ b/packages/api/src/client/types/com/atproto/server/createAccount.ts @@ -24,6 +24,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/createSession.ts b/packages/api/src/client/types/com/atproto/server/createSession.ts index 08d2bcd6225..a06a7a86c6c 100644 --- a/packages/api/src/client/types/com/atproto/server/createSession.ts +++ b/packages/api/src/client/types/com/atproto/server/createSession.ts @@ -21,6 +21,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} email?: string emailConfirmed?: boolean [k: string]: unknown diff --git a/packages/api/src/client/types/com/atproto/server/refreshSession.ts b/packages/api/src/client/types/com/atproto/server/refreshSession.ts index 5b531b19e9d..5519e352920 100644 --- a/packages/api/src/client/types/com/atproto/server/refreshSession.ts +++ b/packages/api/src/client/types/com/atproto/server/refreshSession.ts @@ -16,6 +16,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index ded6b1f86f6..13a0fef4c8c 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2338,6 +2338,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, @@ -2563,6 +2566,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, email: { type: 'string', }, @@ -2879,6 +2885,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts index c67e7445bf9..70aeeacbf86 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts @@ -25,6 +25,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts index 037900346a1..2cd448703a6 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts @@ -22,6 +22,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} email?: string emailConfirmed?: boolean [k: string]: unknown diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/refreshSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/refreshSession.ts index e47bf09fbc2..35874f78a69 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/refreshSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/refreshSession.ts @@ -17,6 +17,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} [k: string]: unknown } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index ded6b1f86f6..13a0fef4c8c 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2338,6 +2338,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, @@ -2563,6 +2566,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, email: { type: 'string', }, @@ -2879,6 +2885,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts index c67e7445bf9..70aeeacbf86 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts @@ -25,6 +25,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts index 037900346a1..2cd448703a6 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts @@ -22,6 +22,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} email?: string emailConfirmed?: boolean [k: string]: unknown diff --git a/packages/pds/src/lexicon/types/com/atproto/server/refreshSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/refreshSession.ts index e47bf09fbc2..35874f78a69 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/refreshSession.ts @@ -17,6 +17,7 @@ export interface OutputSchema { refreshJwt: string handle: string did: string + didDoc?: {} [k: string]: unknown } From c2754a18f02538fe1a11d8d5d117aa343f076428 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 9 Oct 2023 18:37:29 -0400 Subject: [PATCH 02/10] include did doc w/ session when configured. configure on dev-env. --- packages/dev-env/src/bin.ts | 1 + .../api/com/atproto/server/createAccount.ts | 10 +++++++--- .../api/com/atproto/server/createSession.ts | 9 +++++++-- .../api/com/atproto/server/refreshSession.ts | 4 ++++ .../pds/src/api/com/atproto/server/util.ts | 18 ++++++++++++++++++ packages/pds/src/config/config.ts | 2 ++ packages/pds/src/config/env.ts | 2 ++ 7 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/dev-env/src/bin.ts b/packages/dev-env/src/bin.ts index c03f8a76900..12228579a48 100644 --- a/packages/dev-env/src/bin.ts +++ b/packages/dev-env/src/bin.ts @@ -19,6 +19,7 @@ const run = async () => { port: 2583, hostname: 'localhost', dbPostgresSchema: 'pds', + enableDidDocWithSession: true, }, bsky: { dbPostgresSchema: 'bsky', diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 334c2f2b132..0c7d55e531c 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,7 +1,9 @@ +import { MINUTE } from '@atproto/common' +import { AtprotoData } from '@atproto/identity' import { InvalidRequestError } from '@atproto/xrpc-server' +import * as plc from '@did-plc/lib' import disposable from 'disposable-email' import { normalizeAndValidateHandle } from '../../../../handle' -import * as plc from '@did-plc/lib' import * as scrypt from '../../../../db/scrypt' import { Server } from '../../../../lexicon' import { InputSchema as CreateAccountInput } from '../../../../lexicon/types/com/atproto/server/createAccount' @@ -9,8 +11,7 @@ import { countAll } from '../../../../db/util' import { UserAlreadyExistsError } from '../../../../services/account' import AppContext from '../../../../context' import Database from '../../../../db' -import { AtprotoData } from '@atproto/identity' -import { MINUTE } from '@atproto/common' +import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -118,11 +119,14 @@ export default function (server: Server, ctx: AppContext) { } }) + const didDoc = await didDocForSession(ctx, result.did, true) + return { encoding: 'application/json', body: { handle, did: result.did, + didDoc, accessJwt: result.accessJwt, refreshJwt: result.refreshJwt, }, diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 6d8d57e471e..d3f96634a7b 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -1,9 +1,10 @@ +import { DAY, MINUTE } from '@atproto/common' import { AuthRequiredError } from '@atproto/xrpc-server' import AppContext from '../../../../context' import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' import { AuthScope } from '../../../../auth' -import { DAY, MINUTE } from '@atproto/common' +import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createSession({ @@ -60,12 +61,16 @@ export default function (server: Server, ctx: AppContext) { scope: appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, }) const refresh = ctx.auth.createRefreshToken({ did: user.did }) - await authService.grantRefreshToken(refresh.payload, appPasswordName) + const [didDoc] = await Promise.all([ + didDocForSession(ctx, user.did), + authService.grantRefreshToken(refresh.payload, appPasswordName), + ]) return { encoding: 'application/json', body: { did: user.did, + didDoc, handle: user.handle, email: user.email, emailConfirmed: !!user.emailConfirmedAt, diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 0fda8ba48a7..7af7700067a 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -3,6 +3,7 @@ import AppContext from '../../../../context' import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' import { AuthScope } from '../../../../auth' +import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.refreshSession({ @@ -50,10 +51,13 @@ export default function (server: Server, ctx: AppContext) { scope: res.appPassName === null ? AuthScope.Access : AuthScope.AppPass, }) + const didDoc = await didDocForSession(ctx, user.did) + return { encoding: 'application/json', body: { did: user.did, + didDoc, handle: user.handle, accessJwt: access.jwt, refreshJwt: res.refresh.jwt, diff --git a/packages/pds/src/api/com/atproto/server/util.ts b/packages/pds/src/api/com/atproto/server/util.ts index 71bd1ae219c..fc3bfae8e05 100644 --- a/packages/pds/src/api/com/atproto/server/util.ts +++ b/packages/pds/src/api/com/atproto/server/util.ts @@ -1,5 +1,8 @@ import * as crypto from '@atproto/crypto' +import { DidDocument } from '@atproto/identity' import { ServerConfig } from '../../../../config' +import AppContext from '../../../../context' +import { dbLogger } from '../../../../logger' // generate an invite code preceded by the hostname // with '.'s replaced by '-'s so it is not mistakable for a link @@ -22,3 +25,18 @@ export const getRandomToken = () => { const token = crypto.randomStr(8, 'base32').slice(0, 10) return token.slice(0, 5) + '-' + token.slice(5, 10) } + +// @TODO once supporting multiple pdses, validate pds in did doc based on allow-list. +export const didDocForSession = async ( + ctx: AppContext, + did: string, + forceRefresh?: boolean, +): Promise => { + if (!ctx.cfg.identity.enableDidDocWithSession) return + try { + const didDoc = await ctx.idResolver.did.resolve(did, forceRefresh) + return didDoc ?? undefined + } catch (err) { + dbLogger.warn({ err, did }, 'failed to resolve did doc') + } +} diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 68d043e6431..58040abd781 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -91,6 +91,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { recoveryDidKey: env.recoveryDidKey ?? null, serviceHandleDomains, handleBackupNameservers: env.handleBackupNameservers, + enableDidDocWithSession: !!env.enableDidDocWithSession, } // default to being required if left undefined @@ -253,6 +254,7 @@ export type IdentityConfig = { recoveryDidKey: string | null serviceHandleDomains: string[] handleBackupNameservers?: string[] + enableDidDocWithSession: boolean } export type InvitesConfig = diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 170e26d5976..2c13124b4c9 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -36,6 +36,7 @@ export const readEnv = (): ServerEnvironment => { recoveryDidKey: envStr('PDS_RECOVERY_DID_KEY'), serviceHandleDomains: envList('PDS_SERVICE_HANDLE_DOMAINS'), handleBackupNameservers: envList('PDS_HANDLE_BACKUP_NAMESERVERS'), + enableDidDocWithSession: envBool('PDS_ENABLE_DID_DOC_WITH_SESSION'), // invites inviteRequired: envBool('PDS_INVITE_REQUIRED'), @@ -125,6 +126,7 @@ export type ServerEnvironment = { recoveryDidKey?: string serviceHandleDomains?: string[] // public hostname by default handleBackupNameservers?: string[] + enableDidDocWithSession?: boolean // invites inviteRequired?: boolean From 7e3622b41c98186d6c556d7f6f6f20725f6926c0 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 10 Oct 2023 18:36:42 -0700 Subject: [PATCH 03/10] Add dynamic PDS URL adoption to the client --- packages/api/package.json | 3 ++- packages/api/src/agent.ts | 27 ++++++++++++++++++++- packages/api/src/did/did-doc.ts | 40 ++++++++++++++++++++++++++++++++ packages/api/tests/agent.test.ts | 3 +++ pnpm-lock.yaml | 3 +++ 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/did/did-doc.ts diff --git a/packages/api/package.json b/packages/api/package.json index a8af7b6b3d7..e4a1d662ea4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -36,7 +36,8 @@ "@atproto/xrpc": "workspace:^", "multiformats": "^9.9.0", "tlds": "^1.234.0", - "typed-emitter": "^2.1.0" + "typed-emitter": "^2.1.0", + "zod": "^3.21.4" }, "devDependencies": { "@atproto/lex-cli": "workspace:^", diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index 2cd4c44e7a0..e19e6b948ee 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -18,6 +18,7 @@ import { AtpPersistSessionHandler, AtpAgentOpts, } from './types' +import { getPdsEndpoint } from './did/did-doc' const REFRESH_SESSION = 'com.atproto.server.refreshSession' @@ -30,6 +31,11 @@ export class AtpAgent { api: AtpServiceClient session?: AtpSessionData + /** + * The PDS URL, driven by the did doc. May be undefined. + */ + pdsUrl: URL | undefined + private _baseClient: AtpBaseClient private _persistSession?: AtpPersistSessionHandler private _refreshSessionPromise: Promise | undefined @@ -97,6 +103,7 @@ export class AtpAgent { email: opts.email, emailConfirmed: false, } + this._updateApiEndpoint(res.data.didDoc) return res } catch (e) { this.session = undefined @@ -129,6 +136,7 @@ export class AtpAgent { email: res.data.email, emailConfirmed: res.data.emailConfirmed, } + this._updateApiEndpoint(res.data.didDoc) return res } catch (e) { this.session = undefined @@ -157,6 +165,7 @@ export class AtpAgent { this.session.email = res.data.email this.session.handle = res.data.handle this.session.emailConfirmed = res.data.emailConfirmed + this._updateApiEndpoint(res.data.didDoc) return res } catch (e) { this.session = undefined @@ -253,7 +262,7 @@ export class AtpAgent { } // send the refresh request - const url = new URL(this.service.origin) + const url = new URL((this.pdsUrl || this.service).origin) url.pathname = `/xrpc/${REFRESH_SESSION}` const res = await AtpAgent.fetch( url.toString(), @@ -277,6 +286,7 @@ export class AtpAgent { handle: res.body.handle, did: res.body.did, } + this._updateApiEndpoint(res.body.didDoc) this._persistSession?.('update', this.session) } // else: other failures should be ignored - the issue will @@ -311,6 +321,21 @@ export class AtpAgent { */ createModerationReport: typeof this.api.com.atproto.moderation.createReport = (data, opts) => this.api.com.atproto.moderation.createReport(data, opts) + + /** + * Helper to update the pds endpoint dynamically. + * + * The session methods (create, resume, refresh) may respond with the user's + * did document which contains the user's canonical PDS endpoint. That endpoint + * may differ from the endpoint used to contact the server. We capture that + * PDS endpoint and update the client to use that given endpoint for future + * requests. (This helps ensure smooth migrations between PDSes, especially + * when the PDSes are operated by a single org.) + */ + private _updateApiEndpoint(didDoc: unknown) { + this.pdsUrl = getPdsEndpoint(didDoc) + this.api.xrpc.uri = this.pdsUrl || this.service + } } function isErrorObject(v: unknown): v is ErrorResponseBody { diff --git a/packages/api/src/did/did-doc.ts b/packages/api/src/did/did-doc.ts new file mode 100644 index 00000000000..690c4c7747c --- /dev/null +++ b/packages/api/src/did/did-doc.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' + +export const verificationMethod = z.object({ + id: z.string(), + type: z.string(), + controller: z.string(), + publicKeyMultibase: z.string().optional(), +}) + +export const service = z.object({ + id: z.string(), + type: z.string(), + serviceEndpoint: z.union([z.string(), z.record(z.unknown())]), +}) + +export const didDocument = z.object({ + id: z.string(), + alsoKnownAs: z.array(z.string()).optional(), + verificationMethod: z.array(verificationMethod).optional(), + service: z.array(service).optional(), +}) + +export type DidDocument = z.infer + +export function isValidDidDoc(doc: unknown): doc is DidDocument { + return didDocument.safeParse(doc).success +} + +export function getPdsEndpoint(doc: unknown): URL | undefined { + if (isValidDidDoc(doc)) { + const pds = doc.service?.find((s) => s.type === 'AtprotoPersonalDataServer') + if (pds && typeof pds.serviceEndpoint === 'string') { + try { + return new URL(pds.serviceEndpoint) + } catch { + return undefined + } + } + } +} diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index 933326c43f2..7f85f3079af 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -13,6 +13,9 @@ describe('agent', () => { beforeAll(async () => { network = await TestNetworkNoAppView.create({ dbPostgresSchema: 'api_agent', + pds: { + enableDidDocWithSession: true, + }, }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c899913626..5eab41d6acc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: typed-emitter: specifier: ^2.1.0 version: 2.1.0 + zod: + specifier: ^3.21.4 + version: 3.21.4 devDependencies: '@atproto/dev-env': specifier: workspace:^ From 412171a6e9983b4bc944d6ac9e930d09ae35b843 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 24 Oct 2023 10:23:46 -0400 Subject: [PATCH 04/10] remove usage of did doc field from getsession in client --- packages/api/src/agent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index e19e6b948ee..4fa8edf6114 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -165,7 +165,6 @@ export class AtpAgent { this.session.email = res.data.email this.session.handle = res.data.handle this.session.emailConfirmed = res.data.emailConfirmed - this._updateApiEndpoint(res.data.didDoc) return res } catch (e) { this.session = undefined From deb9efc93069eaf112cd616bf8d5590138c75409 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 24 Oct 2023 17:40:29 -0400 Subject: [PATCH 05/10] dry-up did doc type and validation --- packages/api/src/did/did-doc.ts | 34 ++++++++---------------------- packages/common-web/src/did-doc.ts | 23 ++++++++++++++++++++ packages/common-web/src/index.ts | 1 + packages/identity/src/types.ts | 27 ++++-------------------- 4 files changed, 37 insertions(+), 48 deletions(-) create mode 100644 packages/common-web/src/did-doc.ts diff --git a/packages/api/src/did/did-doc.ts b/packages/api/src/did/did-doc.ts index 690c4c7747c..f1cb3bcef33 100644 --- a/packages/api/src/did/did-doc.ts +++ b/packages/api/src/did/did-doc.ts @@ -1,26 +1,4 @@ -import { z } from 'zod' - -export const verificationMethod = z.object({ - id: z.string(), - type: z.string(), - controller: z.string(), - publicKeyMultibase: z.string().optional(), -}) - -export const service = z.object({ - id: z.string(), - type: z.string(), - serviceEndpoint: z.union([z.string(), z.record(z.unknown())]), -}) - -export const didDocument = z.object({ - id: z.string(), - alsoKnownAs: z.array(z.string()).optional(), - verificationMethod: z.array(verificationMethod).optional(), - service: z.array(service).optional(), -}) - -export type DidDocument = z.infer +import { DidDocument, didDocument } from '@atproto/common-web' export function isValidDidDoc(doc: unknown): doc is DidDocument { return didDocument.safeParse(doc).success @@ -28,8 +6,14 @@ export function isValidDidDoc(doc: unknown): doc is DidDocument { export function getPdsEndpoint(doc: unknown): URL | undefined { if (isValidDidDoc(doc)) { - const pds = doc.service?.find((s) => s.type === 'AtprotoPersonalDataServer') - if (pds && typeof pds.serviceEndpoint === 'string') { + const pds = doc.service?.find( + (s) => s.id === '#atproto_pds' || s.id === `${doc.id}#atproto_pds`, + ) + if ( + pds && + pds.type === 'AtprotoPersonalDataServer' && + typeof pds.serviceEndpoint === 'string' + ) { try { return new URL(pds.serviceEndpoint) } catch { diff --git a/packages/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts new file mode 100644 index 00000000000..27ec0018c89 --- /dev/null +++ b/packages/common-web/src/did-doc.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' + +const verificationMethod = z.object({ + id: z.string(), + type: z.string(), + controller: z.string(), + publicKeyMultibase: z.string().optional(), +}) + +const service = z.object({ + id: z.string(), + type: z.string(), + serviceEndpoint: z.union([z.string(), z.record(z.unknown())]), +}) + +export const didDocument = z.object({ + id: z.string(), + alsoKnownAs: z.array(z.string()).optional(), + verificationMethod: z.array(verificationMethod).optional(), + service: z.array(service).optional(), +}) + +export type DidDocument = z.infer diff --git a/packages/common-web/src/index.ts b/packages/common-web/src/index.ts index e125677496f..8352123536a 100644 --- a/packages/common-web/src/index.ts +++ b/packages/common-web/src/index.ts @@ -9,3 +9,4 @@ export * from './ipld' export * from './types' export * from './times' export * from './strings' +export * from './did-doc' diff --git a/packages/identity/src/types.ts b/packages/identity/src/types.ts index f1d983e6742..532ea203047 100644 --- a/packages/identity/src/types.ts +++ b/packages/identity/src/types.ts @@ -1,4 +1,7 @@ -import * as z from 'zod' +import { DidDocument } from '@atproto/common-web' + +export { didDocument } from '@atproto/common-web' +export type { DidDocument } from '@atproto/common-web' export type IdentityResolverOpts = { timeout?: number @@ -42,25 +45,3 @@ export interface DidCache { clearEntry(did: string): Promise clear(): Promise } - -export const verificationMethod = z.object({ - id: z.string(), - type: z.string(), - controller: z.string(), - publicKeyMultibase: z.string().optional(), -}) - -export const service = z.object({ - id: z.string(), - type: z.string(), - serviceEndpoint: z.union([z.string(), z.record(z.unknown())]), -}) - -export const didDocument = z.object({ - id: z.string(), - alsoKnownAs: z.array(z.string()).optional(), - verificationMethod: z.array(verificationMethod).optional(), - service: z.array(service).optional(), -}) - -export type DidDocument = z.infer From bb413b52e634464df64e03ba5049e777d6d907a6 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 24 Oct 2023 17:42:50 -0400 Subject: [PATCH 06/10] remove explicit dep on zod by identity package --- packages/identity/package.json | 3 +-- pnpm-lock.yaml | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/identity/package.json b/packages/identity/package.json index 59de4cc414c..96e654c503b 100644 --- a/packages/identity/package.json +++ b/packages/identity/package.json @@ -29,8 +29,7 @@ "dependencies": { "@atproto/common-web": "workspace:^", "@atproto/crypto": "workspace:^", - "axios": "^0.27.2", - "zod": "^3.21.4" + "axios": "^0.27.2" }, "devDependencies": { "@did-plc/lib": "^0.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe5eb3901ce..1500b3ece5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,9 +410,6 @@ importers: axios: specifier: ^0.27.2 version: 0.27.2 - zod: - specifier: ^3.21.4 - version: 3.21.4 devDependencies: '@did-plc/lib': specifier: ^0.0.1 From 0424019ca16beec420ac1eaee1582c85caa599af Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 26 Oct 2023 14:44:14 -0500 Subject: [PATCH 07/10] move more did doc parsing to common-web --- packages/api/src/agent.ts | 6 +- packages/api/src/did/did-doc.ts | 24 ----- packages/common-web/src/did-doc.ts | 110 ++++++++++++++++++++ packages/identity/src/did/atproto-data.ts | 116 ++++------------------ 4 files changed, 132 insertions(+), 124 deletions(-) delete mode 100644 packages/api/src/did/did-doc.ts diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index 4fa8edf6114..afc8015c284 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -1,5 +1,6 @@ import { ErrorResponseBody, errorResponseBody } from '@atproto/xrpc' import { defaultFetchHandler } from '@atproto/xrpc' +import { isValidDidDoc, getPdsEndpoint } from '@atproto/common-web' import { AtpBaseClient, AtpServiceClient, @@ -18,7 +19,6 @@ import { AtpPersistSessionHandler, AtpAgentOpts, } from './types' -import { getPdsEndpoint } from './did/did-doc' const REFRESH_SESSION = 'com.atproto.server.refreshSession' @@ -332,7 +332,9 @@ export class AtpAgent { * when the PDSes are operated by a single org.) */ private _updateApiEndpoint(didDoc: unknown) { - this.pdsUrl = getPdsEndpoint(didDoc) + if (isValidDidDoc(didDoc)) { + this.pdsUrl = getPdsEndpoint(didDoc) + } this.api.xrpc.uri = this.pdsUrl || this.service } } diff --git a/packages/api/src/did/did-doc.ts b/packages/api/src/did/did-doc.ts deleted file mode 100644 index f1cb3bcef33..00000000000 --- a/packages/api/src/did/did-doc.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { DidDocument, didDocument } from '@atproto/common-web' - -export function isValidDidDoc(doc: unknown): doc is DidDocument { - return didDocument.safeParse(doc).success -} - -export function getPdsEndpoint(doc: unknown): URL | undefined { - if (isValidDidDoc(doc)) { - const pds = doc.service?.find( - (s) => s.id === '#atproto_pds' || s.id === `${doc.id}#atproto_pds`, - ) - if ( - pds && - pds.type === 'AtprotoPersonalDataServer' && - typeof pds.serviceEndpoint === 'string' - ) { - try { - return new URL(pds.serviceEndpoint) - } catch { - return undefined - } - } - } -} diff --git a/packages/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts index 27ec0018c89..1e8855e0e21 100644 --- a/packages/common-web/src/did-doc.ts +++ b/packages/common-web/src/did-doc.ts @@ -1,5 +1,115 @@ import { z } from 'zod' +// Parsing atproto data +// -------- + +export const isValidDidDoc = (doc: unknown): doc is DidDocument => { + return didDocument.safeParse(doc).success +} + +export const getDid = (doc: DidDocument): string => { + const id = doc.id + if (typeof id !== 'string') { + throw new Error('No `id` on document') + } + return id +} + +export const getHandle = (doc: DidDocument): string | undefined => { + const aka = doc.alsoKnownAs + if (!aka) return undefined + const found = aka.find((name) => name.startsWith('at://')) + if (!found) return undefined + // strip off at:// prefix + return found.slice(5) +} + +// @NOTE we parse to type/publicKeyMultibase to avoid the dependency on @atproto/crypto +export const getSigningKey = ( + doc: DidDocument, +): { type: string; publicKeyMultibase: string } | undefined => { + const did = getDid(doc) + let keys = doc.verificationMethod + if (!keys) return undefined + if (typeof keys !== 'object') return undefined + if (!Array.isArray(keys)) { + keys = [keys] + } + const found = keys.find( + (key) => key.id === '#atproto' || key.id === `${did}#atproto`, + ) + if (!found?.publicKeyMultibase) return undefined + return { + type: found.type, + publicKeyMultibase: found.publicKeyMultibase, + } +} + +export const getPdsEndpoint = (doc: DidDocument): URL | undefined => { + return getServiceEndpoint(doc, { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + }) +} + +export const getFeedGenEndpoint = (doc: DidDocument): URL | undefined => { + return getServiceEndpoint(doc, { + id: '#bsky_fg', + type: 'BskyFeedGenerator', + }) +} + +export const getNotifEndpoint = (doc: DidDocument): URL | undefined => { + return getServiceEndpoint(doc, { + id: '#bsky_notif', + type: 'BskyNotificationService', + }) +} + +export const getServiceEndpoint = ( + doc: DidDocument, + opts: { id: string; type: string }, +) => { + const did = getDid(doc) + let services = doc.service + if (!services) return undefined + if (typeof services !== 'object') return undefined + if (!Array.isArray(services)) { + services = [services] + } + const found = services.find( + (service) => service.id === opts.id || service.id === `${did}${opts.id}`, + ) + if (!found) return undefined + if (found.type !== opts.type) { + return undefined + } + if (typeof found.serviceEndpoint !== 'string') { + return undefined + } + return validateUrl(found.serviceEndpoint) +} + +// Check protocol and hostname to prevent potential SSRF +const validateUrl = (urlStr: string): URL | undefined => { + let url + try { + url = new URL(urlStr) + } catch { + return undefined + } + if (!['http:', 'https:'].includes(url.protocol)) { + return undefined + } else if (!url.hostname) { + return undefined + } else { + return url + } +} + +// Types +// -------- + const verificationMethod = z.object({ id: z.string(), type: z.string(), diff --git a/packages/identity/src/did/atproto-data.ts b/packages/identity/src/did/atproto-data.ts index 3e7ee5829eb..3b3d074a786 100644 --- a/packages/identity/src/did/atproto-data.ts +++ b/packages/identity/src/did/atproto-data.ts @@ -1,82 +1,38 @@ import * as crypto from '@atproto/crypto' import { DidDocument, AtprotoData } from '../types' - -export const getDid = (doc: DidDocument): string => { - const id = doc.id - if (typeof id !== 'string') { - throw new Error('No `id` on document') - } - return id -} - -export const getKey = (doc: DidDocument): string | undefined => { - const did = getDid(doc) - let keys = doc.verificationMethod - if (!keys) return undefined - if (typeof keys !== 'object') return undefined - if (!Array.isArray(keys)) { - keys = [keys] - } - const found = keys.find( - (key) => key.id === '#atproto' || key.id === `${did}#atproto`, - ) - if (!found) return undefined - - // @TODO support jwk - // should we be surfacing errors here or returning undefined? - if (!found.publicKeyMultibase) return undefined - const keyBytes = crypto.multibaseToBytes(found.publicKeyMultibase) +import { + getDid, + getHandle, + getPdsEndpoint, + getSigningKey, +} from '@atproto/common-web' + +export const parseKey = (doc: DidDocument): string | undefined => { + const key = getSigningKey(doc) + if (!key) return undefined + + const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase) let didKey: string | undefined = undefined - if (found.type === 'EcdsaSecp256r1VerificationKey2019') { + if (key.type === 'EcdsaSecp256r1VerificationKey2019') { didKey = crypto.formatDidKey(crypto.P256_JWT_ALG, keyBytes) - } else if (found.type === 'EcdsaSecp256k1VerificationKey2019') { + } else if (key.type === 'EcdsaSecp256k1VerificationKey2019') { didKey = crypto.formatDidKey(crypto.SECP256K1_JWT_ALG, keyBytes) - } else if (found.type === 'Multikey') { - const parsed = crypto.parseMultikey(found.publicKeyMultibase) + } else if (key.type === 'Multikey') { + const parsed = crypto.parseMultikey(key.publicKeyMultibase) didKey = crypto.formatDidKey(parsed.jwtAlg, parsed.keyBytes) } return didKey } -export const getHandle = (doc: DidDocument): string | undefined => { - const aka = doc.alsoKnownAs - if (!aka) return undefined - const found = aka.find((name) => name.startsWith('at://')) - if (!found) return undefined - // strip off at:// prefix - return found.slice(5) -} - -export const getPds = (doc: DidDocument): string | undefined => { - return getServiceEndpoint(doc, { - id: '#atproto_pds', - type: 'AtprotoPersonalDataServer', - }) -} - -export const getFeedGen = (doc: DidDocument): string | undefined => { - return getServiceEndpoint(doc, { - id: '#bsky_fg', - type: 'BskyFeedGenerator', - }) -} - -export const getNotif = (doc: DidDocument): string | undefined => { - return getServiceEndpoint(doc, { - id: '#bsky_notif', - type: 'BskyNotificationService', - }) -} - export const parseToAtprotoDocument = ( doc: DidDocument, ): Partial => { const did = getDid(doc) return { did, - signingKey: getKey(doc), + signingKey: parseKey(doc), handle: getHandle(doc), - pds: getPds(doc), + pds: getPdsEndpoint(doc)?.toString(), } } @@ -96,39 +52,3 @@ export const ensureAtpDocument = (doc: DidDocument): AtprotoData => { } return { did, signingKey, handle, pds } } - -// Check protocol and hostname to prevent potential SSRF -const validateUrl = (url: string) => { - const { hostname, protocol } = new URL(url) - if (!['http:', 'https:'].includes(protocol)) { - throw new Error('Invalid pds protocol') - } - if (!hostname) { - throw new Error('Invalid pds hostname') - } -} - -const getServiceEndpoint = ( - doc: DidDocument, - opts: { id: string; type: string }, -) => { - const did = getDid(doc) - let services = doc.service - if (!services) return undefined - if (typeof services !== 'object') return undefined - if (!Array.isArray(services)) { - services = [services] - } - const found = services.find( - (service) => service.id === opts.id || service.id === `${did}${opts.id}`, - ) - if (!found) return undefined - if (found.type !== opts.type) { - return undefined - } - if (typeof found.serviceEndpoint !== 'string') { - return undefined - } - validateUrl(found.serviceEndpoint) - return found.serviceEndpoint -} From 763a3f81f929f4229719d7fc93e190c3f0345b0c Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 26 Oct 2023 14:48:10 -0500 Subject: [PATCH 08/10] go back to strings --- packages/api/src/agent.ts | 3 ++- packages/common-web/src/did-doc.ts | 10 +++++----- packages/identity/src/did/atproto-data.ts | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index afc8015c284..ce34865c189 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -333,7 +333,8 @@ export class AtpAgent { */ private _updateApiEndpoint(didDoc: unknown) { if (isValidDidDoc(didDoc)) { - this.pdsUrl = getPdsEndpoint(didDoc) + const endpoint = getPdsEndpoint(didDoc) + this.pdsUrl = endpoint ? new URL(endpoint) : undefined } this.api.xrpc.uri = this.pdsUrl || this.service } diff --git a/packages/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts index 1e8855e0e21..c2a05b796d3 100644 --- a/packages/common-web/src/did-doc.ts +++ b/packages/common-web/src/did-doc.ts @@ -45,21 +45,21 @@ export const getSigningKey = ( } } -export const getPdsEndpoint = (doc: DidDocument): URL | undefined => { +export const getPdsEndpoint = (doc: DidDocument): string | undefined => { return getServiceEndpoint(doc, { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', }) } -export const getFeedGenEndpoint = (doc: DidDocument): URL | undefined => { +export const getFeedGenEndpoint = (doc: DidDocument): string | undefined => { return getServiceEndpoint(doc, { id: '#bsky_fg', type: 'BskyFeedGenerator', }) } -export const getNotifEndpoint = (doc: DidDocument): URL | undefined => { +export const getNotifEndpoint = (doc: DidDocument): string | undefined => { return getServiceEndpoint(doc, { id: '#bsky_notif', type: 'BskyNotificationService', @@ -91,7 +91,7 @@ export const getServiceEndpoint = ( } // Check protocol and hostname to prevent potential SSRF -const validateUrl = (urlStr: string): URL | undefined => { +const validateUrl = (urlStr: string): string | undefined => { let url try { url = new URL(urlStr) @@ -103,7 +103,7 @@ const validateUrl = (urlStr: string): URL | undefined => { } else if (!url.hostname) { return undefined } else { - return url + return urlStr } } diff --git a/packages/identity/src/did/atproto-data.ts b/packages/identity/src/did/atproto-data.ts index 3b3d074a786..b367754cfc3 100644 --- a/packages/identity/src/did/atproto-data.ts +++ b/packages/identity/src/did/atproto-data.ts @@ -32,7 +32,7 @@ export const parseToAtprotoDocument = ( did, signingKey: parseKey(doc), handle: getHandle(doc), - pds: getPdsEndpoint(doc)?.toString(), + pds: getPdsEndpoint(doc), } } From 791c2854cfb929e9b7770e340746a4f0ad4ee74d Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 26 Oct 2023 18:13:49 -0400 Subject: [PATCH 09/10] rollback breaking changes to identity package --- packages/identity/src/did/atproto-data.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/identity/src/did/atproto-data.ts b/packages/identity/src/did/atproto-data.ts index b367754cfc3..6881bda48dc 100644 --- a/packages/identity/src/did/atproto-data.ts +++ b/packages/identity/src/did/atproto-data.ts @@ -4,10 +4,20 @@ import { getDid, getHandle, getPdsEndpoint, + getFeedGenEndpoint, + getNotifEndpoint, getSigningKey, } from '@atproto/common-web' -export const parseKey = (doc: DidDocument): string | undefined => { +export { + getDid, + getHandle, + getPdsEndpoint as getPds, + getFeedGenEndpoint as getFeedGen, + getNotifEndpoint as getNotif, +} + +export const getKey = (doc: DidDocument): string | undefined => { const key = getSigningKey(doc) if (!key) return undefined @@ -30,7 +40,7 @@ export const parseToAtprotoDocument = ( const did = getDid(doc) return { did, - signingKey: parseKey(doc), + signingKey: getKey(doc), handle: getHandle(doc), pds: getPdsEndpoint(doc), } From faba675bb5bb00603d9f050cb812fdfb773bd511 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 26 Oct 2023 18:25:26 -0400 Subject: [PATCH 10/10] add changeset --- .changeset/purple-shirts-punch.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/purple-shirts-punch.md diff --git a/.changeset/purple-shirts-punch.md b/.changeset/purple-shirts-punch.md new file mode 100644 index 00000000000..b0a6c734454 --- /dev/null +++ b/.changeset/purple-shirts-punch.md @@ -0,0 +1,10 @@ +--- +'@atproto/common-web': patch +'@atproto/identity': patch +'@atproto/dev-env': patch +'@atproto/bsky': patch +'@atproto/api': patch +'@atproto/pds': patch +--- + +Allow pds to serve did doc with credentials, API client to respect PDS listed in the did doc.