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. diff --git a/lexicons/com/atproto/server/createAccount.json b/lexicons/com/atproto/server/createAccount.json index 7e167a92e55..4db1f31e040 100644 --- a/lexicons/com/atproto/server/createAccount.json +++ b/lexicons/com/atproto/server/createAccount.json @@ -30,7 +30,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/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..ce34865c189 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, @@ -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 @@ -253,7 +261,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 +285,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 +320,24 @@ 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) { + if (isValidDidDoc(didDoc)) { + const endpoint = getPdsEndpoint(didDoc) + this.pdsUrl = endpoint ? new URL(endpoint) : undefined + } + this.api.xrpc.uri = this.pdsUrl || this.service + } } function isErrorObject(v: unknown): v is ErrorResponseBody { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 39bc995d533..6c163eedca4 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2341,6 +2341,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, @@ -2566,6 +2569,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, email: { type: 'string', }, @@ -2882,6 +2888,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 54d1d9a1cf0..4281128cae0 100644 --- a/packages/api/src/client/types/com/atproto/server/createAccount.ts +++ b/packages/api/src/client/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/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/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/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 39bc995d533..6c163eedca4 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2341,6 +2341,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, @@ -2566,6 +2569,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, email: { type: 'string', }, @@ -2882,6 +2888,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 d50d2b24025..bd138919101 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts @@ -26,6 +26,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/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts new file mode 100644 index 00000000000..c2a05b796d3 --- /dev/null +++ b/packages/common-web/src/did-doc.ts @@ -0,0 +1,133 @@ +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): string | undefined => { + return getServiceEndpoint(doc, { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + }) +} + +export const getFeedGenEndpoint = (doc: DidDocument): string | undefined => { + return getServiceEndpoint(doc, { + id: '#bsky_fg', + type: 'BskyFeedGenerator', + }) +} + +export const getNotifEndpoint = (doc: DidDocument): string | 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): string | 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 urlStr + } +} + +// Types +// -------- + +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/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/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/packages/identity/src/did/atproto-data.ts b/packages/identity/src/did/atproto-data.ts index 3e7ee5829eb..6881bda48dc 100644 --- a/packages/identity/src/did/atproto-data.ts +++ b/packages/identity/src/did/atproto-data.ts @@ -1,73 +1,39 @@ import * as crypto from '@atproto/crypto' import { DidDocument, AtprotoData } from '../types' +import { + getDid, + getHandle, + getPdsEndpoint, + getFeedGenEndpoint, + getNotifEndpoint, + getSigningKey, +} from '@atproto/common-web' -export const getDid = (doc: DidDocument): string => { - const id = doc.id - if (typeof id !== 'string') { - throw new Error('No `id` on document') - } - return id +export { + getDid, + getHandle, + getPdsEndpoint as getPds, + getFeedGenEndpoint as getFeedGen, + getNotifEndpoint as getNotif, } 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 + const key = getSigningKey(doc) + if (!key) 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) + 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 => { @@ -76,7 +42,7 @@ export const parseToAtprotoDocument = ( did, signingKey: getKey(doc), handle: getHandle(doc), - pds: getPds(doc), + pds: getPdsEndpoint(doc), } } @@ -96,39 +62,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 -} diff --git a/packages/identity/src/types.ts b/packages/identity/src/types.ts index a3604f72f73..cd7996ee3cc 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 @@ -48,25 +51,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 diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index c58e1547863..36bdc7b6b86 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({ @@ -121,11 +122,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 93e64d7bbcd..64872d5aae1 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -1,8 +1,9 @@ +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 { DAY, MINUTE } from '@atproto/common' +import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createSession({ @@ -54,15 +55,16 @@ export default function (server: Server, ctx: AppContext) { ) } - const { access, refresh } = await authService.createSession( - user.did, - appPasswordName, - ) + const [{ access, refresh }, didDoc] = await Promise.all([ + authService.createSession(user.did, appPasswordName), + didDocForSession(ctx, user.did), + ]) 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 8db33e289de..0ab39e4d8cc 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -2,6 +2,7 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import AppContext from '../../../../context' import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' +import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.refreshSession({ @@ -21,12 +22,15 @@ export default function (server: Server, ctx: AppContext) { ) } - const res = await ctx.db.transaction((dbTxn) => { - return ctx.services - .auth(dbTxn) - .rotateRefreshToken(auth.credentials.tokenId) - }) - if (res === null) { + const [didDoc, rotated] = await Promise.all([ + didDocForSession(ctx, user.did), + ctx.db.transaction((dbTxn) => { + return ctx.services + .auth(dbTxn) + .rotateRefreshToken(auth.credentials.tokenId) + }), + ]) + if (rotated === null) { throw new InvalidRequestError('Token has been revoked', 'ExpiredToken') } @@ -34,9 +38,10 @@ export default function (server: Server, ctx: AppContext) { encoding: 'application/json', body: { did: user.did, + didDoc, handle: user.handle, - accessJwt: res.access.jwt, - refreshJwt: res.refresh.jwt, + accessJwt: rotated.access.jwt, + refreshJwt: rotated.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 diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 39bc995d533..6c163eedca4 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2341,6 +2341,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, }, }, }, @@ -2566,6 +2569,9 @@ export const schemaDict = { type: 'string', format: 'did', }, + didDoc: { + type: 'unknown', + }, email: { type: 'string', }, @@ -2882,6 +2888,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 d50d2b24025..bd138919101 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts @@ -26,6 +26,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 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d48398fe9cb..1500b3ece5d 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:^ @@ -407,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