Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Facilitate authing w/ PDS based on DID doc #1727

Merged
merged 11 commits into from
Oct 26, 2023
3 changes: 2 additions & 1 deletion lexicons/com/atproto/server/createAccount.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
},
Expand Down
1 change: 1 addition & 0 deletions lexicons/com/atproto/server/createSession.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
Expand Down
3 changes: 2 additions & 1 deletion lexicons/com/atproto/server/refreshSession.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't an addition to the api because its dependencies already depended on it

},
"devDependencies": {
"@atproto/lex-cli": "workspace:^",
Expand Down
27 changes: 26 additions & 1 deletion packages/api/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
AtpPersistSessionHandler,
AtpAgentOpts,
} from './types'
import { getPdsEndpoint } from './did/did-doc'

const REFRESH_SESSION = 'com.atproto.server.refreshSession'

Expand All @@ -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<void> | undefined
Expand Down Expand Up @@ -97,6 +103,7 @@ export class AtpAgent {
email: opts.email,
emailConfirmed: false,
}
this._updateApiEndpoint(res.data.didDoc)
return res
} catch (e) {
this.session = undefined
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Copy link
Collaborator Author

@devinivy devinivy Oct 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, missed this in original review. This endpoint getSession doesn't include the did doc—if the credentials are no longer valid due to having migrated PDSes, the client will receive an ExpiredToken error and get set back on track via the refresh flow.

Suggested change
this._updateApiEndpoint(res.data.didDoc)

return res
} catch (e) {
this.session = undefined
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,9 @@ export const schemaDict = {
type: 'string',
format: 'did',
},
didDoc: {
type: 'unknown',
},
},
},
},
Expand Down Expand Up @@ -2563,6 +2566,9 @@ export const schemaDict = {
type: 'string',
format: 'did',
},
didDoc: {
type: 'unknown',
},
email: {
type: 'string',
},
Expand Down Expand Up @@ -2879,6 +2885,9 @@ export const schemaDict = {
type: 'string',
format: 'did',
},
didDoc: {
type: 'unknown',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface OutputSchema {
refreshJwt: string
handle: string
did: string
didDoc?: {}
[k: string]: unknown
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface OutputSchema {
refreshJwt: string
handle: string
did: string
didDoc?: {}
email?: string
emailConfirmed?: boolean
[k: string]: unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface OutputSchema {
refreshJwt: string
handle: string
did: string
didDoc?: {}
[k: string]: unknown
}

Expand Down
40 changes: 40 additions & 0 deletions packages/api/src/did/did-doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { z } from 'zod'

export const verificationMethod = z.object({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm we already have something very similar this over in @atproto/identity - we may want to just use the code there instead of duplicating it over to the api package 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or if the client doesn't agree with the dependencies of that package, porting just these types & verification methods over to @atproto/common-web

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<typeof didDocument>

export function isValidDidDoc(doc: unknown): doc is DidDocument {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not required, but I think we could take this one step further & move this to common-web as well

return didDocument.safeParse(doc).success
}

export function getPdsEndpoint(doc: unknown): URL | undefined {
if (isValidDidDoc(doc)) {
const pds = doc.service?.find((s) => s.type === 'AtprotoPersonalDataServer')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in @atproto/identity, we find the first service entry with id #atproto_pds & then check the type

if (pds && typeof pds.serviceEndpoint === 'string') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this is currently taking the first PDS service entry and only handling the string value. I don't know what a non-string value would look like.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that'd be considered a valid json-ld did document, but not supported by atproto rn

this is similar to how we validate services in our identity resolver 👍

try {
return new URL(pds.serviceEndpoint)
} catch {
return undefined
}
}
}
}
3 changes: 3 additions & 0 deletions packages/api/tests/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ describe('agent', () => {
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'api_agent',
pds: {
enableDidDocWithSession: true,
},
})
})

Expand Down
9 changes: 9 additions & 0 deletions packages/bsky/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,9 @@ export const schemaDict = {
type: 'string',
format: 'did',
},
didDoc: {
type: 'unknown',
},
},
},
},
Expand Down Expand Up @@ -2563,6 +2566,9 @@ export const schemaDict = {
type: 'string',
format: 'did',
},
didDoc: {
type: 'unknown',
},
email: {
type: 'string',
},
Expand Down Expand Up @@ -2879,6 +2885,9 @@ export const schemaDict = {
type: 'string',
format: 'did',
},
didDoc: {
type: 'unknown',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface OutputSchema {
refreshJwt: string
handle: string
did: string
didDoc?: {}
[k: string]: unknown
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface OutputSchema {
refreshJwt: string
handle: string
did: string
didDoc?: {}
email?: string
emailConfirmed?: boolean
[k: string]: unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface OutputSchema {
refreshJwt: string
handle: string
did: string
didDoc?: {}
[k: string]: unknown
}

Expand Down
1 change: 1 addition & 0 deletions packages/dev-env/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const run = async () => {
port: 2583,
hostname: 'localhost',
dbPostgresSchema: 'pds',
enableDidDocWithSession: true,
},
bsky: {
dbPostgresSchema: 'bsky',
Expand Down
10 changes: 7 additions & 3 deletions packages/pds/src/api/com/atproto/server/createAccount.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
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'
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({
Expand Down Expand Up @@ -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,
},
Expand Down
9 changes: 7 additions & 2 deletions packages/pds/src/api/com/atproto/server/createSession.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/pds/src/api/com/atproto/server/refreshSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
Loading